Merge pull request #4151 from vitorpamplona/claude/serene-maxwell-rw2uxd

Progressive Health Connect reads: show partial results while metrics load
This commit is contained in:
Vitor Pamplona
2026-09-20 21:34:11 -04:00
committed by GitHub
9 changed files with 841 additions and 100 deletions
@@ -38,7 +38,15 @@ import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.time.Duration
import java.time.Instant
import kotlin.math.roundToInt
@@ -66,6 +74,16 @@ class HealthConnectManager(
/** How far back the New Workout carousel looks for workouts to offer. */
const val LOOKBACK_DAYS = 7L
/**
* How many per-session metric aggregations may be in flight at once.
*
* Each is an independent binder transaction into the Health Connect provider, so running
* them in series made the read scale with the window length. Capped rather than unbounded
* because the provider serves them from a finite thread/transaction pool, and a four-week
* window for a daily trainer would otherwise fire dozens at once.
*/
private const val MAX_CONCURRENT_AGGREGATES = 6
private const val DEFAULT_SOURCE = "Health Connect"
/** Friendly names for well-known writers when their app isn't installed to read a label from. */
@@ -124,49 +142,153 @@ class HealthConnectManager(
}
/**
* All exercise sessions that ended within [since]..[now], mapped to
* [DetectedWorkout]. Sessions whose activity type Amethyst cannot represent
* are skipped. Returns an empty list (never throws) if Health Connect is
* unavailable or a read fails.
* One stage of a progressive Health Connect read. See [readWorkoutsProgressively].
*/
data class WorkoutRead(
val workouts: List<DetectedWorkout>,
/**
* True while the per-session aggregate metrics (distance, calories, heart rate, steps,
* elevation) are still being fetched, so [workouts] currently carries nulls for them.
*/
val metricsPending: Boolean,
)
/**
* All exercise sessions that ended within [since]..[now], mapped to [DetectedWorkout], in two
* stages.
*
* The session list itself is one IPC. Every optional metric — distance, calories, heart rate,
* steps, elevation — needs a *separate* [aggregate] round trip per session, and a four-week
* window for someone who trains daily is dozens of them. Waiting for all of that before
* showing anything is what made the My Fitness dashboard sit on a spinner for seconds.
*
* So this emits twice:
* 1. the sessions with what the read already knows — activity, title, start, duration,
* source — and [WorkoutRead.metricsPending] true;
* 2. the same workouts with their metrics filled in, and the flag false.
*
* That first emission already carries everything the dashboard's counts, durations, streak,
* active days and per-activity split need. `WorkoutStats` totals and bests treat an absent
* metric as contributing nothing rather than zero, so the partial pass is honest rather than
* wrong — a distance cell is missing until it is known, not shown as 0.
*
* A single-emission caller that wants the finished numbers takes [readWorkouts].
*
* Aggregation is per *raw* session and merging happens after it, exactly as a one-shot read
* did: [WorkoutMerger] sums the members' metrics, so aggregating a merged span instead would
* fold in the breaks between segments and change the totals.
*
* Emits a single empty result (never throws) if Health Connect is unavailable or the read
* fails.
*/
fun readWorkoutsProgressively(
since: Instant,
now: Instant = Instant.now(),
): Flow<WorkoutRead> =
// flowOn(IO): callers collect from Dispatchers.Main, Health Connect's own calls suspend,
// but the PackageManager lookup in resolveSourceName is a blocking binder call — so the
// whole read moves off the UI thread rather than relying on each step to behave.
flow {
if (!isAvailable(context)) {
Log.i(TAG) { "readWorkouts: Health Connect unavailable (status=${HealthConnectClient.getSdkStatus(context)})" }
emit(WorkoutRead(emptyList(), metricsPending = false))
return@flow
}
val sessions =
try {
client
.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(since, now),
),
).records
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w(TAG, "Failed to read workouts from Health Connect", e)
emit(WorkoutRead(emptyList(), metricsPending = false))
return@flow
}
Log.i(TAG) { "readWorkouts: ${sessions.size} exercise session(s) in window $since .. $now" }
// Kept paired with their sessions: stage 2 aggregates over each raw session's own
// time range, which the mapped workout no longer carries once merging rewrites it.
val skeletons = sessions.mapNotNull { session -> mapSession(session)?.let { session to it } }
if (skeletons.isEmpty()) {
Log.i(TAG) { "readWorkouts: no mappable sessions" }
emit(WorkoutRead(emptyList(), metricsPending = false))
return@flow
}
emit(WorkoutRead(merge(skeletons.map { it.second }), metricsPending = true))
// Bounded fan-out rather than one-at-a-time: these are independent IPCs into the
// provider, and running them in series is what the window length multiplied. The
// permit cap keeps a month of daily training from opening 60 concurrent binder
// transactions at a provider that has a finite pool for them.
val detailed =
coroutineScope {
val gate = Semaphore(MAX_CONCURRENT_AGGREGATES)
skeletons
.map { (session, workout) ->
async { gate.withPermit { workout.withMetrics(aggregate(session)) } }
}.awaitAll()
}
val merged = merge(detailed)
Log.i(TAG) { "readWorkouts: mapped ${detailed.size} -> ${merged.size} workout(s) after type/duration filtering and merging" }
emit(WorkoutRead(merged, metricsPending = false))
}.flowOn(Dispatchers.IO)
/**
* All exercise sessions in [since]..[now], with their metrics — the finished result of
* [readWorkoutsProgressively]. For callers that have nothing useful to show from a partial
* read and so gain nothing from the intermediate stage.
*/
suspend fun readWorkouts(
since: Instant,
now: Instant = Instant.now(),
): List<DetectedWorkout> {
if (!isAvailable(context)) {
Log.i(TAG) { "readWorkouts: Health Connect unavailable (status=${HealthConnectClient.getSdkStatus(context)})" }
return emptyList()
}
): List<DetectedWorkout> = readWorkoutsProgressively(since, now).last().workouts
// The callers are composables launching into rememberCoroutineScope(), i.e.
// Dispatchers.Main. Health Connect's own calls suspend, but the PackageManager
// lookup in resolveSourceName is a blocking binder call, so the whole read
// moves off the UI thread rather than relying on each step to behave.
return withContext(Dispatchers.IO) {
try {
val response =
client.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(since, now),
),
)
Log.i(TAG) { "readWorkouts: ${response.records.size} exercise session(s) in window $since .. $now" }
val mapped = response.records.mapNotNull { mapSession(it) }
// 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) { "readWorkouts: 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)
emptyList()
}
}
/**
* Folds split-up sessions of the same activity (a long run broken around breaks) into one
* workout so the composer offers the whole effort.
*/
private fun merge(workouts: List<DetectedWorkout>) = WorkoutMerger.mergeCloseWorkouts(workouts)
/** Copies [totals] — the result of one [aggregate] call — onto a session skeleton. */
private fun DetectedWorkout.withMetrics(totals: AggregationResult?): DetectedWorkout {
if (totals == null) return this
return copy(
distanceMeters = totals[DistanceRecord.DISTANCE_TOTAL]?.inMeters,
// Prefer active calories (what RUNSTR publishes); fall back to total for
// sources that only record total energy. Total includes basal burn, so it
// over-reports the workout if used as the primary figure.
calories =
(
totals[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
?: totals[TotalCaloriesBurnedRecord.ENERGY_TOTAL]?.inKilocalories
)?.roundToInt(),
avgHeartRate = totals[HeartRateRecord.BPM_AVG]?.toInt(),
maxHeartRate = totals[HeartRateRecord.BPM_MAX]?.toInt(),
steps = totals[StepsRecord.COUNT_TOTAL]?.toInt(),
elevationGainMeters = totals[ElevationGainedRecord.ELEVATION_GAINED_TOTAL]?.inMeters,
)
}
private suspend fun mapSession(session: ExerciseSessionRecord): DetectedWorkout? {
/**
* The session as the record itself describes it: activity, title, when, how long, who wrote
* it. The optional metrics are left null for [withMetrics] to fill in — they each cost their
* own IPC, so they are not part of mapping a session.
*
* Null when the activity type is one Amethyst cannot represent, or the session has no
* positive duration.
*/
private fun mapSession(session: ExerciseSessionRecord): DetectedWorkout? {
val exercise = ExerciseTypeMapper.toExerciseType(session.exerciseType)
if (exercise == null) {
Log.i(TAG) {
@@ -187,27 +309,18 @@ class HealthConnectManager(
"from ${session.metadata.dataOrigin.packageName}"
}
val totals = aggregate(session)
return DetectedWorkout(
id = session.metadata.id,
exercise = exercise,
title = session.title?.takeIf { it.isNotBlank() },
startTimeEpochSeconds = session.startTime.epochSecond,
durationSeconds = durationSeconds,
distanceMeters = totals?.get(DistanceRecord.DISTANCE_TOTAL)?.inMeters,
// Prefer active calories (what RUNSTR publishes); fall back to total for
// sources that only record total energy. Total includes basal burn, so it
// over-reports the workout if used as the primary figure.
calories =
(
totals?.get(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL)?.inKilocalories
?: totals?.get(TotalCaloriesBurnedRecord.ENERGY_TOTAL)?.inKilocalories
)?.roundToInt(),
avgHeartRate = totals?.get(HeartRateRecord.BPM_AVG)?.toInt(),
maxHeartRate = totals?.get(HeartRateRecord.BPM_MAX)?.toInt(),
steps = totals?.get(StepsRecord.COUNT_TOTAL)?.toInt(),
elevationGainMeters = totals?.get(ElevationGainedRecord.ELEVATION_GAINED_TOTAL)?.inMeters,
distanceMeters = null,
calories = null,
avgHeartRate = null,
maxHeartRate = null,
steps = null,
elevationGainMeters = null,
source = resolveSourceName(session.metadata.dataOrigin.packageName),
)
}
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.my_fitness_connect_title
import com.vitorpamplona.amethyst.commons.resources.my_fitness_distance
import com.vitorpamplona.amethyst.commons.resources.my_fitness_elevation
import com.vitorpamplona.amethyst.commons.resources.my_fitness_empty
import com.vitorpamplona.amethyst.commons.resources.my_fitness_loading_metrics
import com.vitorpamplona.amethyst.commons.resources.my_fitness_max_heart_rate
import com.vitorpamplona.amethyst.commons.resources.my_fitness_recent
import com.vitorpamplona.amethyst.commons.resources.my_fitness_share
@@ -181,6 +182,7 @@ fun MyFitnessScreen(
// Only offered when it would actually add something: a device with no
// provider gets no banner to act on.
showConnectBanner = current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE,
metricsPending = current.metricsPending,
onDetails = openRationale,
onConnect = requestPermissions,
) { workout, label ->
@@ -226,6 +228,32 @@ private fun ConnectBanner(
}
}
/**
* Shown while the per-session metrics are still being read from Health Connect. The dashboard
* below it is already real — counts, time, streak, the activity split — but its distance,
* calories and heart rate cells appear as each session's metrics land, and a row of numbers
* growing on its own needs saying out loud.
*/
@Composable
private fun MetricsPendingNote() {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringRes(Res.string.my_fitness_loading_metrics),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ConnectPrompt(
onDetails: () -> Unit,
@@ -263,6 +291,7 @@ private fun ConnectPrompt(
private fun Dashboard(
report: WorkoutStats.Report,
showConnectBanner: Boolean,
metricsPending: Boolean,
onDetails: () -> Unit,
onConnect: () -> Unit,
onShare: (DetectedWorkout, String) -> Unit,
@@ -274,12 +303,13 @@ private fun Dashboard(
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
if (showConnectBanner) ConnectBanner(onDetails = onDetails, onConnect = onConnect)
if (metricsPending) MetricsPendingNote()
ThisWeekCard(report, miles)
ConsistencyRow(report)
WindowTotalsCard(report, miles)
ActivityBreakdown(report, miles)
BestEfforts(report, miles)
RecentWorkouts(report, miles, onShare)
RecentWorkouts(report, miles, metricsPending, onShare)
Text(
text = stringRes(Res.string.my_fitness_window_note),
@@ -498,6 +528,7 @@ private fun bestValue(
private fun RecentWorkouts(
report: WorkoutStats.Report,
miles: Boolean,
metricsPending: Boolean,
onShare: (DetectedWorkout, String) -> Unit,
) {
SectionCard(stringRes(Res.string.my_fitness_recent)) {
@@ -525,7 +556,16 @@ private fun RecentWorkouts(
// workout that is already posted would publish a second kind 1301 for
// the same effort — whether it came back from a relay, or is the
// Health Connect copy of one the user shared earlier.
if (!workout.alreadyPublished) {
//
// Nor while the metrics are still loading. The composer route carries
// them as primitives where 0 means absent, so sharing a workout whose
// aggregations have not come back yet posts a kind 1301 with its
// duration and nothing else — and then the workout counts as shared, so
// the full numbers never get their turn. Everything still offering a
// Share button in that window is a Health Connect workout, since a
// published one is by definition already published, so the wait is
// blanket rather than per-workout.
if (!workout.alreadyPublished && !metricsPending) {
TextButton(onClick = { onShare(workout, label) }) {
Text(stringRes(Res.string.my_fitness_share), style = MaterialTheme.typography.labelMedium)
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager
import com.vitorpamplona.amethyst.service.workouts.health.publishedWorkoutsOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -42,6 +43,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Duration
import java.time.Instant
@@ -84,6 +86,13 @@ class MyFitnessViewModel : ViewModel() {
data class Ready(
val report: WorkoutStats.Report,
val healthConnect: HealthConnectStatus,
/**
* True while Health Connect's per-session metrics are still arriving. The report is
* real and complete in every other respect — counts, time, streak, active days, the
* per-activity split — but distance, calories, heart rate, steps and elevation are
* still filling in, so the screen says so rather than letting cells appear unexplained.
*/
val metricsPending: Boolean = false,
) : State
}
@@ -93,7 +102,10 @@ class MyFitnessViewModel : ViewModel() {
* Health Connect's contribution. A push source: the platform has no change feed we can
* observe, so [refresh] re-reads it when the screen resumes or a permission is granted.
*/
private val fromHealthConnect = MutableStateFlow<List<DetectedWorkout>>(emptyList())
private val fromHealthConnect = MutableStateFlow(HealthConnectContribution())
/** The in-flight [refresh], cancelled by the next one so two reads never interleave. */
private var refreshJob: Job? = null
/** Null until the first [refresh] resolves, which is what keeps the screen on [State.Loading]. */
private val healthConnectStatus = MutableStateFlow<HealthConnectStatus?>(null)
@@ -112,21 +124,29 @@ class MyFitnessViewModel : ViewModel() {
val state: StateFlow<State> =
combine(fromHealthConnect, fromRelays, healthConnectStatus) { healthConnect, published, status ->
if (status == null) {
State.Loading
} else {
val now = Instant.now()
val since = now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)).epochSecond
// The status check is cheap; reading the workouts is not. Waiting only on the former
// is what lets a user's published log render while their watch data is still coming.
if (status == null) return@combine State.Loading
State.Ready(
report =
WorkoutStats.report(
TrainingLog.merge(healthConnect, published.filter { it.startTimeEpochSeconds >= since }),
now,
),
healthConnect = status,
val now = Instant.now()
val since = now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)).epochSecond
val report =
WorkoutStats.report(
TrainingLog.merge(healthConnect.workouts, published.filter { it.startTimeEpochSeconds >= since }),
now,
)
}
// Nothing to show *yet* is not the same as nothing logged. Going Ready here would
// flash the empty state — or the connect prompt — at a user whose sessions are one
// IPC away, so an empty report keeps waiting while the session list is in flight.
if (report.isEmpty && healthConnect.sessionsPending) return@combine State.Loading
State.Ready(
report = report,
healthConnect = status,
metricsPending = healthConnect.metricsPending,
)
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), State.Loading)
@@ -141,25 +161,85 @@ class MyFitnessViewModel : ViewModel() {
* numbers on display. The published side needs no refresh — it is observed.
*/
fun refresh(context: Context) {
viewModelScope.launch {
val status = healthConnectStatus(context)
// A resume while the previous read is still running would leave two collectors writing
// fromHealthConnect, and the slower one could land a stale list last.
refreshJob?.cancel()
refreshJob =
viewModelScope.launch {
val status = healthConnectStatus(context)
val hc = manager
fromHealthConnect.value =
if (status == HealthConnectStatus.CONNECTED) {
val now = Instant.now()
manager?.readWorkouts(now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)), now).orEmpty()
} else {
emptyList()
// Read through the local rather than the field: nothing may set sessionsPending
// without a reader that will clear it again, or an empty dashboard waits forever.
if (status != HealthConnectStatus.CONNECTED || hc == null) {
fromHealthConnect.value = HealthConnectContribution()
healthConnectStatus.value = status
return@launch
}
healthConnectStatus.value = status
// Published before the read, not after: the status is what the screen is gated on,
// and it is now known. The workouts arrive into an already-rendered dashboard.
// The previous read's workouts stay up meanwhile, so a resume re-reads in place
// rather than blanking a dashboard that is already correct.
fromHealthConnect.value = fromHealthConnect.value.copy(sessionsPending = true)
healthConnectStatus.value = status
val now = Instant.now()
hc
.readWorkoutsProgressively(now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)), now)
.collect { read -> fromHealthConnect.value = fromHealthConnect.value.after(read) }
}
}
/**
* Both calls here are binder round trips — a PackageManager query, and a bind to the Health
* Connect service that [HealthConnectManager] makes lazily on first use — so they run off the
* main thread. [viewModelScope] is `Dispatchers.Main.immediate`, which would otherwise stall
* the frame that opens the screen.
*/
private suspend fun healthConnectStatus(context: Context): HealthConnectStatus =
withContext(Dispatchers.IO) {
if (!HealthConnectManager.isAvailable(context)) return@withContext HealthConnectStatus.UNAVAILABLE
val hc = manager ?: HealthConnectManager(context.applicationContext).also { manager = it }
if (hc.hasAllPermissions()) HealthConnectStatus.CONNECTED else HealthConnectStatus.AVAILABLE
}
}
/**
* Health Connect's contribution to the dashboard, and how far along reading it is.
*
* [sessionsPending] and [metricsPending] are the two stages of
* [HealthConnectManager.readWorkoutsProgressively]: the session list costs one IPC, the metrics
* cost one per session. They are tracked separately because they mean different things to the
* screen — an empty dashboard must not say "nothing logged yet" while the sessions are still
* coming, but it can show real counts and times while the metrics are.
*/
data class HealthConnectContribution(
val workouts: List<DetectedWorkout> = emptyList(),
val sessionsPending: Boolean = false,
val metricsPending: Boolean = false,
) {
/**
* Folds one stage of a read in — and declines the ones that would take information off a
* screen that already has it.
*
* A stage-1 result carries no metrics. On a first load that is exactly what makes the
* dashboard appear in one IPC instead of dozens. On a re-read of an already-populated
* dashboard — [MyFitnessViewModel.refresh] runs on every resume — publishing it would strip
* the distance, calories, heart-rate, steps and elevation cells and most of Best Efforts for
* as long as stage 2 takes, then put them back. So a partial stage only reaches the screen
* when there is nothing better on it already; otherwise the previous read's numbers stay up,
* correct and unannotated, and stage 2 swaps them atomically.
*/
fun after(read: HealthConnectManager.WorkoutRead): HealthConnectContribution =
if (read.metricsPending && workouts.isNotEmpty()) {
copy(sessionsPending = false)
} else {
HealthConnectContribution(
workouts = read.workouts,
sessionsPending = false,
metricsPending = read.metricsPending,
)
}
}
private suspend fun healthConnectStatus(context: Context): HealthConnectStatus {
if (!HealthConnectManager.isAvailable(context)) return HealthConnectStatus.UNAVAILABLE
val hc = manager ?: HealthConnectManager(context.applicationContext).also { manager = it }
return if (hc.hasAllPermissions()) HealthConnectStatus.CONNECTED else HealthConnectStatus.AVAILABLE
}
}
@@ -226,6 +226,49 @@ class WorkoutMergerTest {
assertEquals(1800, merged.durationSeconds)
}
@Test
fun skeletonsWithoutMetricsGroupExactlyAsTheFullyLoadedOnesDo() {
// The progressive read merges twice: once over session skeletons, to get something on
// screen, and again once each session's metrics have been aggregated. Grouping keys on
// type, start and duration only, so both passes must produce the same shape — otherwise
// the dashboard would reshuffle its workout list when the metrics land.
val loaded =
listOf(
workout("a", startTimeEpochSeconds = 0, durationSeconds = 600, distanceMeters = 2_000.0, calories = 150, avgHeartRate = 140, steps = 1_800),
workout("b", startTimeEpochSeconds = 1200, durationSeconds = 600, distanceMeters = 2_100.0, calories = 160, avgHeartRate = 150, steps = 1_900),
workout("c", exercise = ExerciseType.CYCLING, startTimeEpochSeconds = 20_000, durationSeconds = 3600, distanceMeters = 25_000.0),
)
val skeletons =
loaded.map {
it.copy(distanceMeters = null, calories = null, avgHeartRate = null, maxHeartRate = null, steps = null, elevationGainMeters = null)
}
val early = WorkoutMerger.mergeCloseWorkouts(skeletons)
val late = WorkoutMerger.mergeCloseWorkouts(loaded)
assertEquals(late.map { it.id }, early.map { it.id })
assertEquals(late.map { it.sessionCount }, early.map { it.sessionCount })
assertEquals(late.map { it.startTimeEpochSeconds }, early.map { it.startTimeEpochSeconds })
assertEquals(late.map { it.durationSeconds }, early.map { it.durationSeconds })
}
@Test
fun aggregatingPerSessionBeforeMergingIsWhatSumsASplitEffort() {
// Why the progressive read still aggregates each raw session and merges afterwards,
// rather than aggregating the merged span once: the merged workout's metrics are the
// sum of its members', and the span between them is not part of the effort.
val first = workout("a", startTimeEpochSeconds = 0, durationSeconds = 600, distanceMeters = 2_000.0, calories = 150, steps = 1_800)
val second = workout("b", startTimeEpochSeconds = 1200, durationSeconds = 600, distanceMeters = 2_100.0, calories = 160, steps = 1_900)
val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single()
assertEquals(4_100.0, merged.distanceMeters!!, 0.001)
assertEquals(310, merged.calories)
assertEquals(3_700, merged.steps)
// The 10-minute break between them is excluded: duration is the sum, not end minus start.
assertEquals(1200, merged.durationSeconds)
}
@Test
fun chainMergesEvenWhenAdjacentGapsAreShortButEndsAreFarApart() {
// 45-min sessions each starting 50 min apart: consecutive gaps are 5 min,
@@ -0,0 +1,144 @@
/*
* 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.ui.screen.loggedIn.workouts.fitness
import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout
import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rule that decides which stages of a progressive Health Connect read are allowed to reach
* the My Fitness dashboard.
*
* The screen re-reads on every resume, and a read's first stage carries no metrics. Applying it
* unconditionally would be a downgrade for anyone coming back to a dashboard that was already
* complete.
*/
class HealthConnectContributionTest {
private fun workout(
id: String,
distanceMeters: Double? = null,
) = DetectedWorkout(
id = id,
exercise = ExerciseType.RUNNING,
title = null,
startTimeEpochSeconds = 1_700_000_000,
durationSeconds = 1800,
distanceMeters = distanceMeters,
calories = null,
avgHeartRate = null,
maxHeartRate = null,
steps = null,
elevationGainMeters = null,
source = "Samsung Health",
)
private fun stageOne(vararg workouts: DetectedWorkout) = HealthConnectManager.WorkoutRead(workouts.toList(), metricsPending = true)
private fun stageTwo(vararg workouts: DetectedWorkout) = HealthConnectManager.WorkoutRead(workouts.toList(), metricsPending = false)
@Test
fun `the first load shows stage one, which is the whole point of reading in stages`() {
val skeleton = workout("a")
val after = HealthConnectContribution().after(stageOne(skeleton))
assertEquals(listOf(skeleton), after.workouts)
assertTrue(after.metricsPending)
assertFalse(after.sessionsPending)
}
@Test
fun `stage two replaces stage one and clears the pending flag`() {
val loaded = workout("a", distanceMeters = 10_000.0)
val after = HealthConnectContribution().after(stageOne(workout("a"))).after(stageTwo(loaded))
assertEquals(listOf(loaded), after.workouts)
assertFalse(after.metricsPending)
}
@Test
fun `a resume does not strip the metrics off an already-complete dashboard`() {
val loaded = workout("a", distanceMeters = 10_000.0)
val complete = HealthConnectContribution(workouts = listOf(loaded))
// What refresh() does on resume, then the new read's stage 1 arriving.
val rereading = complete.copy(sessionsPending = true)
val after = rereading.after(stageOne(workout("a")))
assertEquals(listOf(loaded), after.workouts)
// Nothing on screen has changed, so nothing should be annotated as loading either.
assertFalse(after.metricsPending)
assertFalse(after.sessionsPending)
}
@Test
fun `the resumed read still lands, atomically, when its metrics arrive`() {
val old = workout("a", distanceMeters = 10_000.0)
val fresh = workout("b", distanceMeters = 12_000.0)
val after =
HealthConnectContribution(workouts = listOf(old))
.copy(sessionsPending = true)
.after(stageOne(workout("a"), workout("b")))
.after(stageTwo(old, fresh))
assertEquals(listOf(old, fresh), after.workouts)
assertFalse(after.metricsPending)
}
@Test
fun `an empty stage two is applied even when workouts are on screen, so revoked data clears`() {
val complete = HealthConnectContribution(workouts = listOf(workout("a", distanceMeters = 10_000.0)))
// Not a downgrade to decline — a completed read that found nothing is the truth.
val after = complete.after(stageTwo())
assertTrue(after.workouts.isEmpty())
assertFalse(after.metricsPending)
}
@Test
fun `a read that finds nothing clears the sessions-pending gate`() {
// Health Connect unavailable, the read failing, and a device with no mappable sessions
// all emit one empty non-pending result. Any of them leaving sessionsPending set would
// strand an empty dashboard on its spinner forever.
val after = HealthConnectContribution(sessionsPending = true).after(stageTwo())
assertTrue(after.workouts.isEmpty())
assertFalse(after.sessionsPending)
assertFalse(after.metricsPending)
}
@Test
fun `declining a stage keeps the same workout list instance rather than rebuilding it`() {
val workouts = listOf(workout("a", distanceMeters = 10_000.0))
val complete = HealthConnectContribution(workouts = workouts, sessionsPending = true)
assertSame(workouts, complete.after(stageOne(workout("a"))).workouts)
}
}
@@ -80,4 +80,22 @@ data class DetectedWorkout(
* sessions (e.g. a long run split around breaks) into a single suggestion.
*/
val sessionCount: Int = 1,
)
) {
/**
* Whether this carries any metric beyond its duration.
*
* False means one of two things, and the difference matters to whoever is holding two copies
* of the same effort: the activity genuinely has none to record (a gym session has no
* distance and often no calories), or they are simply not known yet — a Health Connect
* session read before its per-session aggregations have come back. Either way a copy that
* has some is the better one to keep, which is what [TrainingLog.merge] uses this for.
*/
val hasAnyMetric: Boolean
get() =
distanceMeters != null ||
calories != null ||
avgHeartRate != null ||
maxHeartRate != null ||
steps != null ||
elevationGainMeters != null
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.commons.fitness
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import java.util.Collections
import java.util.IdentityHashMap
import kotlin.math.abs
/**
@@ -47,9 +49,16 @@ object TrainingLog {
* Combines both sources into one log, newest first.
*
* Where the same workout appears in both — the usual case once a user shares one that came
* from their watch — the Health Connect copy wins: it carries the metrics the published
* event may have dropped (heart rate, steps, climb), and its start time is the recorded one
* rather than a publish timestamp.
* from their watch — the Health Connect copy normally wins: it carries the metrics the
* published event may have dropped (heart rate, steps, climb), and its start time is the
* recorded one rather than a publish timestamp.
*
* That preference is only justified while the Health Connect copy actually has those metrics.
* It can arrive without them — its per-session aggregations are read separately and may not
* have come back yet, or may have failed — and then it is strictly worse than the published
* event it would displace. So a copy carrying no metric at all yields to a matching one that
* does, rather than evicting it and taking numbers off the screen. See
* [DetectedWorkout.hasAnyMetric].
*
* The winner keeps the loser's one piece of information: that a kind 1301 for this workout
* exists. Dropping the published copy would otherwise lose that fact, and the survivor —
@@ -60,21 +69,33 @@ object TrainingLog {
healthConnect: List<DetectedWorkout>,
published: List<DetectedWorkout>,
): List<DetectedWorkout> {
val flagged =
healthConnect.map { recorded ->
if (published.any { recorded.isProbablySameWorkoutAs(it) }) {
recorded.copy(alreadyPublished = true)
} else {
recorded
val merged = ArrayList<DetectedWorkout>(healthConnect.size + published.size)
// The published copies that won their tie and are therefore already in [merged].
// Identity, not equality: two published workouts can be equal in every field, and one
// winning must not silently exclude the other from the pass below.
val kept = Collections.newSetFromMap(IdentityHashMap<DetectedWorkout, Boolean>())
healthConnect.forEach { recorded ->
val match = published.firstOrNull { candidate -> recorded.isProbablySameWorkoutAs(candidate) }
when {
match == null -> merged.add(recorded)
recorded.hasAnyMetric || !match.hasAnyMetric -> merged.add(recorded.copy(alreadyPublished = true))
else -> {
// The published event is flagged as published by construction, so no copy.
merged.add(match)
kept.add(match)
}
}
}
val deduped =
published.filterNot { candidate ->
healthConnect.any { it.isProbablySameWorkoutAs(candidate) }
}
// A published workout that any Health Connect copy matched is already represented by
// whichever of the two won — including the ones just added above.
published.forEach { candidate ->
if (candidate !in kept && healthConnect.none { it.isProbablySameWorkoutAs(candidate) }) merged.add(candidate)
}
return (flagged + deduped).sortedByDescending { it.startTimeEpochSeconds }
return merged.sortedByDescending { it.startTimeEpochSeconds }
}
private fun DetectedWorkout.isProbablySameWorkoutAs(other: DetectedWorkout): Boolean =
@@ -0,0 +1,281 @@
/*
* 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.commons.fitness
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
/**
* The My Fitness dashboard renders before Health Connect's per-session metrics have arrived —
* each of those costs its own IPC, so waiting for all of them is what made the screen sit on a
* spinner. These tests pin what that first pass is allowed to look like.
*
* The contract has two halves, and the screen depends on both:
* - everything not derived from a metric must already be final, so no visible number *changes*
* when the metrics land — it only gains cells;
* - a metric that is not known yet must read as absent, never as zero, so the screen hides its
* cell instead of claiming the user ran 0 km.
*/
class PartialMetricsReportTest {
private val zone: ZoneId = ZoneId.of("UTC")
/** Noon UTC so a test never straddles a day boundary by accident. */
private val now: Instant = ZonedDateTime.of(2026, 3, 15, 12, 0, 0, 0, zone).toInstant()
private var nextId = 0
private fun workout(
daysAgo: Long,
exercise: ExerciseType = ExerciseType.RUNNING,
durationSeconds: Long = 1800,
distanceMeters: Double? = null,
calories: Int? = null,
avgHeartRate: Int? = null,
maxHeartRate: Int? = null,
steps: Int? = null,
elevationGainMeters: Double? = null,
) = DetectedWorkout(
id = "w${nextId++}",
exercise = exercise,
title = null,
startTimeEpochSeconds = now.epochSecond - daysAgo * 86_400L,
durationSeconds = durationSeconds,
distanceMeters = distanceMeters,
calories = calories,
avgHeartRate = avgHeartRate,
maxHeartRate = maxHeartRate,
steps = steps,
elevationGainMeters = elevationGainMeters,
source = "Samsung Health",
)
/** What the first pass has: activity, when, how long. Nothing that needs an aggregate call. */
private fun DetectedWorkout.withoutMetrics() =
copy(
distanceMeters = null,
calories = null,
avgHeartRate = null,
maxHeartRate = null,
steps = null,
elevationGainMeters = null,
)
private val fullyLoaded =
listOf(
workout(daysAgo = 0, durationSeconds = 3600, distanceMeters = 10_000.0, calories = 700, avgHeartRate = 150, maxHeartRate = 175, steps = 9_000, elevationGainMeters = 120.0),
workout(daysAgo = 1, durationSeconds = 1800, distanceMeters = 5_000.0, calories = 320, avgHeartRate = 140, maxHeartRate = 160, steps = 4_500),
workout(daysAgo = 2, exercise = ExerciseType.CYCLING, durationSeconds = 5400, distanceMeters = 40_000.0, calories = 900, avgHeartRate = 130, maxHeartRate = 155, elevationGainMeters = 400.0),
workout(daysAgo = 9, exercise = ExerciseType.STRENGTH, durationSeconds = 2700, calories = 250),
)
private val partial = fullyLoaded.map { it.withoutMetrics() }
@Test
fun `counts, time and consistency are already final before the metrics arrive`() {
val early = WorkoutStats.report(partial, now, zone)
val late = WorkoutStats.report(fullyLoaded, now, zone)
assertEquals(late.windowTotals.workoutCount, early.windowTotals.workoutCount)
assertEquals(late.windowTotals.durationSeconds, early.windowTotals.durationSeconds)
assertEquals(late.thisWeek.workoutCount, early.thisWeek.workoutCount)
assertEquals(late.thisWeek.durationSeconds, early.thisWeek.durationSeconds)
assertEquals(late.previousWeek.workoutCount, early.previousWeek.workoutCount)
assertEquals(late.weeklyAverage.durationSeconds, early.weeklyAverage.durationSeconds)
assertEquals(late.activeDays, early.activeDays)
assertEquals(late.currentStreakDays, early.currentStreakDays)
}
@Test
fun `the activity split is already final, in the same order`() {
val early = WorkoutStats.report(partial, now, zone)
val late = WorkoutStats.report(fullyLoaded, now, zone)
assertEquals(late.byActivity.map { it.exercise }, early.byActivity.map { it.exercise })
assertEquals(
late.byActivity.map { it.totals.workoutCount },
early.byActivity.map { it.totals.workoutCount },
)
assertEquals(
late.byActivity.map { it.totals.durationSeconds },
early.byActivity.map { it.totals.durationSeconds },
)
}
@Test
fun `an unknown metric reads as absent, so the screen hides its cell instead of showing zero`() {
val early = WorkoutStats.report(partial, now, zone)
// The screen gates these cells on `> 0` / a non-null, so absent is what keeps them hidden.
assertEquals(0.0, early.windowTotals.distanceMeters, 0.0)
assertEquals(0, early.windowTotals.calories)
assertEquals(0, early.windowTotals.steps)
assertEquals(0.0, early.windowTotals.elevationGainMeters, 0.0)
assertNull(early.windowTotals.avgHeartRate)
assertNull(early.windowTotals.maxHeartRate)
}
@Test
fun `only the duration best is offered before the metrics arrive`() {
val early = WorkoutStats.report(partial, now, zone)
val late = WorkoutStats.report(fullyLoaded, now, zone)
// A best whose metric is unknown is withheld rather than awarded to whichever workout
// happens to have a null — the cycling ride below is the real longest distance.
assertEquals(listOf(WorkoutStats.BestKind.LONGEST_DURATION), early.bests.map { it.kind })
assertTrue(late.bests.map { it.kind }.containsAll(early.bests.map { it.kind }))
assertEquals(
late.bests
.first { it.kind == WorkoutStats.BestKind.LONGEST_DURATION }
.workout.id,
early.bests
.first { it.kind == WorkoutStats.BestKind.LONGEST_DURATION }
.workout.id,
)
}
@Test
fun `a partial pass with workouts in it is not mistaken for an empty log`() {
val early = WorkoutStats.report(partial, now, zone)
// isEmpty drives the "nothing logged yet" copy and the connect prompt. A user with four
// workouts whose metrics are still loading must not see either.
assertFalse(early.isEmpty)
assertEquals(fullyLoaded.size, early.workouts.size)
}
@Test
fun `merging both sources still dedupes when the health connect copy has no metrics yet`() {
val published =
fullyLoaded.take(1).map {
it.copy(id = "published", origin = WorkoutOrigin.PUBLISHED, alreadyPublished = true)
}
// Dedupe keys on activity and start time, neither of which is a metric, so the first pass
// must already collapse the pair rather than double-count it and then halve the count.
val merged = TrainingLog.merge(partial, published)
assertEquals(partial.size, merged.size)
assertEquals(1, merged.count { it.alreadyPublished })
}
@Test
fun `a metric-less health connect copy yields to the published one that still has its numbers`() {
val published =
fullyLoaded.take(1).map {
it.copy(id = "published", origin = WorkoutOrigin.PUBLISHED, alreadyPublished = true)
}
// Health Connect normally wins the tie because it carries more. During stage 1 it carries
// less, and evicting the published copy then would take 10 km off a dashboard that was
// already showing it — down to zero, and back up a second later.
val merged = TrainingLog.merge(partial, published)
val survivor = merged.first { it.startTimeEpochSeconds == published.single().startTimeEpochSeconds }
assertEquals(10_000.0, survivor.distanceMeters!!, 0.001)
assertEquals(700, survivor.calories)
assertTrue(survivor.alreadyPublished)
}
@Test
fun `the whole window keeps its published totals through the partial pass`() {
val published =
fullyLoaded.map {
it.copy(id = "published-${'$'}{it.id}", origin = WorkoutOrigin.PUBLISHED, alreadyPublished = true)
}
// The user has published every one of these. Stage 1 must not make their distance and
// calorie totals dip to zero on the way to showing the same numbers again.
val beforeAnyHealthConnect = WorkoutStats.report(TrainingLog.merge(emptyList(), published), now, zone)
val duringStageOne = WorkoutStats.report(TrainingLog.merge(partial, published), now, zone)
assertEquals(beforeAnyHealthConnect.windowTotals.distanceMeters, duringStageOne.windowTotals.distanceMeters, 0.001)
assertEquals(beforeAnyHealthConnect.windowTotals.calories, duringStageOne.windowTotals.calories)
assertEquals(beforeAnyHealthConnect.windowTotals.steps, duringStageOne.windowTotals.steps)
assertEquals(beforeAnyHealthConnect.windowTotals.maxHeartRate, duringStageOne.windowTotals.maxHeartRate)
assertEquals(beforeAnyHealthConnect.bests.map { it.kind }.toSet(), duringStageOne.bests.map { it.kind }.toSet())
}
@Test
fun `a loaded health connect copy still wins over the published one, as before`() {
// The yielding rule is narrow: it must not invert the normal preference, or a shared
// workout would lose the device detail the published event dropped.
val published =
fullyLoaded.take(1).map {
it.copy(
id = "published",
origin = WorkoutOrigin.PUBLISHED,
alreadyPublished = true,
steps = null,
elevationGainMeters = null,
)
}
val merged = TrainingLog.merge(fullyLoaded, published)
val survivor = merged.first { it.startTimeEpochSeconds == published.single().startTimeEpochSeconds }
assertEquals(WorkoutOrigin.HEALTH_CONNECT, survivor.origin)
assertEquals(9_000, survivor.steps)
assertTrue(survivor.alreadyPublished)
}
@Test
fun `one health connect session still absorbs every published copy it matches`() {
// Pre-existing dedupe behaviour, kept: the yielding rule changes which copy survives a
// tie, never how many survive. Two published events inside the 15-minute tolerance and
// one recorded session is still one workout, not two.
val recorded = listOf(workout(daysAgo = 1, durationSeconds = 1800, distanceMeters = 5_000.0))
val start = recorded.single().startTimeEpochSeconds
val published =
listOf(
recorded.single().copy(id = "p1", startTimeEpochSeconds = start, origin = WorkoutOrigin.PUBLISHED, alreadyPublished = true),
recorded.single().copy(id = "p2", startTimeEpochSeconds = start + 300, origin = WorkoutOrigin.PUBLISHED, alreadyPublished = true),
)
assertEquals(1, TrainingLog.merge(recorded, published).size)
// And the same when the recorded copy is the metric-less one that yields.
assertEquals(1, TrainingLog.merge(recorded.map { it.withoutMetrics() }, published).size)
}
@Test
fun `two copies that both lack metrics keep the health connect one`() {
// A gym session genuinely has no distance or steps on either side. Nothing to prefer, so
// the normal rule stands and the pair still collapses to one entry.
val strengthOnly = listOf(workout(daysAgo = 3, exercise = ExerciseType.STRENGTH, durationSeconds = 2700))
val published =
strengthOnly.map {
it.copy(id = "published", origin = WorkoutOrigin.PUBLISHED, alreadyPublished = true)
}
val merged = TrainingLog.merge(strengthOnly, published)
assertEquals(1, merged.size)
assertEquals(WorkoutOrigin.HEALTH_CONNECT, merged.single().origin)
assertTrue(merged.single().alreadyPublished)
}
}
@@ -716,6 +716,7 @@
<string name="health_connect_rationale_privacy_policy">Read the full privacy policy</string>
<string name="my_fitness_title">My Fitness</string>
<string name="my_fitness_window">Last 4 weeks</string>
<string name="my_fitness_loading_metrics">Loading distance, calories and heart rate…</string>
<string name="my_fitness_window_note">The last four whole weeks of your training: the workouts you have published, plus anything Health Connect records on this phone. Amethyst works the summary out on the device and publishes none of it.</string>
<string name="my_fitness_this_week">This week</string>
<string name="my_fitness_vs_last_week">vs last week</string>