diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt index bfb18e2e8b..f8f8637e55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt index 9daaaedb05..051c914ec5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMerger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMerger.kt new file mode 100644 index 0000000000..90e10fa22a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMerger.kt @@ -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, + maxGapSeconds: Long = DEFAULT_MAX_GAP_SECONDS, + ): List { + if (workouts.size < 2) return workouts + + val sorted = workouts.sortedBy { it.startTimeEpochSeconds } + + val groups = mutableListOf>() + // 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>() + val lastEndByType = HashMap() + + 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 { + 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): 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() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt index 146472d60b..a64f15d1bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt @@ -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(" · ") } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0253dfbb19..a743a7dadd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -792,6 +792,7 @@ Connect %1$s km From Health Connect + %1$d activities Running Walking Cycling diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt new file mode 100644 index 0000000000..f8ea473932 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt @@ -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) + } +}