Merge pull request #3746 from vitorpamplona/claude/health-connect-activity-consolidation-y8ao9e

Merge close workout sessions to reduce noise in Health Connect suggestions
This commit is contained in:
Vitor Pamplona
2026-07-27 13:50:40 -04:00
committed by GitHub
6 changed files with 399 additions and 3 deletions
@@ -30,7 +30,9 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
* UI directly.
*
* [id] is the Health Connect record id, used to remember which sessions the
* user has already handled (accepted or dismissed) so each is offered once.
* user has already handled (accepted or dismissed) so each is offered once. When
* several close-by sessions of the same type are combined by [WorkoutMerger],
* [id] becomes the members' ids joined with `+` and [sessionCount] rises above 1.
*/
@Immutable
data class DetectedWorkout(
@@ -47,4 +49,10 @@ data class DetectedWorkout(
val elevationGainMeters: Double?,
/** Human-readable name of the app/device that wrote the record (e.g. "Samsung Health"). */
val source: String,
/**
* How many Health Connect sessions this workout represents. 1 for a raw
* session; higher when [WorkoutMerger] combined several close-by same-type
* sessions (e.g. a long run split around breaks) into a single suggestion.
*/
val sessionCount: Int = 1,
)
@@ -142,8 +142,11 @@ class HealthConnectManager(
)
Log.i(TAG) { "readNewWorkouts: ${response.records.size} exercise session(s) in window $since .. $now" }
val mapped = response.records.mapNotNull { mapSession(it) }
Log.i(TAG) { "readNewWorkouts: mapped ${mapped.size} workout(s) after type/duration filtering" }
mapped
// Fold split-up sessions of the same activity (a long run broken around
// breaks) into one suggestion so the composer offers the whole effort.
val merged = WorkoutMerger.mergeCloseWorkouts(mapped)
Log.i(TAG) { "readNewWorkouts: mapped ${mapped.size} -> ${merged.size} workout(s) after type/duration filtering and merging" }
merged
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w(TAG, "Failed to read workouts from Health Connect", e)
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.workouts.health
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
import kotlin.math.roundToInt
/**
* Combines Health Connect exercise sessions that belong to the same real-world
* effort. Watches, Strava, and auto-pause features often split one long workout —
* a 5-hour run with coffee/water breaks — into several back-to-back
* `ExerciseSessionRecord`s of the same type. Posting each break as its own
* workout is noise; the user wants the whole run as one thing.
*
* [mergeCloseWorkouts] walks the sessions in start-time order and joins any run
* of same-type sessions whose consecutive gap (start of the next minus the end of
* the previous) is under [DEFAULT_MAX_GAP_SECONDS] into a single [DetectedWorkout]:
* distance, calories, steps, elevation and duration are summed, heart rate is
* duration-weighted, max heart rate is the max, and [DetectedWorkout.sessionCount]
* records how many sessions were folded in.
*
* Sessions of a different type occurring between two same-type sessions do not
* break the chain — a brief cross-training block mid-run still lets the two run
* segments merge — because gaps are tracked per exercise type.
*/
object WorkoutMerger {
/** Sessions less than one hour apart are treated as one workout. */
const val DEFAULT_MAX_GAP_SECONDS = 3600L
/**
* Merges close-by same-type sessions in [workouts]. Input order is irrelevant
* (sessions are sorted by start time internally). The result is ordered by
* each combined workout's start time, ascending. A gap of exactly
* [maxGapSeconds] does NOT merge — only strictly closer sessions do.
*/
fun mergeCloseWorkouts(
workouts: List<DetectedWorkout>,
maxGapSeconds: Long = DEFAULT_MAX_GAP_SECONDS,
): List<DetectedWorkout> {
if (workouts.size < 2) return workouts
val sorted = workouts.sortedBy { it.startTimeEpochSeconds }
val groups = mutableListOf<MutableList<DetectedWorkout>>()
// Most recent still-open group per exercise type, plus the latest end time
// seen for that type, so an interleaved activity of a different type never
// splits a run of same-type sessions.
val openGroupByType = HashMap<ExerciseType, MutableList<DetectedWorkout>>()
val lastEndByType = HashMap<ExerciseType, Long>()
for (workout in sorted) {
val openGroup = openGroupByType[workout.exercise]
val lastEnd = lastEndByType[workout.exercise]
val closeEnough = lastEnd != null && workout.startTimeEpochSeconds - lastEnd < maxGapSeconds
if (openGroup != null && closeEnough) {
openGroup.add(workout)
} else {
val newGroup = mutableListOf(workout)
groups.add(newGroup)
openGroupByType[workout.exercise] = newGroup
}
lastEndByType[workout.exercise] = maxOf(lastEnd ?: Long.MIN_VALUE, endOf(workout))
}
return groups.map { combine(it) }
}
/** End of a raw session: its start plus its (contiguous) duration. */
private fun endOf(workout: DetectedWorkout): Long = workout.startTimeEpochSeconds + workout.durationSeconds
/** Folds a group (already sorted by start time) into one [DetectedWorkout]. */
private fun combine(group: List<DetectedWorkout>): DetectedWorkout {
if (group.size == 1) return group.first()
val earliest = group.first()
val distance = group.mapNotNull { it.distanceMeters }.takeIf { it.isNotEmpty() }?.sum()
val calories = group.mapNotNull { it.calories }.takeIf { it.isNotEmpty() }?.sum()
val steps = group.mapNotNull { it.steps }.takeIf { it.isNotEmpty() }?.sum()
val elevation = group.mapNotNull { it.elevationGainMeters }.takeIf { it.isNotEmpty() }?.sum()
val maxHeartRate = group.mapNotNull { it.maxHeartRate }.maxOrNull()
return DetectedWorkout(
id = group.joinToString("+") { it.id },
exercise = earliest.exercise,
title = group.firstNotNullOfOrNull { it.title?.takeIf(String::isNotBlank) },
startTimeEpochSeconds = earliest.startTimeEpochSeconds,
durationSeconds = group.sumOf { it.durationSeconds },
distanceMeters = distance,
calories = calories,
avgHeartRate = weightedAvgHeartRate(group),
maxHeartRate = maxHeartRate,
steps = steps,
elevationGainMeters = elevation,
source = earliest.source,
sessionCount = group.sumOf { it.sessionCount },
)
}
/**
* Duration-weighted average heart rate across the members that reported one,
* so a 4-hour leg dominates a 5-minute leg. Null when no member has a heart
* rate; falls back to a plain average if every contributing leg has zero
* duration (shouldn't happen for real sessions).
*/
private fun weightedAvgHeartRate(group: List<DetectedWorkout>): Int? {
val withHeartRate = group.filter { it.avgHeartRate != null }
if (withHeartRate.isEmpty()) return null
val weightSum = withHeartRate.sumOf { it.durationSeconds }
return if (weightSum > 0) {
withHeartRate
.sumOf { it.avgHeartRate!!.toDouble() * it.durationSeconds }
.div(weightSum)
.roundToInt()
} else {
withHeartRate.map { it.avgHeartRate!! }.average().roundToInt()
}
}
}
@@ -210,6 +210,9 @@ private fun summaryLine(workout: DetectedWorkout): String {
parts.add(stringRes(R.string.workout_suggestion_distance_km, "%.2f".format(it / 1000.0)))
}
parts.add(formatWorkoutDuration(workout.durationSeconds))
if (workout.sessionCount > 1) {
parts.add(stringRes(R.string.workout_suggestion_combined_sessions, workout.sessionCount))
}
return parts.joinToString(" · ")
}
+1
View File
@@ -792,6 +792,7 @@
<string name="workout_suggestion_connect_button">Connect</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="workout_from_health_connect">From Health Connect</string>
<string name="workout_suggestion_combined_sessions">%1$d activities</string>
<string name="exercise_running">Running</string>
<string name="exercise_walking">Walking</string>
<string name="exercise_cycling">Cycling</string>
@@ -0,0 +1,242 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.workouts.health
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class WorkoutMergerTest {
private val hour = 3600L
private fun workout(
id: String,
exercise: ExerciseType = ExerciseType.RUNNING,
startTimeEpochSeconds: Long,
durationSeconds: Long,
distanceMeters: Double? = null,
calories: Int? = null,
avgHeartRate: Int? = null,
maxHeartRate: Int? = null,
steps: Int? = null,
elevationGainMeters: Double? = null,
title: String? = null,
source: String = "Samsung Health",
) = DetectedWorkout(
id = id,
exercise = exercise,
title = title,
startTimeEpochSeconds = startTimeEpochSeconds,
durationSeconds = durationSeconds,
distanceMeters = distanceMeters,
calories = calories,
avgHeartRate = avgHeartRate,
maxHeartRate = maxHeartRate,
steps = steps,
elevationGainMeters = elevationGainMeters,
source = source,
)
@Test
fun emptyListPassesThrough() {
assertTrue(WorkoutMerger.mergeCloseWorkouts(emptyList()).isEmpty())
}
@Test
fun singleWorkoutIsReturnedUnchanged() {
val one = workout("a", startTimeEpochSeconds = 0, durationSeconds = 100)
val result = WorkoutMerger.mergeCloseWorkouts(listOf(one))
assertEquals(1, result.size)
assertSame(one, result.first())
assertEquals(1, result.first().sessionCount)
}
@Test
fun twoCloseSameTypeSessionsMergeAndSumMetrics() {
// Run 10:00-11:00, then Run 11:30-12:00 (30 min gap < 1h): one 90-min run.
val first =
workout(
id = "first",
startTimeEpochSeconds = 0,
durationSeconds = hour,
distanceMeters = 10_000.0,
calories = 600,
steps = 12_000,
elevationGainMeters = 50.0,
)
val second =
workout(
id = "second",
startTimeEpochSeconds = hour + 1800, // starts 30 min after first ends
durationSeconds = 1800,
distanceMeters = 5_000.0,
calories = 300,
steps = 6_000,
elevationGainMeters = 25.0,
)
val result = WorkoutMerger.mergeCloseWorkouts(listOf(first, second))
assertEquals(1, result.size)
val merged = result.first()
assertEquals("first+second", merged.id)
assertEquals(2, merged.sessionCount)
assertEquals(0, merged.startTimeEpochSeconds) // earliest start
assertEquals(hour + 1800, merged.durationSeconds) // summed active duration
assertEquals(15_000.0, merged.distanceMeters!!, 0.0001)
assertEquals(900, merged.calories)
assertEquals(18_000, merged.steps)
assertEquals(75.0, merged.elevationGainMeters!!, 0.0001)
}
@Test
fun sameTypeButFarApartDoesNotMerge() {
val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour)
// Starts 90 min after the first one ends -> gap exceeds the 1h threshold.
val second = workout("second", startTimeEpochSeconds = hour + hour + 1800, durationSeconds = hour)
val result = WorkoutMerger.mergeCloseWorkouts(listOf(first, second))
assertEquals(2, result.size)
assertEquals(listOf("first", "second"), result.map { it.id })
}
@Test
fun gapOfExactlyOneHourDoesNotMerge() {
val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour)
// Starts exactly 1h after the first ends -> boundary is exclusive.
val second = workout("second", startTimeEpochSeconds = hour + hour, durationSeconds = hour)
val result = WorkoutMerger.mergeCloseWorkouts(listOf(first, second))
assertEquals(2, result.size)
}
@Test
fun differentTypesCloseTogetherDoNotMerge() {
val run = workout("run", exercise = ExerciseType.RUNNING, startTimeEpochSeconds = 0, durationSeconds = hour)
val ride = workout("ride", exercise = ExerciseType.CYCLING, startTimeEpochSeconds = hour + 60, durationSeconds = hour)
val result = WorkoutMerger.mergeCloseWorkouts(listOf(run, ride))
assertEquals(2, result.size)
}
@Test
fun interleavedOtherTypeDoesNotBreakSameTypeChain() {
// Run, then a short walk during a break, then Run again — the two runs
// are close in time and should still combine across the walk.
val run1 = workout("run1", exercise = ExerciseType.RUNNING, startTimeEpochSeconds = 0, durationSeconds = hour)
val walk = workout("walk", exercise = ExerciseType.WALKING, startTimeEpochSeconds = hour + 300, durationSeconds = 600)
val run2 = workout("run2", exercise = ExerciseType.RUNNING, startTimeEpochSeconds = hour + 1200, durationSeconds = hour)
val result = WorkoutMerger.mergeCloseWorkouts(listOf(run1, walk, run2))
assertEquals(2, result.size)
val run = result.first { it.exercise == ExerciseType.RUNNING }
assertEquals("run1+run2", run.id)
assertEquals(2, run.sessionCount)
assertEquals(2 * hour, run.durationSeconds)
assertEquals(1, result.first { it.exercise == ExerciseType.WALKING }.sessionCount)
}
@Test
fun heartRateIsDurationWeighted() {
// 1h at 120 bpm + 0.5h at 150 bpm -> (120*3600 + 150*1800) / 5400 = 130.
val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour, avgHeartRate = 120, maxHeartRate = 140)
val second = workout("second", startTimeEpochSeconds = hour + 60, durationSeconds = 1800, avgHeartRate = 150, maxHeartRate = 175)
val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single()
assertEquals(130, merged.avgHeartRate)
assertEquals(175, merged.maxHeartRate)
}
@Test
fun nullMetricsAreSummedOnlyWhenPresent() {
val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour, distanceMeters = 10_000.0, calories = null)
val second = workout("second", startTimeEpochSeconds = hour + 60, durationSeconds = 1800, distanceMeters = null, calories = 200)
val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single()
// Distance present only on one -> that value survives; calories only on the other.
assertEquals(10_000.0, merged.distanceMeters!!, 0.0001)
assertEquals(200, merged.calories)
}
@Test
fun allNullMetricStaysNull() {
val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour)
val second = workout("second", startTimeEpochSeconds = hour + 60, durationSeconds = 1800)
val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single()
assertNull(merged.distanceMeters)
assertNull(merged.calories)
assertNull(merged.avgHeartRate)
assertNull(merged.maxHeartRate)
assertNull(merged.steps)
assertNull(merged.elevationGainMeters)
}
@Test
fun titleTakesFirstNonBlank() {
val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour, title = " ")
val second = workout("second", startTimeEpochSeconds = hour + 60, durationSeconds = 1800, title = "Morning long run")
val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single()
assertEquals("Morning long run", merged.title)
}
@Test
fun unsortedInputProducesResultsOrderedByStart() {
// Three same-type runs, all within an hour of each other, given out of order.
val a = workout("a", startTimeEpochSeconds = 0, durationSeconds = 600)
val b = workout("b", startTimeEpochSeconds = 1200, durationSeconds = 600)
val c = workout("c", startTimeEpochSeconds = 2400, durationSeconds = 600)
val merged = WorkoutMerger.mergeCloseWorkouts(listOf(c, a, b)).single()
assertEquals("a+b+c", merged.id)
assertEquals(3, merged.sessionCount)
assertEquals(0, merged.startTimeEpochSeconds)
assertEquals(1800, merged.durationSeconds)
}
@Test
fun chainMergesEvenWhenAdjacentGapsAreShortButEndsAreFarApart() {
// 45-min sessions each starting 50 min apart: consecutive gaps are 5 min,
// so the whole chain merges though first and last are hours apart.
val sessions =
(0 until 5).map {
workout("s$it", startTimeEpochSeconds = it * 3000L, durationSeconds = 2700)
}
val merged = WorkoutMerger.mergeCloseWorkouts(sessions)
assertEquals(1, merged.size)
assertEquals(5, merged.single().sessionCount)
}
}