mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+165
-52
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+42
-2
@@ -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)
|
||||
}
|
||||
|
||||
+111
-31
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -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,
|
||||
|
||||
+144
@@ -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)
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -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
|
||||
}
|
||||
|
||||
+35
-14
@@ -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 =
|
||||
|
||||
+281
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user