diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 61b16739..9cbb15d9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,7 +42,9 @@ + first permission sheet stayed narrow. READ_STEPS below is the one exception: it supports + the optional phone step source, and users on the default "Ring" source never see the + Health Connect read prompt and never trigger a read. --> @@ -62,6 +64,10 @@ + + = setOf(DataOrigin(context.packageName)) + + /** + * Query Health Connect for today's step total and publish it as a + * [PulseEvent.PhoneStepsUpdate]. Returns true when the query succeeded, false when + * Health Connect is unavailable or the read permission has not been granted yet. + * + * **Today always publishes, including 0.** A day's phone total of 0 is a real state — + * just after midnight, before the phone has counted anything, the correct phone-mode + * display is 0, not yesterday's ring total still sitting in the `steps` column. Under + * the separate-column design ([ActivityDailyEntity.phoneSteps]), publishing 0 to + * `phoneSteps` is how "today's phone value is 0" becomes visible: `displaySteps` picks + * `phoneSteps` when it is non-null and falls back to the ring only when the phone has + * never written for the day. Skipping the 0 would leave today's `phoneSteps` null and + * the display showing the ring's stale value — the exact midnight gap the previous + * ownership-tracking design produced. `upsertActivityDailyFromPhone` is a no-op when + * the value is unchanged, so re-publishing the same 0 on every foreground is free. + */ + suspend fun refresh(): Boolean { + val client = clientWithStepsPermission(context) ?: return false + + val now = Instant.now() + val startOfDayMs = TimeUtil.startOfDayLocal(System.currentTimeMillis()) + val startOfDay = Instant.ofEpochMilli(startOfDayMs) + val range = TimeRangeFilter.between(startOfDay, now) + + // Discover foreign origins writing steps today; aggregate only over them. See the + // class KDoc for why HC's own subtraction doesn't work. + val foreignOrigins = try { + foreignStepOrigins(client, range) + } catch (_: Exception) { + return false + } + + val steps = if (foreignOrigins.isEmpty()) { + // No foreign origin has written steps today. Two sub-cases, and they are not the + // same thing (review item 3, PR #98): + // - the phone has a step writer and it simply hasn't counted anything yet today + // (just after midnight) — publish 0, because that is the honest phone total; + // - the phone has never had a step writer at all — publishing 0 would paint a + // false zero over a real ring day. Leave `phoneSteps` null so the display + // falls back to the ring. + // `hasEverSeenPhoneSteps` distinguishes them. `aggregate` with an empty + // `dataOriginFilter` means "all origins", not "none", so it must not be called + // here regardless. + if (!prefs.hasEverSeenPhoneSteps) return true + 0 + } else { + // At least one foreign origin writes steps — record the fact so the midnight-gap + // path above keeps publishing 0 on later passes, even if this origin stops + // writing (a source uninstalled but the phone still in Phone mode: the days + // where it did write are real data). + prefs.hasEverSeenPhoneSteps = true + try { + client.aggregate( + AggregateRequest( + metrics = setOf(StepsRecord.COUNT_TOTAL), + timeRangeFilter = range, + dataOriginFilter = foreignOrigins, + ) + )[StepsRecord.COUNT_TOTAL]?.toInt() ?: 0 + } catch (_: Exception) { + return false + } + } + + PulseEventBus.publishBlocking( + PulseEvent.PhoneStepsUpdate( + timestamp = now, + steps = steps, + ) + ) + return true + } + + /** + * Query Health Connect for step totals per day across a rolling window, and publish each + * one as a separate [PulseEvent.PhoneStepsUpdate]. Used on app start / toggle change to + * backfill historical days from the phone's own step counter, so past days in Phone mode + * show the phone's numbers rather than whatever the ring recorded. + * + * Days where Health Connect has no phone-step records are skipped — those days fall back + * to the ring's data, which is the only source available for them. Unlike today's read + * ([refresh], which always publishes including 0), a past day with no phone records is + * genuinely unknown: the phone may not have been tracking then, so a 0 would wrongly hide + * a ring day that does have data. Only today's 0 is meaningful. + * + * **Three Health Connect quirks handled here:** + * + * 1. `aggregateGroupByPeriod` requires the `TimeRangeFilter` to be built with + * `LocalDateTime`, not `Instant`. Passing an Instant-based filter throws + * `IllegalArgumentException: Either use TimeRangeFilter with LocalDateTime or + * AggregateGroupByDurationRequest`. The plain `aggregate` (today's query, above) + * accepts Instant, but this one does not. + * + * 2. `aggregateGroupByPeriod` aligns buckets to the range **start**, not to calendar + * days. If the range starts at `LocalDateTime.now()` (e.g. 22:42), the last bucket + * runs 22:42 yesterday → 22:42 today and gets mislabeled as "yesterday", duplicating + * today's data. We anchor the range to local midnight so every bucket is exactly one + * calendar day. + * + * 3. Origins are discovered from the raw records first, then the aggregate runs with + * `dataOriginFilter` set to the foreign origins only — see the class KDoc for why. + * + * @param daysBack how many days of history to backfill (default 30). + * @return true if the query succeeded (even if some days were empty), false on failure. + */ + suspend fun refreshHistoricalDays(daysBack: Long = 30): Boolean { + val client = clientWithStepsPermission(context) ?: return false + + // Anchor the range to local midnight so each bucket is exactly one calendar day. + // endLocal is tomorrow-midnight (exclusive upper bound); startLocal is the first + // day of the window. With daysBack = 30 this yields 30 calendar-day buckets, the + // last of which is today. + val todayStart: LocalDateTime = LocalDate.now().atStartOfDay() + val endLocal: LocalDateTime = todayStart.plusDays(1) + val startLocal: LocalDateTime = todayStart.minusDays(daysBack - 1) + val range = TimeRangeFilter.between(startLocal, endLocal) + + // Discover foreign origins across the whole window; aggregate only over them. See + // the class KDoc for why HC's own subtraction doesn't work. + val foreignOrigins = try { + foreignStepOrigins(client, range) + } catch (_: Exception) { + return false + } + if (foreignOrigins.isEmpty()) return true + // A backfill window with a foreign writer proves a step source exists — see + // [StepSourcePrefs.hasEverSeenPhoneSteps]. Same signal as the today read. + prefs.hasEverSeenPhoneSteps = true + + val buckets = try { + client.aggregateGroupByPeriod( + AggregateGroupByPeriodRequest( + metrics = setOf(StepsRecord.COUNT_TOTAL), + timeRangeFilter = range, + timeRangeSlicer = Period.ofDays(1), + dataOriginFilter = foreignOrigins, + ) + ) + } catch (_: Exception) { + return false + } + + // Each bucket is one day's total over the foreign origins. The bucket's startTime + // marks the beginning of the period it covers (local midnight). Map each non-empty + // bucket to a PhoneStepsUpdate so the persistence layer overwrites the corresponding + // activity_daily row. + for (bucket in buckets) { + val steps = bucket.result[StepsRecord.COUNT_TOTAL]?.toInt() ?: 0 + // Skip empty days: publishing 0 would clobber the ring's only valid record for + // that day. Leaving the day untouched lets it fall back to the ring's data. + if (steps <= 0) continue + PulseEventBus.publishBlocking( + PulseEvent.PhoneStepsUpdate( + timestamp = bucket.startTime + .atZone(ZoneId.systemDefault()) + .toInstant(), + steps = steps, + ) + ) + } + return true + } + + /** + * The set of Health Connect origins writing `StepsRecord`s in [timeRangeFilter], minus + * PulseLoop's own. The result is passed as `dataOriginFilter` to the aggregates above so + * HC's priority-based de-duplication runs over foreign sources only — see the class KDoc. + * + * **Why this reads raw records (review item 8, PR #98).** Health Connect has no API that + * enumerates "the set of origins writing a record type" — `AggregateRequest` and + * `AggregateGroupByPeriodRequest` both require a `dataOriginFilter` *up front*, and + * there is nothing else in the SDK to discover it. Reading records and inspecting each + * `metadata.dataOrigin` is the only option; the cost is intrinsic to the API. Do not + * "optimise" this by swapping in an unfiltered aggregate or by subtracting an own-only + * aggregate — HC's de-duplication is priority-based, not additive (see the class KDoc), + * so either shortcut produces wrong totals. + * + * **Pagination.** [ReadRecordsRequest] returns one page at a time (default `pageSize` + * of 1000) and puts the cursor in `pageToken`. The previous code read a single page and + * ignored the token, so an origin that first appeared *after* the first 1000 records + * was silently excluded from `dataOriginFilter` — under-counting that source's steps + * for the whole range. Loop until `pageToken` is null. In practice a 30-day backfill + * window fits in one page for a typical step writer, so the loop collapses to one + * round trip; the `pageSize` bump keeps that true for heavier ranges too. + */ + private suspend fun foreignStepOrigins( + client: HealthConnectClient, + timeRangeFilter: TimeRangeFilter, + ): Set { + val origins = mutableSetOf() + var pageToken: String? = null + do { + val response = client.readRecords( + ReadRecordsRequest( + recordType = StepsRecord::class, + timeRangeFilter = timeRangeFilter, + pageSize = 1_000, + pageToken = pageToken, + ) + ) + response.records.forEach { origins.add(it.metadata.dataOrigin) } + pageToken = response.pageToken + } while (pageToken != null) + return origins - ownOrigin + } + + companion object { + /** + * READ_STEPS permission string, resolved once. Referenced by the reads above (through + * [clientWithStepsPermission]) and by [com.pulseloop.ui.screens.SettingsScreen]'s Step + * Source row (to test whether the permission is already granted before asking, and to + * verify the permission-result callback was granted *this* permission). + */ + val READ_STEPS_PERMISSION: String = + HealthPermission.getReadPermission(StepsRecord::class) + + /** + * The Health Connect client if READ_STEPS is granted, else null. Null means: HC is + * unavailable on this device, the client could not be constructed, the permission + * query threw, or READ_STEPS is simply not granted. Callers in the phone step path + * treat all four the same way — a silent no-op that leaves the ring's own data in + * place. + */ + suspend fun clientWithStepsPermission(context: Context): HealthConnectClient? { + val client = try { + HealthConnectClient.getOrCreate(context) + } catch (_: Exception) { + return null + } + val granted = try { + client.permissionController.getGrantedPermissions() + } catch (_: Exception) { + return null + } + return if (READ_STEPS_PERMISSION in granted) client else null + } + + /** + * True when READ_STEPS is granted — the Settings Step Source row's "already granted" + * check, so its async HC preamble isn't a third copy of [clientWithStepsPermission]'s. + */ + suspend fun hasStepsPermission(context: Context): Boolean = + clientWithStepsPermission(context) != null + } +} diff --git a/app/src/main/java/com/pulseloop/PulseLoopApplication.kt b/app/src/main/java/com/pulseloop/PulseLoopApplication.kt index 9689d3dd..b32a0c9f 100644 --- a/app/src/main/java/com/pulseloop/PulseLoopApplication.kt +++ b/app/src/main/java/com/pulseloop/PulseLoopApplication.kt @@ -32,8 +32,10 @@ class PulseLoopApplication : Application() { QuietHoursPrefs.migrateIndependentBedtime(this) // Application-scoped so capture runs whether or not the Debug screen is open. DiagnosticsSubscriber(PulseLoopDatabase.getInstance(this)).start(appScope) - // Same reason, for the ring data itself: a process launched only for RingSyncWorker never - // runs the UI's startup, and without a collector its events are dropped. + // Persistence subscribes from here — not from PulseLoopApp's LaunchedEffect — so that + // a process launched only for RingSyncWorker / ScheduledSpo2Worker has a collector, and so + // that nothing published on activity resume (MainActivity.onResume → refreshPhoneSteps) + // races the UI's composition. Idempotent: PulseLoopApp's LaunchedEffect no longer calls start(). EventPersistenceSubscriber.shared(this).start() // One-time cleanup of activity totals corrupted by the old bucket handling; runs off the // main thread and is prefs-gated so it executes once. Safe to race the first sync — the diff --git a/app/src/main/java/com/pulseloop/coach/context/CoachContextBuilder.kt b/app/src/main/java/com/pulseloop/coach/context/CoachContextBuilder.kt index 51959a6b..b214be93 100644 --- a/app/src/main/java/com/pulseloop/coach/context/CoachContextBuilder.kt +++ b/app/src/main/java/com/pulseloop/coach/context/CoachContextBuilder.kt @@ -17,6 +17,7 @@ object CoachContextBuilder { suspend fun build( db: PulseLoopDatabase, + preferPhone: Boolean, conversationSummary: String? = null, environment: EnvironmentContext? = null, ): CoachContextPacket { @@ -39,8 +40,11 @@ object CoachContextBuilder { val sevenDaysAgo = todayStart - 7 * 24 * 3600_000L val activityWeek = (if (activityHasReal) db.activityDailyDao().recentReal(7) else db.activityDailyDao().recent(7)).filter { it.date >= sevenDaysAgo } - val stepsWeek = activityWeek.map { it.steps.toDouble() } - val daysAvailable = activityWeek.count { it.steps > 0 } + // Display steps: ring mode reads `steps`, phone mode reads `phoneSteps` when present + // (review item 5, PR #98). Same figure the Today tile shows, so the coach cannot quote + // 9,800 while Today reads 8,200. Falls back to the ring's value on an un-backfilled day. + val stepsWeek = com.pulseloop.data.DisplaySteps.counts(activityWeek, preferPhone).map { it.toDouble() } + val daysAvailable = com.pulseloop.data.DisplaySteps.counts(activityWeek, preferPhone).count { it > 0 } // Resting HR: average of HR values in the last 24h while at rest (approximated as lowest 25th percentile) val hr24h = if (db.measurementDao().hasReal(MeasurementKind.HEART_RATE.name)) diff --git a/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryContextBuilder.kt b/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryContextBuilder.kt index bb8974c7..363bd5ba 100644 --- a/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryContextBuilder.kt +++ b/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryContextBuilder.kt @@ -29,8 +29,8 @@ object CoachSummaryContextBuilder { // ── Today ──────────────────────────────────────────────────────────── - suspend fun today(db: PulseLoopDatabase): Built { - val packet = CoachContextBuilder.build(db) + suspend fun today(db: PulseLoopDatabase, preferPhone: Boolean): Built { + val packet = CoachContextBuilder.build(db, preferPhone) val todayDate = packet.today @Serializable @@ -53,7 +53,7 @@ object CoachSummaryContextBuilder { val tp = TodayPacket( today = todayDate, - steps = activity?.steps, + steps = com.pulseloop.data.DisplaySteps.count(activity, preferPhone), calories = activity?.calories, distanceKm = activity?.distanceMeters?.let { it / 1000 }, activeMinutes = activity?.activeMinutes, @@ -63,17 +63,21 @@ object CoachSummaryContextBuilder { ) val contextJson = json.encodeToString(tp) + // The signature must include the *display* step figure, not the raw column — a phone-mode + // change that leaves `steps` untouched but moves `phoneSteps` has to trigger a regeneration + // or the card would keep quoting the old value (review item 5, PR #98). val sig = signature(listOf( - activity?.steps?.toString(), activity?.calories?.toInt()?.toString(), + tp.steps?.toString(), activity?.calories?.toInt()?.toString(), activity?.activeMinutes?.toString(), tp.distanceKm?.toString(), latestHr?.toInt()?.toString(), latestSpo2?.toInt()?.toString(), )) - // Scripted fallback - val title = if (activity != null && activity.steps > 0) "Today's snapshot" else "No activity synced yet" - val body = if (activity != null && activity.steps > 0) - "${activity.steps} steps so far today. Your ring is tracking — sync for the latest readings." - else "I don't have today's activity from the ring yet. Wear your ring and sync for a daily recap." + // Scripted fallback — same display value as the Today tile, so the two cannot disagree. + val displaySteps = tp.steps ?: 0 + val title = if (displaySteps > 0) "Today's snapshot" else "No activity synced yet" + val body = if (displaySteps > 0) + "$displaySteps steps so far today. Your ring is tracking — sync for the latest readings." + else "I don't have today's activity yet. Wear your ring and sync for a daily recap." return Built( scopeKey = todayDate, @@ -85,7 +89,7 @@ object CoachSummaryContextBuilder { // ── Sleep Day ──────────────────────────────────────────────────────── - suspend fun sleepDay(db: PulseLoopDatabase): Built? { + suspend fun sleepDay(db: PulseLoopDatabase, preferPhone: Boolean): Built? { val todayStart = java.time.LocalDate.now().atStartOfDay(java.time.ZoneId.systemDefault()) .toInstant().toEpochMilli() val session = db.sleepSessionDao().byDay(todayStart) ?: return null @@ -93,7 +97,9 @@ object CoachSummaryContextBuilder { val blocks = db.sleepStageBlockDao().forSession(session.id) val score = SleepScore.calculate(session, blocks) - val activitySteps = db.activityDailyDao().byDay(todayStart)?.steps + val activitySteps = com.pulseloop.data.DisplaySteps.count( + db.activityDailyDao().byDay(todayStart), preferPhone, + ) @Serializable data class SleepDayPacket( diff --git a/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryCoordinator.kt b/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryCoordinator.kt index d8a189d9..825c70fc 100644 --- a/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryCoordinator.kt +++ b/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryCoordinator.kt @@ -19,8 +19,13 @@ class CoachSummaryCoordinator( /** Optional multi-provider settings, threaded through to the service so * summaries follow the selected coach provider. */ providerSettings: com.pulseloop.coach.config.CoachProviderSettingsStore? = null, + /** The user's step-source preference, threaded to the service so the coach's + * today/sleep summaries quote the same step figure Today does (review item 5, + * PR #98). The service reads it per generation, so a toggle takes effect on + * the next card regeneration without an app restart. */ + stepSourcePrefs: com.pulseloop.settings.StepSourcePrefs, ) { - private val service = CoachSummaryService(db, apiKeyStore, providerSettings) + private val service = CoachSummaryService(db, apiKeyStore, providerSettings, stepSourcePrefs) private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var streamJob: Job? = null private var debounceJob: Job? = null diff --git a/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryService.kt b/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryService.kt index 5fb171ef..d555b73a 100644 --- a/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryService.kt +++ b/app/src/main/java/com/pulseloop/coach/summaries/CoachSummaryService.kt @@ -26,6 +26,10 @@ class CoachSummaryService( * `CoachClientResolver` (Gemini/OpenRouter follow the provider picker). * null keeps the legacy OpenAI-only behavior. */ private val providerSettings: CoachProviderSettingsStore? = null, + /** The user's step-source preference (review item 5, PR #98). Applied per regeneration so + * the coach's today/sleep summaries quote the same steps Today shows — Path B's separate + * columns otherwise let the coach read ring steps while Today read phone steps. */ + private val stepSourcePrefs: com.pulseloop.settings.StepSourcePrefs, ) { /** Minimum gap between Today / aggregate-sleep regenerations. */ private val minIntervalMs = 2 * 3600_000L // 2 hours @@ -42,7 +46,7 @@ class CoachSummaryService( // ── Refresh (self-gating) ──────────────────────────────────────────── suspend fun refreshTodayIfNeeded() { - val built = CoachSummaryContextBuilder.today(db) + val built = CoachSummaryContextBuilder.today(db, stepSourcePrefs.preferPhone()) val existing = db.coachSummaryDao().get(CoachSummaryKind.TODAY.rawValue, built.scopeKey) val now = System.currentTimeMillis() @@ -54,7 +58,7 @@ class CoachSummaryService( } suspend fun refreshSleepDayIfNeeded(now: Long = System.currentTimeMillis()) { - val built = CoachSummaryContextBuilder.sleepDay(db) ?: return + val built = CoachSummaryContextBuilder.sleepDay(db, stepSourcePrefs.preferPhone()) ?: return val endAt = built.sleepSessionEnd ?: return val lastFullSyncAt = db.deviceDao().current()?.lastFullSyncAt if (!CoachSleepSyncGate.sleepDaySafeToSummarize(endAt, lastFullSyncAt, now)) return diff --git a/app/src/main/java/com/pulseloop/coach/tools/CoachDataAccess.kt b/app/src/main/java/com/pulseloop/coach/tools/CoachDataAccess.kt index b3be595f..89f0491d 100644 --- a/app/src/main/java/com/pulseloop/coach/tools/CoachDataAccess.kt +++ b/app/src/main/java/com/pulseloop/coach/tools/CoachDataAccess.kt @@ -129,13 +129,14 @@ object CoachDataAccess { // ── Daily series ────────────────────────────────────────────────────── suspend fun dailySeries( - db: PulseLoopDatabase, metric: String, start: String, end: String + db: PulseLoopDatabase, metric: String, start: String, end: String, + preferPhone: Boolean, ): List> { return when (metric) { "steps", "calories", "distance", "active_minutes" -> { activityRows(db, start, end).map { row -> val v = when (metric) { - "steps" -> row.steps.toDouble() + "steps" -> com.pulseloop.data.DisplaySteps.count(row, preferPhone)?.toDouble() ?: 0.0 "calories" -> row.calories "distance" -> (row.distanceMeters / 1000.0).roundTo(2) "active_minutes" -> row.activeMinutes.toDouble() @@ -166,7 +167,8 @@ object CoachDataAccess { // ── Series points ───────────────────────────────────────────────────── suspend fun seriesPoints( - db: PulseLoopDatabase, metric: String, start: String, end: String, granularity: String + db: PulseLoopDatabase, metric: String, start: String, end: String, granularity: String, + preferPhone: Boolean, ): List> { val labeler: (Long) -> String = when (granularity) { "hour" -> { ts -> hourLabel(ts) } @@ -182,7 +184,7 @@ object CoachDataAccess { Instant.ofEpochMilli(it.timestamp).atZone(ZoneId.systemDefault()) .truncatedTo(ChronoUnit.HOURS).toInstant().toEpochMilli() }.map { (h, ms) -> h to ms.map { it.value }.average().roundTo(1) }.sortedBy { it.first } - "day" -> dailySeries(db, metric, start, end) + "day" -> dailySeries(db, metric, start, end, preferPhone) else -> rows.map { it.timestamp to it.value } } } @@ -193,11 +195,11 @@ object CoachDataAccess { Instant.ofEpochMilli(it.timestamp).atZone(ZoneId.systemDefault()) .truncatedTo(ChronoUnit.HOURS).toInstant().toEpochMilli() }.map { (h, ms) -> h to ms.map { it.value }.average().roundTo(1) }.sortedBy { it.first } - "day" -> dailySeries(db, metric, start, end) + "day" -> dailySeries(db, metric, start, end, preferPhone) else -> rows.map { it.timestamp to it.value } } } - else -> dailySeries(db, metric, start, end) + else -> dailySeries(db, metric, start, end, preferPhone) } return downsample(dated, 300).map { labeler(it.first) to it.second } diff --git a/app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt b/app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt index 826bfc4b..249c27b1 100644 --- a/app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt +++ b/app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt @@ -42,6 +42,19 @@ data class ToolExecutionContext( * compile unchanged (iOS's ToolExecutionContext has the same optional foodClient). */ val foodClient: com.pulseloop.nutrition.FoodDatabaseClient? = null, + /** + * The resolved step-source preference (review item 5, PR #98). Path B keeps phone and + * ring steps in separate columns; the retrieval tools must quote the same figure the + * Today tile shows. Populated per turn from + * [com.pulseloop.AppServices.stepSourcePrefs] at the tool context factory, so a toggle + * made mid-session is reflected on the next call. + * + * Defaults to false (ring steps) so existing test harnesses compile unchanged. Any + * production call site that reads `.steps` from an `activity_daily` row and is + * user-visible must pass this through `com.pulseloop.data.DisplaySteps` — see + * [com.pulseloop.data.DisplaySteps] for the pick rule. + */ + val preferPhone: Boolean = false, ) data class CoachFeatureFlags( diff --git a/app/src/main/java/com/pulseloop/coach/tools/ToolImplementations.kt b/app/src/main/java/com/pulseloop/coach/tools/ToolImplementations.kt index cb282b27..62a085d9 100644 --- a/app/src/main/java/com/pulseloop/coach/tools/ToolImplementations.kt +++ b/app/src/main/java/com/pulseloop/coach/tools/ToolImplementations.kt @@ -60,13 +60,14 @@ object RetrievalTools { val dayTs = CoachDataAccess.parseLocalDate(date) if (dayTs == null) return@runBlocking """{"error":"invalid date: $date"}""" val activity = db.activityDailyDao().byDay(dayTs) + val displaySteps = com.pulseloop.data.DisplaySteps.count(activity, ctx.preferPhone) val hrVals = db.measurementDao().range(MeasurementKind.HEART_RATE.name, dayTs, dayTs + 86400000L) val hrStats = if (hrVals.isNotEmpty()) { val vals = hrVals.map { it.value } """"mean":${(vals.average() * 10).toLong() / 10.0},"min":${vals.minOrNull()!!.toInt()},"max":${vals.maxOrNull()!!.toInt()}"""" } else null val sleep = db.sleepSessionDao().byDay(dayTs) - """{"date":"$date","data_available":${activity != null || hrVals.isNotEmpty()},"activity":{"${if (activity != null) """steps":${activity.steps},"calories":${(activity.calories * 10).toLong() / 10.0},"distance_km":${(activity.distanceMeters / 1000 * 10).toLong() / 10.0},"active_minutes":${activity.activeMinutes}""" else """steps":0,"calories":0,"distance_km":0,"active_minutes":0"""}},"hr":{${hrStats ?: """"mean":null,"min":null,"max":null""""}},"sleep":${if (sleep != null) """{"total_min":${sleep.totalMinutes},"score":${sleep.totalMinutes / 5}}""" else "null"}}""" + """{"date":"$date","data_available":${activity != null || hrVals.isNotEmpty()},"activity":{"${if (activity != null) """steps":${displaySteps ?: 0},"calories":${(activity.calories * 10).toLong() / 10.0},"distance_km":${(activity.distanceMeters / 1000 * 10).toLong() / 10.0},"active_minutes":${activity.activeMinutes}""" else """steps":0,"calories":0,"distance_km":0,"active_minutes":0"""}},"hr":{${hrStats ?: """"mean":null,"min":null,"max":null""""}},"sleep":${if (sleep != null) """{"total_min":${sleep.totalMinutes},"score":${sleep.totalMinutes / 5}}""" else "null"}}""" } ToolResult(result) } @@ -95,7 +96,7 @@ object RetrievalTools { val start = params["start_date"] ?: return@CoachToolDef ToolResult("""{"error":"missing 'start_date'"}""", isError = true) val end = params["end_date"] ?: start val result = kotlinx.coroutines.runBlocking { - val points = CoachDataAccess.seriesPoints(db, metric, start, end, "day") + val points = CoachDataAccess.seriesPoints(db, metric, start, end, "day", ctx.preferPhone) val sb = StringBuilder() sb.append("""{"metric":"$metric","points":[""") points.forEachIndexed { i, (date, value) -> @@ -178,7 +179,7 @@ object RetrievalTools { val d = java.time.Instant.ofEpochMilli(it.date).atZone(java.time.ZoneId.systemDefault()).toLocalDate().toString() d >= start && d <= end } - val totalSteps = rows.sumOf { it.steps } + val totalSteps = com.pulseloop.data.DisplaySteps.counts(rows, ctx.preferPhone).sum() sb.append(""""activity":{"days_available":${rows.size},"totals":{"steps":$totalSteps,"calories":${rows.sumOf { it.calories }},"active_minutes":${rows.sumOf { it.activeMinutes }}}},""") } val hrData = db.measurementDao().range(MeasurementKind.HEART_RATE.name, 0, System.currentTimeMillis()) @@ -326,7 +327,8 @@ object RetrievalTools { val goal = kotlinx.coroutines.runBlocking { db.userGoalDao().get() } val todayStart = java.time.Instant.now().atZone(java.time.ZoneId.systemDefault()).truncatedTo(java.time.temporal.ChronoUnit.DAYS).toInstant().toEpochMilli() val today = kotlinx.coroutines.runBlocking { db.activityDailyDao().byDay(todayStart) } - ToolResult("""{"today":{"steps":${today?.steps ?: 0},"steps_goal":${goal?.steps ?: 10000},"active_minutes":${today?.activeMinutes ?: 0},"active_minutes_goal":${goal?.activeMinutes ?: 45}},"sleep_hours_goal":${(goal?.sleepMinutes ?: 480) / 60}}""") + val displaySteps = com.pulseloop.data.DisplaySteps.count(today, ctx.preferPhone) ?: 0 + ToolResult("""{"today":{"steps":$displaySteps,"steps_goal":${goal?.steps ?: 10000},"active_minutes":${today?.activeMinutes ?: 0},"active_minutes_goal":${goal?.activeMinutes ?: 45}},"sleep_hours_goal":${(goal?.sleepMinutes ?: 480) / 60}}""") } // ── get_recent_anomalies ─────────────────────────────────────────── @@ -343,7 +345,8 @@ object RetrievalTools { )), ) { _, ctx -> val db = ctx.db ?: return@CoachToolDef ToolResult("""{"error":"database not available"}""", isError = true) - val recentSteps = kotlinx.coroutines.runBlocking { db.activityDailyDao().recent(14) }.map { it.steps.toDouble() } + val recentRows = kotlinx.coroutines.runBlocking { db.activityDailyDao().recent(14) } + val recentSteps = com.pulseloop.data.DisplaySteps.counts(recentRows, ctx.preferPhone).map { it.toDouble() } val stepOutliers = AnalysisEngine.outliers(recentSteps) ToolResult("""{"steps_outliers":${stepOutliers.size},"steps_analyzed":${recentSteps.size}}""") } diff --git a/app/src/main/java/com/pulseloop/data/DataArchive.kt b/app/src/main/java/com/pulseloop/data/DataArchive.kt index 949e02b1..e81e7e76 100644 --- a/app/src/main/java/com/pulseloop/data/DataArchive.kt +++ b/app/src/main/java/com/pulseloop/data/DataArchive.kt @@ -74,6 +74,13 @@ data class PulseArchive( * buckets back into the day. */ val deletedSteps: Int = 0, val deletedDistanceMeters: Double = 0.0, val estimatedActiveCalories: Double? = null, + /** PR #98 (Path B): phone-sourced steps, kept separate from the ring's [steps]. Defaulted + * to `null` for two reasons. An archive written before the column existed deserializes as + * "no phone data", which is exactly what [ActivityDailyEntity.displaySteps] already means + * by a missing value. A default of `0` would falsely mean "the phone reported zero", and + * after a restore that would hide the ring's own count on every historical day in Phone + * mode. */ + val phoneSteps: Int? = null, ) @Serializable data class ActivityBucketDTO( diff --git a/app/src/main/java/com/pulseloop/data/DataArchiveService.kt b/app/src/main/java/com/pulseloop/data/DataArchiveService.kt index 4e1c778e..f985d12e 100644 --- a/app/src/main/java/com/pulseloop/data/DataArchiveService.kt +++ b/app/src/main/java/com/pulseloop/data/DataArchiveService.kt @@ -82,6 +82,7 @@ object DataArchiveService { estimatedActiveCalories = c.dblOrNull("estimatedActiveCalories"), deletedSteps = c.int_("deletedSteps"), deletedDistanceMeters = c.dbl("deletedDistanceMeters"), + phoneSteps = c.intOrNull("phoneSteps"), ) }, activityBuckets = collect("activity_buckets") { c -> @@ -399,6 +400,7 @@ object DataArchiveService { estimatedActiveCalories = a.estimatedActiveCalories, deletedSteps = a.deletedSteps, deletedDistanceMeters = a.deletedDistanceMeters, + phoneSteps = a.phoneSteps, )) } for (b in archive.activityBuckets) { diff --git a/app/src/main/java/com/pulseloop/data/DisplaySteps.kt b/app/src/main/java/com/pulseloop/data/DisplaySteps.kt new file mode 100644 index 00000000..cf53671a --- /dev/null +++ b/app/src/main/java/com/pulseloop/data/DisplaySteps.kt @@ -0,0 +1,58 @@ +package com.pulseloop.data + +import com.pulseloop.data.entity.ActivityDailyEntity + +/** + * The one place the step-source preference is applied to display values. + * + * Path B keeps phone and ring steps in separate columns on [ActivityDailyEntity] + * (`phoneSteps` vs `steps`). Every screen, the widgets, notifications, the coach + * context and the coach tools must pick between them the same way, from the + * user's "Ring" / "Phone" preference. Doing that at each call site meant fifteen + * places had to agree — and the coach disagreed with Today for two weeks + * (review item 5, PR #98). + * + * This object is that place. Callers pass `preferPhone: Boolean` — resolved once + * at the boundary (a ViewModel's `preferPhone()` lambda, `AppServices`' single + * `StepSourcePrefs`), threaded down as a plain parameter. It does not read + * SharedPreferences itself, so callers can control when the preference is + * consulted (per emission, per composition) and tests can drive it directly. + * + * **Not for the exporter or the archive.** Those must serialize the ring's + * actual `steps` column, not whatever the user has chosen to display. Passing + * a `preferPhone` value into an export path would silently write phone steps + * into the ring's column on the next restore. + */ +object DisplaySteps { + + /** + * The step count a screen should show for [row] under the user's preference. + * + * Null-safe (a missing row has no count). Phone mode returns [ActivityDailyEntity.phoneSteps] + * when it has one, else the ring's [ActivityDailyEntity.steps]; ring mode always returns + * `steps`. That fallback is what makes an un-backfilled day still show something in Phone + * mode instead of a spurious zero. + */ + fun count(row: ActivityDailyEntity?, preferPhone: Boolean): Int? = + row?.displaySteps(preferPhone) + + /** + * The step counts for [rows], in the same order, under the user's preference. + * Vector form of [count] — used by the 7-day hero series and week widgets. + */ + fun counts(rows: List, preferPhone: Boolean): List = + rows.map { it.displaySteps(preferPhone) } + + /** + * [row] with its `steps` column projected to the display value, for callers + * that hold a whole entity in their state (`ActivityViewModel.recentDays`, + * the widget snapshot). Only `steps` is rewritten; `distanceMeters`, + * `calories` and the rest of the row stay as the ring wrote them. + */ + fun project(row: ActivityDailyEntity?, preferPhone: Boolean): ActivityDailyEntity? = + row?.forDisplay(preferPhone) + + /** [rows] projected the same way, same order. */ + fun project(rows: List, preferPhone: Boolean): List = + rows.map { it.forDisplay(preferPhone) } +} diff --git a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt index f09ba2b6..5e527811 100644 --- a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt +++ b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt @@ -44,7 +44,7 @@ import com.pulseloop.data.entity.* CachedFoodProductEntity::class, MeasurementDeletionEntity::class, ], - version = 28, + version = 29, exportSchema = false, ) abstract class PulseLoopDatabase : RoomDatabase() { @@ -523,6 +523,21 @@ abstract class PulseLoopDatabase : RoomDatabase() { } } + /** + * v28 -> v29: `activity_daily.phoneSteps` — the phone-source step column. + * + * Phone-sourced steps live in their own column instead of overwriting [ActivityDailyEntity.steps], + * so the ring's totals (steps, calories, distance) are never clobbered and switching the step + * source is a read-time choice, not a destructive write. Null for every existing row: no day + * has a phone value under the new design yet, and the display falls back to `steps` when null. + */ + private val MIGRATION_28_29 = object : Migration(28, 29) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `activity_daily` ADD COLUMN `phoneSteps` INTEGER") + } + } + + private fun adoptStableMeasurementIdentities(db: SupportSQLiteDatabase) { db.execSQL("DROP INDEX IF EXISTS `index_measurements_kindRaw_timestamp_sourceRaw`") db.execSQL( @@ -617,6 +632,7 @@ abstract class PulseLoopDatabase : RoomDatabase() { MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, + MIGRATION_28_29, ) // Downgrades only (sideloading an older APK). A blanket destructive // fallback would silently wipe every measurement, sleep session, and diff --git a/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt b/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt index 6e74fd85..f76b467d 100644 --- a/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt +++ b/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt @@ -77,6 +77,11 @@ data class ActivityDailyEntity( @PrimaryKey val id: String = java.util.UUID.randomUUID().toString(), val date: Long, // epoch millis, start of day val steps: Int = 0, + /** Phone-sourced step count for this day (Health Connect), stored separately from [steps]. + * Null = no phone value written for this day. The display picks between [steps] and + * [phoneSteps] at read time based on the user's step-source preference; neither column + * overwrites the other, so switching source is reversible. */ + val phoneSteps: Int? = null, val calories: Double = 0.0, val distanceMeters: Double = 0.0, val activeMinutes: Int = 0, @@ -100,7 +105,34 @@ data class ActivityDailyEntity( */ val deletedSteps: Int = 0, val deletedDistanceMeters: Double = 0.0, -) +) { + /** + * The step count a screen should show for this day under the user's step-source preference + * (Path B read-time selection, PR #98). Ring mode shows the ring's own column; phone mode + * shows [phoneSteps] when the phone has a value for the day, falling back to the ring's + * column when it does not — so a day the phone never backfilled still shows something. + * + * `internal` on purpose (review item 5, PR #98). The one production caller is + * [com.pulseloop.data.DisplaySteps] — the one place the preference is applied. Leaving + * this `public` let each screen, widget, notification and coach path call it directly + * and drift; that is the bug the reviewer flagged. New callers use + * [com.pulseloop.data.DisplaySteps], not this. Test source sets (same module) can still + * exercise it directly. + */ + internal fun displaySteps(preferPhone: Boolean): Int = + if (preferPhone) phoneSteps ?: steps else steps + + /** + * The whole entity a screen should show, with `steps` projected for display under the user's + * step-source preference. Only `steps` is rewritten; `distanceMeters`, `calories`, and the + * rest of the row are left as the ring wrote them (Path B — see PR #98's design notes). + * + * `internal` for the same reason as [displaySteps] — use + * [com.pulseloop.data.DisplaySteps.project]. + */ + internal fun forDisplay(preferPhone: Boolean): ActivityDailyEntity = + if (preferPhone && phoneSteps != null) copy(steps = phoneSteps) else this +} /** * Ported from [ActivityBucketSample] in PulseModels.swift. diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt b/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt index a1a77bf3..eb034129 100644 --- a/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt +++ b/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt @@ -24,8 +24,25 @@ import java.time.ZoneOffset */ object HealthConnectTypeMappings { - /** [com.pulseloop.data.entity.MeasurementEntity.sourceRaw] values that must never reach - * Health Connect (mirrors iOS, which never exports demo/mock data). */ + /** + * `source` / `sourceRaw` values whose rows must never be exported to Health Connect — + * seeded placeholder data (mirrors iOS, which never exports demo/mock). + * + * `"phone"` used to be listed here as well, to break a self-feedback loop: back when the + * phone write path overwrote `activity_daily.steps` — the very column this exporter reads — + * PulseLoop would re-export Health Connect's own data back to Health Connect, and the phone + * reader would then read it back as if it were the phone's. Under the separate-column design + * (PR #98) the phone's step count lives in `phoneSteps`, which this exporter never reads, so + * the loop is broken by construction and the exclusion is gone. `PhoneStepManager`'s + * own-origin subtraction at read time is unaffected and still runs — it cleans up the + * historic exports that already exist in Health Connect. + * + * A phone-first day (`source == "phone"`, `steps == 0`, `calories == 0.0`, + * `distanceMeters == 0.0`) still produces no record, and does so on its own merits: every + * metric fails its plausibility guard (`isPlausibleSteps` floors at 1, the other two at + * strictly > 0), so the day drops through the same path as any other empty day. It no + * longer needs a source-based shortcut to be skipped. + */ val EXCLUDED_SOURCES = setOf("demo", "mock") const val HOUR_MS = 3_600_000L diff --git a/app/src/main/java/com/pulseloop/notifications/CoachNotificationSlotRunner.kt b/app/src/main/java/com/pulseloop/notifications/CoachNotificationSlotRunner.kt index 1c4e36cb..a47deb6b 100644 --- a/app/src/main/java/com/pulseloop/notifications/CoachNotificationSlotRunner.kt +++ b/app/src/main/java/com/pulseloop/notifications/CoachNotificationSlotRunner.kt @@ -458,7 +458,11 @@ private class CoachSlotProductionEngine( */ suspend fun generate(slot: CoachNotificationSlot, now: Long): CoachNotificationContent { val environment = WeatherContextService(context).snapshot() - val packet = NotificationContextBuilder.build(slot, db, now, environment = environment) + // Path B (PR #98): the check-in quotes the step count from the source the user chose. + val preferPhone = com.pulseloop.settings.StepSourcePrefs(context).preferPhone() + val packet = NotificationContextBuilder.build( + slot, db, now, environment = environment, preferPhone = preferPhone, + ) // Variety + anti-repeat (iOS #65): a deterministic per-day/slot coaching angle, // plus the last few delivered check-ins so the model doesn't repeat itself. diff --git a/app/src/main/java/com/pulseloop/notifications/NotificationContextBuilder.kt b/app/src/main/java/com/pulseloop/notifications/NotificationContextBuilder.kt index ab4a986c..367e7d14 100644 --- a/app/src/main/java/com/pulseloop/notifications/NotificationContextBuilder.kt +++ b/app/src/main/java/com/pulseloop/notifications/NotificationContextBuilder.kt @@ -23,6 +23,8 @@ object NotificationContextBuilder { db: PulseLoopDatabase, now: Long = System.currentTimeMillis(), environment: EnvironmentContext? = null, + /** When true, today's steps come from [ActivityDailyEntity.phoneSteps] (Path B, PR #98). */ + preferPhone: Boolean = false, ): NotificationContextPacket { val cutoff = now - 12 * 3600_000L @@ -30,9 +32,13 @@ object NotificationContextBuilder { val goal = db.userGoalDao().get() val device = db.deviceDao().current() - // Today's activity + // Today's activity. Steps are picked at read time by the user's step-source preference + // (Path B, PR #98) — a phone-mode evening check-in should quote the phone's count, not + // the ring's. Distance and calories stay as the ring wrote them, matching every other + // display read site. val todayStart = localStartOfDay(now) val todayActivity = db.activityDailyDao().byDay(todayStart) + val todaySteps = com.pulseloop.data.DisplaySteps.count(todayActivity, preferPhone) // Latest sleep val latestSleep = db.sleepSessionDao().recent(1).firstOrNull() @@ -93,7 +99,7 @@ object NotificationContextBuilder { exerciseDaysWeekly = goal?.workoutsPerWeek ?: 4, ), today = NotificationContextPacket.DayContext( - steps = todayActivity?.steps, + steps = todaySteps, calories = todayActivity?.calories, distanceMeters = todayActivity?.distanceMeters, activeMinutes = todayActivity?.activeMinutes, diff --git a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt index 4c67857e..8ba720ca 100644 --- a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt +++ b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt @@ -64,15 +64,40 @@ sealed class PulseEvent { if (this === other) return true if (other !is RawPacket) return false return direction == other.direction && data.contentEquals(other.data) && - decoded == other.decoded && deviceType == other.deviceType + decoded == other.decoded && deviceType == other.deviceType } override fun hashCode(): Int = 31 * (31 * (31 * direction.hashCode() + data.contentHashCode()) + decoded.hashCode()) + - (deviceType?.hashCode() ?: 0) + (deviceType?.hashCode() ?: 0) } data class ActivityUpdate(val timestamp: java.time.Instant, val steps: Int, val distanceMeters: Double, val calories: Double) : PulseEvent() data class ActivityBucket(val timestamp: java.time.Instant, val steps: Int, val distanceMeters: Double) : PulseEvent() data object ActivitySyncReset : PulseEvent() + /** Steps counted by the phone's own pedometer, published by the app's + * [com.pulseloop.PhoneStepManager] when the user has chosen "phone" as their step source. + * Distinct from [ActivityUpdate], which carries the ring's cumulative counter. + * + * Only `steps` is carried. Distance and calories are deliberately not baked in here: + * calories come from [com.pulseloop.service.DailyCalorieEstimator] (which needs the + * user's weight, HR, and workout history), and workout distance is credited to the + * day by [com.pulseloop.service.ActivityRollup]. Writing either from this event would + * overwrite what those paths computed. */ + data class PhoneStepsUpdate( + val timestamp: java.time.Instant, + val steps: Int, + ) : PulseEvent() + + /** + * The phone step reader failed to produce a value this pass — Health Connect unreachable, + * or READ_STEPS not granted. Published by [com.pulseloop.MainActivity.refreshPhoneSteps] + * on a `refresh()` false return. [com.pulseloop.service.EventPersistenceSubscriber] + * handles it by clearing *today's* `phoneSteps` on the `activity_daily` row, so the + * display's read-time pick (`phoneSteps ?: steps`) falls back to the ring's live count + * instead of showing whatever stale phone value was last written. Historical days keep + * their `phoneSteps` — that data was real when collected, and a permission loss today + * does not retract it. + */ + data object PhoneStepSourceUnavailable : PulseEvent() /** * [spot] marks the one settled reading a spot measurement publishes for itself (issue #60); * false for the ring's live stream. [ringWillLogIt] additionally says this ring writes that @@ -161,6 +186,13 @@ object PulseEventBus { init { dispatchScope.launch { + // The drain runs from process start. Subscribers must attach by the time + // Application.onCreate returns — DiagnosticsSubscriber and (since PR #98) + // EventPersistenceSubscriber both do. Every real publisher — ring, phone, sync — runs + // after that, so no event published by the app can reach a drain with no subscriber. + // A publisher added inside Application.onCreate *before* those subscribers start + // would lose its event here; if that ever becomes necessary, the fix is to attach the + // subscribers first, not to hold this drain. // The bus is process-long and single-drained: an uncaught throw here would kill the // dispatcher and silently stop every subscriber for the rest of the process (the // SupervisorJob does not restart it). Isolate each emit so one bad event can't do that. diff --git a/app/src/main/java/com/pulseloop/service/DailyCalorieEstimator.kt b/app/src/main/java/com/pulseloop/service/DailyCalorieEstimator.kt index f36776d3..b6cfecb5 100644 --- a/app/src/main/java/com/pulseloop/service/DailyCalorieEstimator.kt +++ b/app/src/main/java/com/pulseloop/service/DailyCalorieEstimator.kt @@ -38,6 +38,14 @@ object DailyCalorieEstimator { */ const val RING_HISTORY_SOURCE = "ring_history" + /** + * The `source` value [com.pulseloop.service.EventPersistenceSubscriber] writes for a + * phone-sourced day. Like `ring_history`, the row's `calories` column is not a + * device-reported figure — the phone reader supplies steps only — so it must not be treated + * as one by [deviceReportedCalories]. + */ + const val PHONE_SOURCE = com.pulseloop.settings.StepSourcePrefs.SOURCE_PHONE + data class Profile(val sex: String?, val age: Int?, val weightKg: Double?, val heightCm: Double?) /** Mifflin-St Jeor BMR (kcal/day), clamped at 0 like iOS's `mifflinBMR`. */ @@ -83,9 +91,16 @@ object DailyCalorieEstimator { * This was inverted on first port (it returned the value *only* for `ring_history`, and ignored * genuine device calories from every other source), which made the estimate replace real data * and real data replace the estimate. + * + * A `phone`-sourced row is excluded for the same reason as `ring_history`: the phone reader + * supplies steps only, so its `calories` column is never a device-reported figure — it's + * either the estimator's own fill-in or the hardcoded `steps * 0.04` placeholder this fork + * wrote before that path existed. Excluding it lets [effectiveCalories] fall through to + * `estimatedActiveCalories` on the same row. */ fun deviceReportedCalories(day: ActivityDailyEntity): Double? = - if (day.source == RING_HISTORY_SOURCE || day.calories <= 0.0) null else day.calories + if (day.source == RING_HISTORY_SOURCE || day.source == PHONE_SOURCE || day.calories <= 0.0) null + else day.calories /** * What to display: the device's own figure when it reported one, else the estimated **total** — @@ -137,7 +152,7 @@ object DailyCalorieEstimator { val buckets = db.activityBucketDao().byDay(dayStart) val workouts = db.activitySessionDao().recent(WORKOUT_SCAN_LIMIT).filter { it.statusRaw == "finished" && it.endedAt != null && - it.startedAt < dayEnd && it.endedAt!! > dayStart + it.startedAt < dayEnd && it.endedAt!! > dayStart } val active = estimateNetActive( @@ -227,7 +242,7 @@ object DailyCalorieEstimator { if (bucket.steps <= 0) continue val bucketEnd = bucket.startEpoch + (BUCKET_DURATION_SECONDS * 1000).toLong() val keepFraction = 1.0 - - overlapSeconds(Window(bucket.startEpoch, bucketEnd), covered) / BUCKET_DURATION_SECONDS + overlapSeconds(Window(bucket.startEpoch, bucketEnd), covered) / BUCKET_DURATION_SECONDS if (keepFraction <= 0.0) continue val durationMinutes = BUCKET_DURATION_SECONDS / 60.0 val cadence = bucket.steps / durationMinutes diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index 7584b1f1..9f2514ae 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -7,6 +7,7 @@ import com.pulseloop.data.dao.MeasurementDeletionDao import com.pulseloop.data.entity.* import com.pulseloop.health.HealthConnectExportWorker import com.pulseloop.settings.QuietHoursPrefs +import com.pulseloop.settings.StepSourcePrefs import com.pulseloop.ring.* import com.pulseloop.widgets.WidgetSnapshotPublisher import kotlinx.coroutines.* @@ -55,7 +56,12 @@ class EventPersistenceSubscriber( fun start() { if (job != null) return - job = scope.launch { + // UNDISPATCHED: the collect call registers its subscriber synchronously, before start() + // returns. Without this, `scope.launch` merely schedules the coroutine and there is still + // a window between start() returning and the subscriber attaching — which is exactly the + // window MainActivity.onResume → refreshPhoneSteps publishes into on a cold start. + // Review item 1, PR #98. Idempotent (the `if (job != null)` guard above). + job = scope.launch(start = CoroutineStart.UNDISPATCHED) { PulseEventBus.events.collect { event -> persist(event) } } } @@ -85,6 +91,7 @@ class EventPersistenceSubscriber( is PulseEvent.TemperatureSample, is PulseEvent.ActivityUpdate, is PulseEvent.ActivityBucket, + is PulseEvent.PhoneStepsUpdate, is PulseEvent.SleepTimeline -> true else -> false } @@ -382,6 +389,9 @@ class EventPersistenceSubscriber( else db.measurementDao().insert(measurement) } is PulseEvent.ActivityUpdate -> { + // The ring always writes its own totals, in every mode. Under Path B the phone's + // count lives in `phoneSteps`, so there is no ownership conflict to gate on — the + // read path picks which column to show at display time. upsertActivityDaily(event.timestamp.toEpochMilli(), event.steps, event.calories, event.distanceMeters) } is PulseEvent.ActivityBucket -> { @@ -391,6 +401,20 @@ class EventPersistenceSubscriber( // its single largest bucket. Calories omitted (unverified ring field). applyActivityBucket(event.timestamp.toEpochMilli(), event.steps, event.distanceMeters) } + is PulseEvent.PhoneStepsUpdate -> { + upsertActivityDailyFromPhone(event.timestamp.toEpochMilli(), event.steps) + } + // The phone reader just failed (permission revoked mid-day, provider gone, HC + // unavailable). Clear *today's* phoneSteps so the display's read-time pick falls + // back to the ring's live `steps` — otherwise the last value the phone wrote + // stays on the Today tile, widget and check-ins for the rest of the day while + // the ring's own count climbs unseen. Historical days keep their phoneSteps: a + // permission loss today does not retract data that was real when collected. + is PulseEvent.PhoneStepSourceUnavailable -> { + val dayStart = com.pulseloop.util.TimeUtil.startOfTodayLocal() + val existing = db.activityDailyDao().byDay(dayStart) + clearPhoneStepsOnRow(existing)?.let { db.activityDailyDao().upsert(it) } + } is PulseEvent.SleepTimeline -> { upsertSleepSession(event.timestamp.toEpochMilli(), event.stages, event.completeSession) } @@ -497,15 +521,24 @@ class EventPersistenceSubscriber( val deletion = com.pulseloop.data.ActivityBucketDeletion val hasDeletion = existing.deletedSteps > 0 || existing.deletedDistanceMeters > 0.0 db.activityDailyDao().upsert(existing.copy( + // A row the phone created first (`source == "phone"`) flips to `"ring"` when the + // ring writes. The `source` column still has two downstream consumers — + // `HealthConnectTypeMappings.EXCLUDED_SOURCES` (which filters demo/mock, not + // phone) and [DailyCalorieEstimator.deviceReportedCalories] — but only the latter + // cares about `"phone"`, and it reads the column as "who last wrote these data + // columns". So once the ring has supplied `steps`/`calories`/`distance`, the row + // belongs to the ring and the estimator must see it that way. Other sources + // ("ring_history", "manual_recording", "hr_and_manual") are preserved as before. + source = if (existing.source == StepSourcePrefs.SOURCE_PHONE) StepSourcePrefs.SOURCE_RING else existing.source, steps = if (stale) steps - else deletion.ratchetAgainstRing(existing.steps, steps, existing.deletedSteps), + else deletion.ratchetAgainstRing(existing.steps, steps, existing.deletedSteps), // A bucket carries no calorie field, so there is nothing to subtract from the // ring's own figure — the deletion drops it and the app's estimate takes over // (see [com.pulseloop.data.ActivityBucketDeletion]). Ratcheting here would put it // straight back. calories = if (stale) calories - else if (hasDeletion) existing.calories - else maxOf(existing.calories, calories), + else if (hasDeletion) existing.calories + else maxOf(existing.calories, calories), distanceMeters = if (stale) distanceM else deletion.ratchetAgainstRing( existing.distanceMeters, distanceM, existing.deletedDistanceMeters, ), @@ -514,11 +547,53 @@ class EventPersistenceSubscriber( } else { db.activityDailyDao().upsert(ActivityDailyEntity( date = dayStart, steps = steps, calories = calories, - distanceMeters = distanceM, source = "ring", + distanceMeters = distanceM, source = StepSourcePrefs.SOURCE_RING, )) } } + /** + * Persist the phone's step count for one day, in its own column. + * + * Under the separate-column design ([ActivityDailyEntity.phoneSteps]), an existing row's + * **data columns** are untouched except for `phoneSteps`: `steps`, `distanceMeters`, + * `calories`, and `source` are left exactly as the ring wrote them. `syncedAt` also moves — + * metadata, not data. `updatedAt` is *preserved* (review item 10): it is what + * [com.pulseloop.health.exporters.ActivityExporter] selects on, so bumping it for a + * `phoneSteps`-only change would re-select the row and re-export unchanged ring data. The + * display picks between `steps` and `phoneSteps` at read time based on the user's + * step-source preference, so switching source is a read-time choice, not a destructive + * write: the ring's totals are never clobbered and switching back is instant. + * + * A brand-new row (phone wrote first, ring hasn't yet) is created with + * `source = "phone"` so the day is visible in the "real" DAO queries + * (`source NOT IN ('demo','mock')`) even before the ring has data for it. The old design + * also needed the source label to keep the row out of the Health Connect export, via + * `HealthConnectTypeMappings.EXCLUDED_SOURCES` — that exclusion is gone (the phone's value + * used to land in `steps`, the exported column; under the separate-column design the + * self-feedback loop is broken by construction). A phone-first day still produces no HC + * record: it carries `steps = 0`, and `isPlausibleSteps` floors at 1, so the plausibility + * guards skip it regardless of source. A phone-first row also stamps `updatedAt = now` + * (see [phoneStepRow]) so the exporter's watermark query picks it up. + * + * A later ring write flips `source` from `"phone"` to `"ring"` — see [upsertActivityDaily]. + * That is not a mismatch: `source` means "who last wrote the data columns", and once the + * ring has supplied `steps`/`calories`/`distance`, the row belongs to the ring. + * [DailyCalorieEstimator.deviceReportedCalories] reads `source` for exactly that question. + * The phone's step value is kept visible by the separate `phoneSteps` column, not by the + * source label — the old "overwrite source and reset it back to ring on next ring write" + * dance existed because the phone used to occupy `steps`; under the separate-column design + * nothing needs it any more. + * + * A no-op when the day already holds this exact phone value: on every foreground the phone + * reader re-publishes today's total, so a value that hasn't moved must not produce a write. + */ + private suspend fun upsertActivityDailyFromPhone(ts: Long, steps: Int) { + val dayStart = com.pulseloop.util.TimeUtil.startOfDayLocal(ts) + val existing = db.activityDailyDao().byDay(dayStart) + phoneStepRow(existing, dayStart, steps)?.let { db.activityDailyDao().upsert(it) } + } + /** * Persist one intraday activity bucket from ring history (a Colmi `0x43` sample) and recompute * its day's total. The bucket is **upserted by its start time**, so re-syncing the same bucket @@ -561,11 +636,16 @@ class EventPersistenceSubscriber( val isToday = dayStart == com.pulseloop.util.TimeUtil.startOfTodayLocal() val stale = existing != null && existing.steps > 200_000 val ratchet = isToday && existing != null && !stale + // Under Path B the ring always writes: the phone never occupies `steps`, so there is no + // ownership gate to skip this upsert. The phone's value lives in `phoneSteps`, untouched + // by anything here. + val effectiveSource = if (existing?.source == StepSourcePrefs.SOURCE_PHONE) "ring_history" + else if (ratchet) existing!!.source else "ring_history" db.activityDailyDao().upsert( (existing ?: ActivityDailyEntity(date = dayStart, source = "ring_history")).copy( steps = if (ratchet) maxOf(existing!!.steps, totalSteps) else totalSteps, distanceMeters = if (ratchet) maxOf(existing!!.distanceMeters, totalDistance) else totalDistance, - source = if (ratchet) existing!!.source else "ring_history", + source = effectiveSource, syncedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(), ) @@ -731,7 +811,7 @@ class EventPersistenceSubscriber( val matched: List> = segments.map { seg -> val best = available.maxByOrNull { overlap(seg.start, seg.end, it.startAt, it.endAt) } if (best != null && (overlap(seg.start, seg.end, best.startAt, best.endAt) > 0L || - best.startAt in seg.start..seg.end)) { + best.startAt in seg.start..seg.end)) { available.remove(best) seg to best } else { @@ -851,6 +931,77 @@ class EventPersistenceSubscriber( } } +/** + * The row a [PulseEvent.PhoneStepsUpdate] should write for one day — or null when it would be a + * no-op. + * + * Pure, so the phone write's column-only contract can be tested without a Room instance (same + * pattern as the sleep-merge helpers below). The contract, all of which is asserted in + * `EventPersistencePhoneWriteTest`: + * + * - **Only two columns change on an existing row:** `phoneSteps` and `syncedAt`. `steps`, + * `calories`, `distanceMeters`, `source`, and `updatedAt` are carried through untouched — + * the first four keep the source switch reversible, and preserving `updatedAt` stops + * [com.pulseloop.health.exporters.ActivityExporter] from re-selecting the row for unchanged + * ring data (review item 10). + * - **A brand-new row is created with `source = "phone"`**, so the day is visible in the + * "real" DAO queries even before the ring has data for it. New rows also stamp + * `updatedAt = now` so a phone-first day is picked up by the exporter's watermark query. + * - **Writing the same value twice is a no-op** (returns null): the phone reader re-publishes + * today's total on every foreground, so a value that hasn't moved must not produce a write. + * - **`phoneSteps = 0` is a real value, distinct from null.** `null → 0` writes (the midnight + * gap: the phone has begun counting and counted zero); `0 → 0` is a no-op. + */ +internal fun phoneStepRow( + existing: ActivityDailyEntity?, + dayStart: Long, + steps: Int, + now: Long = System.currentTimeMillis(), +): ActivityDailyEntity? = when { + // No-op when the day already holds this exact phone value. + existing != null && existing.phoneSteps == steps -> null + // Existing row: phoneSteps and syncedAt only. updatedAt is preserved — ActivityExporter + // selects on it, so a metadata-only phone write must not re-select the row and re-export + // unchanged ring steps/calories/distance (review item 10). + existing != null -> existing.copy(phoneSteps = steps, syncedAt = now) + // New phone-first row: updatedAt stamps "this row just appeared" so the exporter picks + // it up. + else -> ActivityDailyEntity( + date = dayStart, + source = StepSourcePrefs.SOURCE_PHONE, + phoneSteps = steps, + syncedAt = now, + updatedAt = now, + ) +} + +/** + * The row a [PulseEvent.PhoneStepSourceUnavailable] should write for today — or null when it + * would be a no-op. + * + * Review item 4, PR #98. When the phone reader fails mid-day (permission revoked, provider + * uninstalled, HC unreachable), the last `phoneSteps` value it wrote stays on the row and the + * display — which picks `phoneSteps ?: steps` — keeps showing it, hiding the ring's live count + * for the rest of the day. Clearing today's `phoneSteps` restores the fallback. + * + * Pure, so the contract can be tested without Room (same pattern as [phoneStepRow]). + * + * The contract: + * - **Returns null when the row is absent** — nothing to clear. `source = "phone"` on a + * phone-first row is preserved (see [phoneStepRow]'s KDoc for why that matters). + * - **Returns null when `phoneSteps` is already null** — the row already falls back to the + * ring. Repeated `PhoneStepSourceUnavailable` events are common (every failed foreground), + * and each one must be a no-op after the first. + * - **Returns a copy with only `phoneSteps = null`** — `steps`, `calories`, `distanceMeters`, + * `source`, `syncedAt` and `updatedAt` are untouched. Not bumping `updatedAt` is deliberate: + * [com.pulseloop.health.exporters.ActivityExporter] selects rows by it, and a metadata-only + * change here would re-export unchanged ring data. (This is also review item 10's rule + * applied proactively to this path.) + */ +internal fun clearPhoneStepsOnRow(existing: ActivityDailyEntity?): ActivityDailyEntity? = + if (existing == null || existing.phoneSteps == null) null + else existing.copy(phoneSteps = null) + /** * Is this reading one the ring will hand back from its own history, so that the two copies have to * be reconciled later ([EventPersistenceSubscriber.adoptRingsCopy])? Both halves are required: diff --git a/app/src/main/java/com/pulseloop/service/MetricsService.kt b/app/src/main/java/com/pulseloop/service/MetricsService.kt index fde35045..5a81d5f8 100644 --- a/app/src/main/java/com/pulseloop/service/MetricsService.kt +++ b/app/src/main/java/com/pulseloop/service/MetricsService.kt @@ -33,7 +33,16 @@ object MetricsService { val spo2Trend24h: List = emptyList(), ) - suspend fun buildTodaySummary(db: PulseLoopDatabase): TodaySummary { + /** + * The day's activity totals for display. + * + * [preferPhone] is the resolved step-source preference (review item 5, PR #98). Pass it + * through [com.pulseloop.data.DisplaySteps] to pick between the ring's `steps` column and + * the phone's `phoneSteps` column per Path B. No callers today — kept for parity with the + * iOS reference and so that any future wiring of this aggregation gets the same answer + * Today does. + */ + suspend fun buildTodaySummary(db: PulseLoopDatabase, preferPhone: Boolean): TodaySummary { val now = System.currentTimeMillis() val todayStart = Instant.ofEpochMilli(now).atZone(java.time.ZoneId.systemDefault()) .truncatedTo(ChronoUnit.DAYS).toInstant().toEpochMilli() @@ -61,7 +70,7 @@ object MetricsService { val restingHr = hrValues.filter { it <= 72.0 }.minOrNull() return TodaySummary( - steps = todayActivity?.steps, + steps = com.pulseloop.data.DisplaySteps.count(todayActivity, preferPhone), calories = todayActivity?.calories, distanceMeters = todayActivity?.distanceMeters, activeMinutes = todayActivity?.activeMinutes, @@ -73,7 +82,7 @@ object MetricsService { // The seeder writes source = "demo"; comparing against "mock" (the iOS spelling) // alone meant this never fired, so seeded steps/HR were reported as live data. isDemo = DemoDataPolicy.isDemo(todayActivity?.source), - stepsTrend = recent7.map { it.steps.toDouble() }, + stepsTrend = com.pulseloop.data.DisplaySteps.counts(recent7, preferPhone).map { it.toDouble() }, hrTrend24h = hrValues, spo2Trend24h = spoValues, ) diff --git a/app/src/main/java/com/pulseloop/settings/ApiKeyStore.kt b/app/src/main/java/com/pulseloop/settings/ApiKeyStore.kt index a679e4ba..930be423 100644 --- a/app/src/main/java/com/pulseloop/settings/ApiKeyStore.kt +++ b/app/src/main/java/com/pulseloop/settings/ApiKeyStore.kt @@ -9,6 +9,9 @@ import com.pulseloop.service.GlucoseUnit * Ported from OpenAIKeychainStore in the iOS app. * Stores the OpenAI API key securely using EncryptedSharedPreferences * (Android equivalent of iOS Keychain). + * + * Non-sensitive UI preferences like `stepSource` live in [StepSourcePrefs] instead — the + * encryption layer is expensive enough to avoid using it for values that don't need it. */ class ApiKeyStore(context: Context) { private val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) diff --git a/app/src/main/java/com/pulseloop/settings/StepSourcePrefs.kt b/app/src/main/java/com/pulseloop/settings/StepSourcePrefs.kt new file mode 100644 index 00000000..962c5008 --- /dev/null +++ b/app/src/main/java/com/pulseloop/settings/StepSourcePrefs.kt @@ -0,0 +1,107 @@ +package com.pulseloop.settings + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +/** + * The user's step-source preference ("ring" or "phone"). + * + * Deliberately uses plain (unencrypted) [android.content.SharedPreferences] rather than + * [ApiKeyStore]'s EncryptedSharedPreferences: this is a non-sensitive UI preference, and the + * encryption layer is expensive enough that callers reading it on every ring activity event + * previously needed an in-memory cache. Plain prefs are cheap to read, so the cache is gone. + * + * Also holds the phone-step backfill throttle: [lastBackfillDay] is the local-midnight epoch + * millis of the last successful `PhoneStepManager.refreshHistoricalDays()` run, so the app + * backfills once per day instead of on every foreground (see + * [com.pulseloop.MainActivity.refreshPhoneSteps]). + */ +class StepSourcePrefs(context: Context) { + private val prefs = context.applicationContext + .getSharedPreferences("pulseloop_prefs", Context.MODE_PRIVATE) + + var stepSource: String + get() = prefs.getString(KEY_STEP_SOURCE, SOURCE_RING) ?: SOURCE_RING + set(value) { prefs.edit().putString(KEY_STEP_SOURCE, value).apply() } + + /** + * True when the user has chosen the phone as the step source. The read-time column selection + * (Path B, PR #98) branches on this: [ActivityDailyEntity.displaySteps] returns + * [ActivityDailyEntity.phoneSteps] when this is true and a phone value exists for the day, + * and [ActivityDailyEntity.steps] otherwise. + */ + fun preferPhone(): Boolean = stepSource == SOURCE_PHONE + + /** + * [preferPhone] as a hot-observer Flow — emits the current value immediately, then again + * whenever [stepSource] is written (via `OnSharedPreferenceChangeListener`). + * + * Review item 7, PR #98. `preferPhone()` is cheap to read at each DB emission, so the + * ViewModels used to consult it inside their `.collect { }` blocks. That meant a toggle + * with no accompanying DB write left the screen stale: no flow emits, so the display + * keeps the old column until some unrelated row changes. Combining this with the DB + * flow in each ViewModel (`combine(dbFlow, preferPhoneFlow())`) makes the display + * follow the toggle, not just the data. + * + * Only fires for [KEY_STEP_SOURCE] writes — `lastBackfillDay` and + * `hasEverSeenPhoneSteps` churn is not relevant to what the user sees and must not + * re-project the display. [distinctUntilChanged] also swallows a no-op write of the + * same value (SharedPreferences fires the listener even on an unchanged `putString`). + */ + fun preferPhoneFlow(): Flow = callbackFlow { + trySend(preferPhone()) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == KEY_STEP_SOURCE) trySend(stepSource == SOURCE_PHONE) + } + prefs.registerOnSharedPreferenceChangeListener(listener) + awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } + }.distinctUntilChanged() + + /** + * Local-midnight epoch millis of the last day on which the phone step reader ran a + * successful 30-day backfill. 0 means "never run". Read/written only from + * [com.pulseloop.MainActivity.refreshPhoneSteps] — the Settings toggle intentionally + * does not consult it, because an explicit user action should always run. + */ + var lastBackfillDay: Long + get() = prefs.getLong(KEY_LAST_BACKFILL_DAY, 0L) + set(value) { prefs.edit().putLong(KEY_LAST_BACKFILL_DAY, value).apply() } + + /** + * True once any Health Connect source has been observed writing steps for this device — + * either a foreign origin seen in a today-read ([com.pulseloop.PhoneStepManager.refresh]) + * or one seen in a backfill window ([com.pulseloop.PhoneStepManager.refreshHistoricalDays]). + * + * Review item 3, PR #98. Without this flag the phone reader cannot tell "today is 0 + * because it's 3 a.m. and the phone has counted nothing yet" (a real state — publish 0 so + * phone mode shows 0) from "today is 0 because no app on this phone writes steps to Health + * Connect at all" (not a real state — publishing 0 would paint a false zero over a real + * ring day). With it: `refresh()` publishes 0 only when [hasEverSeenPhoneSteps] is true. + * When false, the day's `phoneSteps` stays null and the display falls back to the ring. + * + * Never reset. Whether a source has ever existed for this install is installation-level + * history, not current-preference state — a user who toggles to Ring and back still expects + * Phone mode to show real numbers on a phone that has been tracking steps all along. + */ + var hasEverSeenPhoneSteps: Boolean + get() = prefs.getBoolean(KEY_HAS_EVER_SEEN_PHONE_STEPS, false) + set(value) { prefs.edit().putBoolean(KEY_HAS_EVER_SEEN_PHONE_STEPS, value).apply() } + + companion object { + /** + * The two valid values of [stepSource]. Every call site that reads or writes the + * preference — or that compares an `activity_daily.source` value it wrote — refers + * to one of these rather than a bare `"ring"` / `"phone"` literal. + */ + const val SOURCE_RING = "ring" + const val SOURCE_PHONE = "phone" + + private const val KEY_STEP_SOURCE = "step_source" + private const val KEY_LAST_BACKFILL_DAY = "last_backfill_day" + private const val KEY_HAS_EVER_SEEN_PHONE_STEPS = "has_ever_seen_phone_steps" + } +} diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index acddf1c8..43417021 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -120,23 +120,32 @@ fun PulseLoopApp() { flags = flags, coordinator = coordinator, foodClient = foodClient, + // Review item 5, part 2 of 3 — resolved per turn so a toggle made + // mid-session is reflected on the next tool call. + preferPhone = services.stepSourcePrefs.preferPhone(), ) }, ) } // ── ViewModels ─────────────────────────────────────────────────── - val todayVM = remember { TodayViewModel(db, apiKeyStore) } + val stepSourcePrefs = remember { com.pulseloop.settings.StepSourcePrefs(context) } + val todayVM = remember { TodayViewModel(db, apiKeyStore, stepSourcePrefs) } val vitalsVM = remember { VitalsViewModel(db, apiKeyStore, com.pulseloop.settings.ScheduledSpo2Prefs(context)) } val sleepVM = remember { val appContext = context.applicationContext - SleepViewModel(db, gapBridge = { com.pulseloop.service.SleepGapBridge.fromSettings(appContext) }) + SleepViewModel( + db, + gapBridge = { com.pulseloop.service.SleepGapBridge.fromSettings(appContext) }, + stepSourcePrefs = stepSourcePrefs, + ) } - val activityVM = remember { ActivityViewModel(db) } + val activityVM = remember { ActivityViewModel(db, stepSourcePrefs) } val weatherContextService = remember { com.pulseloop.coach.context.WeatherContextService(context) } val coachVM = remember { CoachViewModel( db, coachOrchestrator, + preferPhone = { stepSourcePrefs.preferPhone() }, attachmentPayloads = { refs -> com.pulseloop.coach.attachments.CoachAttachmentStore.payloads(context, refs) }, @@ -191,8 +200,9 @@ fun PulseLoopApp() { } } - // Start services - persistence.start() + // Start services. Persistence is already started from PulseLoopApplication.onCreate + // (review item 1, PR #98): it must be subscribed before MainActivity.onResume's + // refreshPhoneSteps publishes. batteryAlerts.start() coordinator.start() summaryCoordinator.start() diff --git a/app/src/main/java/com/pulseloop/ui/screens/ActivityScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/ActivityScreen.kt index f65b6b2e..319a7894 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/ActivityScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/ActivityScreen.kt @@ -44,6 +44,7 @@ import com.pulseloop.data.entity.ActivitySessionEntity import com.pulseloop.settings.ApiKeyStore import com.pulseloop.settings.UnitConverter import com.pulseloop.settings.UnitSystem +import com.pulseloop.settings.StepSourcePrefs import com.pulseloop.ui.components.ActivityMeta import com.pulseloop.ui.components.ActivityRing import com.pulseloop.ui.components.ActivityRings @@ -79,6 +80,10 @@ fun ActivityScreen( // remember{}: the ApiKeyStore constructor does Keystore + encrypted-prefs I/O — far // too expensive to repeat on every recomposition of this state-collecting screen. val units = remember { ApiKeyStore(context) }.resolvedUnitSystem + // Read the step-source preference once per composition. Cheap enough to read + // unconditionally (plain SharedPreferences, no crypto), and re-read on every entry to + // the screen so a toggle made in Settings is reflected on return. + val stepSourcePrefs = remember { StepSourcePrefs(context) } val scope = rememberCoroutineScope() var pickerOpen by remember { mutableStateOf(false) } var historyOpen by remember { mutableStateOf(false) } @@ -230,13 +235,30 @@ fun ActivityScreen( // The blocks behind today's total, each removable (issue #70). Under the summary rather // than beside it: it answers "why is that number wrong", which is a question you only ask // after reading the number. - item { - ActivityRecordsCard( - viewModel = viewModel, - units = units, - dayStart = shownDayStart(state), - dayLabel = shownDayLabel(state), - ) + // + // Hidden only when the phone is *actually supplying the shown day's steps* (review + // item 9, PR #98). The old guard keyed on the user's preference alone, which hid the + // card even on days whose `phoneSteps` is null — those days display the ring's total, + // so the ring's own buckets are the right records for the number above them, and a + // bad bucket in that total needs to be deletable. `phoneOwnsShownDay` asks the two + // questions that actually determine it: the preference is Phone, AND the phone wrote + // a value for this day. `state.daySummary` is already projected by the ViewModel, so + // its `phoneSteps` field is the raw column — untouched by projection. + // + // On a phone-owned day the records legitimately do not sum to the header (ring + // buckets vs. phone total), which reads as a bug rather than a detail view — hide it + // for that case. Toggling back to Ring, or landing on a day the phone never + // backfilled, restores the card. + val phoneOwnsShownDay = stepSourcePrefs.preferPhone() && state.daySummary?.phoneSteps != null + if (!phoneOwnsShownDay) { + item { + ActivityRecordsCard( + viewModel = viewModel, + units = units, + dayStart = shownDayStart(state), + dayLabel = shownDayLabel(state), + ) + } } item { Spacer(Modifier.height(64.dp)) } } @@ -363,6 +385,17 @@ private fun DailyActivitySummaryCard(state: ActivityViewModel.ActivityState, uni // WeeklyGoalCard keeps reading state.today — a week widget is about the live week. val today = state.daySummary val distValue = today?.distanceMeters?.let { Formats.distance(UnitConverter.distance(it, units)) } + // Read the effective calories from the ViewModel rather than the row's raw `calories` + // column: the estimator's figure is what the Today card shows, and reading the column + // here would surface a phone row's stale hardcoded placeholder (or a ring's pre-toggle + // value) as if it were a real total. Same read path as TodayViewModel's, so the two + // screens cannot disagree. + // + // `calories` is the day's *total* (basal + active) — what the Calories metric shows, same + // as Today. The goal ring reads the active-only figure instead: UserGoal.calories is an + // active-energy goal, so a ring drawn against the total would sit near full from midnight. + val calories = state.effectiveCalories + val activeCalories = state.effectiveActiveCalories Row( Modifier .fillMaxWidth() @@ -379,14 +412,13 @@ private fun DailyActivitySummaryCard(state: ActivityViewModel.ActivityState, uni SummaryMetric("Distance", distValue ?: "—", if (distValue != null) UnitConverter.distanceUnit(units) else null, PulseColors.distance, Modifier.weight(1f)) } // The same figure the Today screen shows, not the row's raw `calories` (issue #101). - val calories = state.dayCalories SummaryMetric("Calories", calories?.let { Formats.count(it.toInt()) } ?: "—", if (calories != null) "cal" else null, PulseColors.calories) } ActivityRings( rings = listOf( ActivityRing(today?.steps?.toDouble(), state.stepGoal.toDouble(), PulseColors.steps), ActivityRing(today?.distanceMeters, state.distanceGoalMeters, PulseColors.distance), - ActivityRing(state.dayActiveCalories, state.caloriesGoal.toDouble(), PulseColors.calories), + ActivityRing(activeCalories, state.caloriesGoal.toDouble(), PulseColors.calories), ), size = 112.dp, strokeWidth = 11.dp, @@ -869,8 +901,8 @@ private fun ActivityRecordsCard( text = { Text( "${Formats.count(bucket.steps)} steps at ${timeOf(bucket.startEpoch)}." + - "\n\nThe day's total drops by that much. This can't be undone, and the record " + - "stays deleted the next time this day syncs.", + "\n\nThe day's total drops by that much. This can't be undone, and the record " + + "stays deleted the next time this day syncs.", ) }, confirmButton = { diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt index 9c1141ac..419e8e71 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt @@ -1,5 +1,6 @@ package com.pulseloop.ui.screens +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -11,15 +12,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import android.widget.Toast import com.pulseloop.coach.config.CoachProviderMode import com.pulseloop.coach.config.CoachProviderSettingsStore +import com.pulseloop.health.HealthConnectAvailability +import com.pulseloop.health.HealthConnectSdk import com.pulseloop.data.PulseLoopDatabase import com.pulseloop.ring.WearableCapability import com.pulseloop.settings.ApiKeyStore +import com.pulseloop.settings.StepSourcePrefs import com.pulseloop.ui.components.DeviceHeroCard import com.pulseloop.ui.components.SettingsRowItem import com.pulseloop.ui.components.SettingsSection import com.pulseloop.ui.theme.PulseColors +import com.pulseloop.util.TimeUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Ported from SettingsView.swift (iOS #49 rehaul). @@ -35,8 +45,10 @@ fun SettingsScreen( ) { val context = LocalContext.current val keyStore = remember { ApiKeyStore(context) } + val stepSourcePrefs = remember { StepSourcePrefs(context) } val providerStore = remember { CoachProviderSettingsStore(context) } val db = remember { PulseLoopDatabase.getInstance(context) } + val scope = rememberCoroutineScope() val bleState = bleClient?.state?.collectAsState()?.value ?: com.pulseloop.ring.RingBLEClient.BLEState() @@ -51,6 +63,12 @@ fun SettingsScreen( // recomposes this hub, so toggles made there are reflected immediately. val coachEnabled = keyStore.coachEnabled val developerUnlocked = keyStore.developerUnlocked + var stepSource by remember { mutableStateOf(stepSourcePrefs.stepSource) } + + // In-flight "switch to Phone" attempt. Cancelled before each new attempt so a + // rapid Ring → Phone → Ring toggle cannot have an older attempt's async HC + // checks land after the user's latest intent and re-persist "phone". + var switchJob by remember { mutableStateOf(null) } // Provider-aware AI Coach summary — mirrors iOS `coachTrailing` (no Apple on-device // mode on Android; hosted providers show the selected model slug). @@ -67,6 +85,44 @@ fun SettingsScreen( } val notificationsTrailing = if (keyStore.notificationsEnabled) "On" else "Off" + // The Health Connect READ_STEPS permission is requested only from the Step Source + // row below, on the user's explicit action. MainActivity deliberately does not + // prompt for it on launch or on foreground return — this is the single entry + // point, so a user who declines is not asked again until they touch the toggle + // once more. If the dialog is declined, the toggle reverts to "Ring" so the UI + // never claims a source the app cannot actually read from. + // + // The explicit backfill here marks `lastBackfillDay` before launching: returning from + // the permission sheet fires MainActivity.onResume, whose automatic refreshPhoneSteps() + // would otherwise see the throttle stale and run the same 30-day backfill a second time. + // The toggle is not throttled — an explicit user action always runs — but it *does* + // update the throttle so the automatic path doesn't immediately repeat the work. + val healthConnectPermissionLauncher = rememberLauncherForActivityResult( + androidx.health.connect.client.PermissionController.createRequestPermissionResultContract() + ) { granted -> + if (com.pulseloop.PhoneStepManager.READ_STEPS_PERMISSION in granted) { + // Granted — now it is safe to persist "phone" and run the backfill. A6: the + // preference is written only after permission is confirmed, so an interrupted + // flow cannot leave the app claiming a source it can't read from. + stepSourcePrefs.stepSource = StepSourcePrefs.SOURCE_PHONE + stepSourcePrefs.lastBackfillDay = TimeUtil.startOfTodayLocal() + scope.launch(Dispatchers.IO) { + try { + val manager = com.pulseloop.PhoneStepManager(context) + manager.refresh() + manager.refreshHistoricalDays() + } catch (_: Exception) { + // A failed backfill just leaves the ring's data in place for + // historical days. Nothing user-visible to report. + } + } + } else { + // Declined. Do NOT persist "phone". Revert the UI toggle so it doesn't claim + // a source the app cannot read from. + stepSource = StepSourcePrefs.SOURCE_RING + } + } + fun navigate(route: String) { navController?.navigate(route) } @@ -141,6 +197,92 @@ fun SettingsScreen( ), ) + // STEP SOURCE — switching to "Phone" backfills the last 30 days of step totals + // from Health Connect, so past days show the phone's numbers rather than the + // ring's. The permission for Health Connect READ_STEPS is requested here, on the + // user's explicit action, and only when it isn't already granted. The backfill + // itself is fire-and-forget: the persistence layer receives PhoneStepsUpdate + // events and writes each day's row; the Activity screen's Flows pick up the new + // rows automatically. + SettingsSection( + title = "Step Source", + rows = listOf( + SettingsRowItem( + icon = Icons.Filled.Timeline, + tint = PulseColors.accent, + title = "Data Source", + trailingValue = if (stepSource == StepSourcePrefs.SOURCE_PHONE) "Phone" else "Ring" + ) { + val newSource = if (stepSource == StepSourcePrefs.SOURCE_RING) StepSourcePrefs.SOURCE_PHONE else StepSourcePrefs.SOURCE_RING + stepSource = newSource // optimistic UI; persisted only once proven valid + // Cancel any previous in-flight attempt: the user's latest tap is the + // only intent that matters, and a stale attempt must not persist + // "phone" after the user has since flipped back to Ring. + switchJob?.cancel() + if (newSource == StepSourcePrefs.SOURCE_RING) { + // Ring is always safe — no external dependency to verify. + stepSourcePrefs.stepSource = StepSourcePrefs.SOURCE_RING + } else { + // Switching to Phone. A6: verify HC availability and permission + // BEFORE persisting. The old flow saved "phone" first and reverted + // on failure, so an interrupted run left the app claiming a source + // it couldn't read from, which then dropped the ring's live totals. + switchJob = scope.launch { + val availability = withContext(Dispatchers.IO) { + HealthConnectSdk.availability(context) + } + if (availability != HealthConnectAvailability.AVAILABLE) { + Toast.makeText( + context, + when (availability) { + HealthConnectAvailability.PROVIDER_UPDATE_REQUIRED -> + "Health Connect needs an update before phone steps can be used." + else -> + "Health Connect isn't available on this device." + }, + Toast.LENGTH_LONG, + ).show() + stepSource = StepSourcePrefs.SOURCE_RING // revert the optimistic flip + return@launch + } + val hasPermission = withContext(Dispatchers.IO) { + com.pulseloop.PhoneStepManager.hasStepsPermission(context) + } + if (hasPermission) { + // Re-check intent immediately before persisting: the user + // may have flipped back to Ring while the permission query + // was in flight, and cancellation is cooperative — this + // coroutine can reach here with the cancel flag set. + if (stepSource != StepSourcePrefs.SOURCE_PHONE) return@launch + // Already granted — persist and backfill now. Same throttle + // update as the permission-sheet callback: without it, the + // next onResume's automatic refreshPhoneSteps() would see the + // throttle stale and run this same 30-day backfill again. + stepSourcePrefs.stepSource = StepSourcePrefs.SOURCE_PHONE + stepSourcePrefs.lastBackfillDay = TimeUtil.startOfTodayLocal() + withContext(Dispatchers.IO) { + try { + val manager = com.pulseloop.PhoneStepManager(context) + manager.refresh() + manager.refreshHistoricalDays() + } catch (_: Exception) { + // A failed backfill just leaves the ring's data + // in place for historical days. + } + } + } else { + // Not granted — ask. The launcher's callback persists + // "phone" only once permission is confirmed. + healthConnectPermissionLauncher.launch( + setOf(com.pulseloop.PhoneStepManager.READ_STEPS_PERMISSION) + ) + } + } + } + } + ), + ) + // RESOURCES — Calibration is jring-only (Colmi rings measure neither BP nor blood // sugar); Developer is hidden until unlocked by tapping the version 7× in About. SettingsSection( diff --git a/app/src/main/java/com/pulseloop/ui/screens/TodayScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/TodayScreen.kt index 7ea4cf21..5e7fc189 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/TodayScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/TodayScreen.kt @@ -15,10 +15,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.pulseloop.PhoneStepManager import com.pulseloop.service.MetricKind import com.pulseloop.service.MetricZone import com.pulseloop.service.VitalsThresholdEngine import com.pulseloop.settings.ApiKeyStore +import com.pulseloop.settings.StepSourcePrefs import com.pulseloop.settings.UnitConverter import com.pulseloop.ui.components.* import com.pulseloop.ui.dashboard.CustomizeCardsButton @@ -66,6 +68,7 @@ fun TodayScreen( // remember{}: the ApiKeyStore constructor does Keystore + encrypted-prefs I/O — far // too expensive to repeat on every recomposition of this state-collecting screen. val keyStore = remember { ApiKeyStore(context) } + val stepSourcePrefs = remember { StepSourcePrefs(context) } val units = keyStore.resolvedUnitSystem val coachEnabled = keyStore.coachEnabled val pullRefreshState = rememberPullRefreshState( @@ -74,6 +77,12 @@ fun TodayScreen( isRefreshing = true scope.launch { coordinator?.pullToRefresh() + // Also refresh phone steps from Health Connect — otherwise pulling to + // refresh syncs the ring but leaves the activity tile showing a stale + // step count. No-op unless the user's step source is set to "Phone". + if (stepSourcePrefs.stepSource == StepSourcePrefs.SOURCE_PHONE) { + PhoneStepManager(context).refresh() + } kotlinx.coroutines.delay(1500) isRefreshing = false } @@ -112,8 +121,8 @@ fun TodayScreen( val visibleKeys = allSupported.filter { !prefs.isHidden(it, MetricScope.TODAY) }.map { it.key }.toSet() val visibleOrdered: List = run { val base = if (editState.editing) editState.liveOrder - else prefsStore.resolvedOrder(visibleKeys, DashboardCard.todayDefault.map { it.key }, MetricScope.TODAY) - .mapNotNull { DashboardCard.fromKey(it) } + else prefsStore.resolvedOrder(visibleKeys, DashboardCard.todayDefault.map { it.key }, MetricScope.TODAY) + .mapNotNull { DashboardCard.fromKey(it) } base.filter { it.key in visibleKeys } } val hiddenCards = allSupported.filter { prefs.isHidden(it, MetricScope.TODAY) } @@ -398,7 +407,7 @@ private fun deriveHero(state: TodayViewModel.TodayState, sleep: SleepViewModel.S val series = state.steps7d.map { it.toDouble() } val prior = series.dropLast(1) val base = if (prior.isNotEmpty() && prior.average() > 0) prior.average() - else if (series.isNotEmpty()) series.average() else 0.0 + else if (series.isNotEmpty()) series.average() else 0.0 val stepsDelta = if (base == 0.0) 0 else (((steps - base) / base) * 100).toInt() val title = when { diff --git a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt index a2b215b7..e775bdf0 100644 --- a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt @@ -23,6 +23,7 @@ import com.pulseloop.service.SleepScoreResult import com.pulseloop.service.UserPhysiologyProfile import com.pulseloop.service.VitalSample import com.pulseloop.settings.ApiKeyStore +import com.pulseloop.settings.StepSourcePrefs import com.pulseloop.settings.UnitConverter import com.pulseloop.settings.UnitSystem import com.pulseloop.ui.components.MetricThresholds @@ -39,7 +40,11 @@ import java.time.ZoneId * Ported from MetricsService.buildTodaySummary in PulseServices.swift. * Uses reactive Flow queries so live ring data appears immediately. */ -class TodayViewModel(db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore? = null) : ViewModel() { +class TodayViewModel( + db: PulseLoopDatabase, + private val apiKeyStore: ApiKeyStore? = null, + private val stepSourcePrefs: StepSourcePrefs, +) : ViewModel() { // Local midnight, not UTC — the Today dashboard rolls over at the device's local // midnight so daily stats line up with how the rest of the app keys per-day rows. private val todayStart = MutableStateFlow(TimeUtil.startOfTodayLocal()) @@ -85,7 +90,10 @@ class TodayViewModel(db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore init { viewModelScope.launch { - currentDayValues(todayStart, db.activityDailyDao()::byDayFlow).collect { activity -> + combine( + currentDayValues(todayStart, db.activityDailyDao()::byDayFlow), + stepSourcePrefs.preferPhoneFlow(), + ) { activity, preferPhone -> activity to preferPhone }.collect { (activity, preferPhone) -> // iOS #98: the ring's own calorie figure wins when it reported one; otherwise show // the on-device estimate (BMR accrued so far + net active). Reading `calories` // straight off the row would show 0 all day for rings that report none — which is @@ -98,7 +106,7 @@ class TodayViewModel(db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore ?: DailyCalorieEstimator.deviceReportedCalories(row) } _state.update { it.copy( - steps = activity?.steps, + steps = com.pulseloop.data.DisplaySteps.count(activity, preferPhone), calories = calories, distanceMeters = activity?.distanceMeters, activeMinutes = activity?.activeMinutes, @@ -120,8 +128,11 @@ class TodayViewModel(db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore } // 7-day step series for the hero delta (oldest→newest; DAO returns newest-first). viewModelScope.launch { - db.activityDailyDao().recentFlow(7).collect { days -> - _state.update { it.copy(steps7d = days.reversed().map { d -> d.steps }) } + combine( + db.activityDailyDao().recentFlow(7), + stepSourcePrefs.preferPhoneFlow(), + ) { days, preferPhone -> days to preferPhone }.collect { (days, preferPhone) -> + _state.update { it.copy(steps7d = com.pulseloop.data.DisplaySteps.counts(days.reversed(), preferPhone)) } } } // Today's coach summary card (kind="today", scopeKey=local date). @@ -208,6 +219,7 @@ class SleepViewModel( private val db: PulseLoopDatabase, /** The night-bridging rule a record delete/edit re-segments with — the sync's own (issue #105). */ private val gapBridge: () -> com.pulseloop.service.SleepGapBridge = { com.pulseloop.service.SleepGapBridge.NONE }, + private val stepSourcePrefs: StepSourcePrefs, ) : ViewModel() { data class SleepState( val range: SleepRangeKey = SleepRangeKey.DAY, @@ -258,7 +270,13 @@ class SleepViewModel( init { // Any sleep-table change retriggers a rebuild of the selected range. viewModelScope.launch { - db.sleepSessionDao().recentFlow(7).collect { sessions -> + // Combined with the step-source preference (review item 7, PR #98) so the sleep + // coach's step figure follows the toggle without waiting for a sleep-table write. + // rebuild() reads preferPhone() fresh each call, so the emit is the entire fix. + combine( + db.sleepSessionDao().recentFlow(7), + stepSourcePrefs.preferPhoneFlow(), + ) { sessions, _ -> sessions }.collect { sessions -> _state.update { it.copy(recentSessions = sessions) } try { rebuild(_state.value.range) } catch (_: Exception) {} } @@ -271,7 +289,12 @@ class SleepViewModel( blocksCache.getOrPut(id) { db.sleepStageBlockDao().forSession(id) } // aggregateCoach/averageScore take a synchronous lookup; pre-warm the cache first. val goal = try { db.userGoalDao().get()?.sleepMinutes } catch (_: Exception) { null } - val steps = try { db.activityDailyDao().byDay(TimeUtil.startOfTodayLocal())?.steps } catch (_: Exception) { null } + val steps = try { + com.pulseloop.data.DisplaySteps.count( + db.activityDailyDao().byDay(TimeUtil.startOfTodayLocal()), + stepSourcePrefs.preferPhone(), + ) + } catch (_: Exception) { null } // ── Today tile (never moves with Day-view navigation) ── val todayReference = TimeUtil.referenceNightLocal() @@ -434,7 +457,10 @@ class SleepViewModel( * ActivityViewModel — reads Room data for the Activity screen. */ @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class ActivityViewModel(db: PulseLoopDatabase) : ViewModel() { +class ActivityViewModel( + db: PulseLoopDatabase, + private val stepSourcePrefs: StepSourcePrefs, +) : ViewModel() { data class ActivityState( val recentDays: List = emptyList(), /** All finished sessions, newest first (drives Today + the history sheet). */ @@ -450,13 +476,28 @@ class ActivityViewModel(db: PulseLoopDatabase) : ViewModel() { val shownDay: Long = 0, /** The shown day's totals (the summary card); `today` stays live for the week widget. */ val daySummary: ActivityDailyEntity? = null, - /** The shown day's calories as the Today screen shows them (issue #101): the ring's own - * figure when it reported one, else BMR accrued plus the active estimate. The row's raw - * `calories` stays 0 for rings that report none, which is why this exists. */ - val dayCalories: Double? = null, - /** The active-energy part of [dayCalories] — what the calorie goal ring measures, since the - * calorie goal is an active-energy goal ([DailyCalorieEstimator.effectiveActiveCalories]). */ - val dayActiveCalories: Double? = null, + /** + * Effective calories for the shown day: the ring's own figure when it reported one, + * else [DailyCalorieEstimator.effectiveCalories] (BMR accrued through the day so far + * plus the net active estimate). Mirrors [TodayViewModel]'s read path so the Today + * card and the Activity card cannot disagree about what a day's calories are — a + * `phone`-sourced row would otherwise show its raw `calories` column, which is either + * the estimator's own fill-in or the stale placeholder written before that path + * existed. + * + * This is the *total* figure (basal + active), which is what the Calories metric text + * shows. The calorie goal ring reads [effectiveActiveCalories] instead — the stored + * goal is an active-energy goal ([UserGoalEntity.calories] KDoc), so a ring drawn + * against this total would sit near-full from the moment the day begins. + */ + val effectiveCalories: Double? = null, + /** + * The active-energy portion of the shown day's calories — the ring's own reported + * figure when it has one, else [DailyCalorieEstimator.effectiveActiveCalories]. + * Drives the calorie goal ring, whose goal ([UserGoalEntity.calories]) is in the same + * active-energy units. Null when the row has neither a device figure nor an estimate. + */ + val effectiveActiveCalories: Double? = null, val stepGoal: Int = UserGoalEntity.DEFAULT_STEPS, val activeMinutesGoal: Int = 45, val distanceGoalMeters: Double = UserGoalEntity.DEFAULT_DISTANCE_METERS, @@ -484,17 +525,33 @@ class ActivityViewModel(db: PulseLoopDatabase) : ViewModel() { init { viewModelScope.launch { - db.activityDailyDao().recentFlow(7).collect { days -> - _state.update { it.copy(recentDays = days) } + combine( + db.activityDailyDao().recentFlow(7), + stepSourcePrefs.preferPhoneFlow(), + ) { days, preferPhone -> days to preferPhone }.collect { (days, preferPhone) -> + _state.update { it.copy(recentDays = com.pulseloop.data.DisplaySteps.project(days, preferPhone)) } } } viewModelScope.launch { - currentDayValues(todayStart, db.activityDailyDao()::byDayFlow).collect { day -> - _state.update { it.copy(today = day) } + combine( + currentDayValues(todayStart, db.activityDailyDao()::byDayFlow), + stepSourcePrefs.preferPhoneFlow(), + ) { day, preferPhone -> day to preferPhone }.collect { (day, preferPhone) -> + _state.update { it.copy(today = com.pulseloop.data.DisplaySteps.project(day, preferPhone)) } } } viewModelScope.launch { - shownDayStart.flatMapLatest { day -> db.activityDailyDao().byDayFlow(day) }.collect { day -> + combine( + shownDayStart.flatMapLatest { day -> db.activityDailyDao().byDayFlow(day) }, + stepSourcePrefs.preferPhoneFlow(), + ) { day, preferPhone -> day to preferPhone }.collect { (day, preferPhone) -> + // Effective calories for the shown day — same read path as the Today card + // (see TodayViewModel's init), so both screens agree. Reading `day.calories` + // straight off the row would surface the ring's value for ring modes, but + // for phone-sourced rows it would show the estimator's own fill-in — or, on + // older rows, the hardcoded `steps * 0.04` placeholder — as if it were a + // device-reported figure. [DailyCalorieEstimator.effectiveCalories] handles + // both cases: device figure when there is one, else BMR + active estimate. val profile = db.userProfileDao().get()?.let { DailyCalorieEstimator.Profile(it.sex, it.age, it.weightKg, it.heightCm) } @@ -502,8 +559,17 @@ class ActivityViewModel(db: PulseLoopDatabase) : ViewModel() { profile?.let { DailyCalorieEstimator.effectiveCalories(row, it) } ?: DailyCalorieEstimator.deviceReportedCalories(row) } + // The active-energy read: no profile needed — this is either the device's own + // figure or the stored estimate, both already net of BMR. See the state field's + // KDoc for why the ring uses this and the metric text does not. val activeCalories = day?.let { DailyCalorieEstimator.effectiveActiveCalories(it) } - _state.update { it.copy(daySummary = day, dayCalories = calories, dayActiveCalories = activeCalories) } + _state.update { + it.copy( + daySummary = com.pulseloop.data.DisplaySteps.project(day, preferPhone), + effectiveCalories = calories, + effectiveActiveCalories = activeCalories, + ) + } } } viewModelScope.launch { @@ -901,6 +967,12 @@ fun VitalsViewModel.VitalsState.toCardInputs(units: UnitSystem): VitalsCardFacto class CoachViewModel( private val db: PulseLoopDatabase, private val orchestrator: com.pulseloop.coach.orchestration.CoachOrchestrator, + /** + * Resolves the current step-source preference (review item 5, PR #98). Injected as a lambda + * rather than a `StepSourcePrefs` so the VM stays context-free like [attachmentPayloads]; + * the lambda is called per turn so a toggle made mid-session is reflected on the next turn. + */ + private val preferPhone: () -> Boolean = { false }, /** Resolves staged attachment refs to wire payloads (injected so the VM stays context-free). */ private val attachmentPayloads: (List) -> List = { emptyList() }, /** Opt-in city + weather (iOS #65d), injected so the VM stays context-free like [attachmentPayloads]. */ @@ -1070,7 +1142,11 @@ class CoachViewModel( attachmentsJson = com.pulseloop.coach.attachments.CoachAttachmentRef.encode(attachments), )) try { - val packet = com.pulseloop.coach.context.CoachContextBuilder.build(db, environment = environmentSnapshot()) + val packet = com.pulseloop.coach.context.CoachContextBuilder.build( + db, + preferPhone = preferPhone(), + environment = environmentSnapshot(), + ) // Drop error bubbles: they're app-generated diagnostics (a provider HTTP error), // not real assistant turns. Replaying them as assistant history would feed the // model garbage like "Coach error · HTTP 404 …" as if it had said it. diff --git a/app/src/main/java/com/pulseloop/widgets/WidgetSnapshotPublisher.kt b/app/src/main/java/com/pulseloop/widgets/WidgetSnapshotPublisher.kt index 1f6c9c70..67422ceb 100644 --- a/app/src/main/java/com/pulseloop/widgets/WidgetSnapshotPublisher.kt +++ b/app/src/main/java/com/pulseloop/widgets/WidgetSnapshotPublisher.kt @@ -105,7 +105,8 @@ object WidgetSnapshotPublisher { try { val db = PulseLoopDatabase.getInstance(context) val keyStore = ApiKeyStore(context) - val snapshot = buildSnapshot(db, keyStore) + val preferPhone = com.pulseloop.settings.StepSourcePrefs(context).preferPhone() + val snapshot = buildSnapshot(db, keyStore, preferPhone) // Content comparison with a fixed timestamp (iOS `.distantPast` trick). val hash = WidgetSnapshotCodec.encode(snapshot.copy(generatedAt = 0)).hashCode() @@ -152,7 +153,11 @@ object WidgetSnapshotPublisher { * activity tile's unit conversion/formatting — so a widget tile is pixel- and label-identical * to its Today tile at publish time. */ - suspend fun buildSnapshot(db: PulseLoopDatabase, keyStore: ApiKeyStore?): WidgetSnapshot { + suspend fun buildSnapshot( + db: PulseLoopDatabase, + keyStore: ApiKeyStore?, + preferPhone: Boolean = false, + ): WidgetSnapshot { val nowMs = System.currentTimeMillis() val units = keyStore?.resolvedUnitSystem ?: UnitSystem.METRIC val vitals = VitalsViewModel.buildState(db, keyStore) @@ -180,18 +185,24 @@ object WidgetSnapshotPublisher { return WidgetSnapshot( generatedAt = nowMs / 1000L, dayStart = TimeUtil.startOfTodayLocal() / 1000L, - activity = activityPayload(db, units), + activity = activityPayload(db, units, preferPhone), sleep = sleepPayload(db), metrics = metrics, ) } /** Replicates the Today activity tile's value derivations (unit conversion, formatting). */ - private suspend fun activityPayload(db: PulseLoopDatabase, units: UnitSystem): WidgetActivityPayload { + private suspend fun activityPayload( + db: PulseLoopDatabase, + units: UnitSystem, + preferPhone: Boolean, + ): WidgetActivityPayload { val today = try { db.activityDailyDao().byDay(TimeUtil.startOfTodayLocal()) } catch (_: Exception) { null } val goal = try { db.userGoalDao().get() } catch (_: Exception) { null } - val steps = today?.steps + // Read-time column pick (Path B, PR #98): the ring's `steps` unless the user has chosen + // the phone AND the phone has a value for the day. Matches the app's Today tile. + val steps = com.pulseloop.data.DisplaySteps.count(today, preferPhone) val calories = today?.calories val distanceDisplay = today?.distanceMeters?.let { UnitConverter.distance(it, units) } diff --git a/app/src/test/java/com/pulseloop/data/ActivityDailyDisplayTest.kt b/app/src/test/java/com/pulseloop/data/ActivityDailyDisplayTest.kt new file mode 100644 index 00000000..d7ca8578 --- /dev/null +++ b/app/src/test/java/com/pulseloop/data/ActivityDailyDisplayTest.kt @@ -0,0 +1,115 @@ +package com.pulseloop.data + +import com.pulseloop.data.entity.ActivityDailyEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * Read-time step-source selection (PR #98, Path B). The phone never overwrites the ring's + * `steps` column — it writes `phoneSteps` — and the display picks between them here. The rules + * the whole feature rests on: + * + * - Ring mode (`preferPhone = false`) always shows the ring's `steps`, never `phoneSteps`. + * - Phone mode (`preferPhone = true`) shows `phoneSteps` when it is non-null, else falls back + * to the ring's `steps` — a day the phone never backfilled still shows something. + * - `phoneSteps = 0` is a real state distinct from `null`: the phone was counting and counted + * zero. Only `null` means "the phone never wrote for this day". + * - `forDisplay` rewrites only `steps`; every other column (calories, distance, source) is + * left as the ring wrote it — that is what makes switching source reversible. + * + * Pure functions, no Room: these are the same helpers the ViewModels, widgets, and the + * notification context builder call. + */ +class ActivityDailyDisplayTest { + + private fun row( + steps: Int = 5_000, + phoneSteps: Int? = null, + calories: Double = 200.0, + distanceMeters: Double = 4_000.0, + source: String = "ring", + ) = ActivityDailyEntity( + date = 1_700_000_000_000L, + steps = steps, + phoneSteps = phoneSteps, + calories = calories, + distanceMeters = distanceMeters, + source = source, + ) + + // ── displaySteps (scalar read) ── + + @Test + fun ringModeShowsRingStepsEvenWhenPhoneHasAValue() { + assertEquals(5_000, row(phoneSteps = 8_200).displaySteps(preferPhone = false)) + } + + @Test + fun ringModeShowsRingStepsWhenPhoneHasNoValue() { + assertEquals(5_000, row(phoneSteps = null).displaySteps(preferPhone = false)) + } + + @Test + fun phoneModeShowsPhoneStepsWhenSet() { + assertEquals(8_200, row(phoneSteps = 8_200).displaySteps(preferPhone = true)) + } + + @Test + fun phoneModeFallsBackToRingStepsWhenPhoneIsNull() { + assertEquals(5_000, row(phoneSteps = null).displaySteps(preferPhone = true)) + } + + @Test + fun phoneModeShowsZeroWhenPhoneWalkedZero() { + // The midnight-gap case: the phone was counting and counted 0, which must display as + // 0 and not fall through to the ring's stale value. + assertEquals(0, row(steps = 5_000, phoneSteps = 0).displaySteps(preferPhone = true)) + } + + // ── forDisplay (whole-row projection) ── + + @Test + fun forDisplayInRingModeReturnsRowUnchanged() { + val original = row(phoneSteps = 8_200) + assertSame(original, original.forDisplay(preferPhone = false)) + } + + @Test + fun forDisplayInPhoneModeWithNoPhoneValueReturnsRowUnchanged() { + val original = row(phoneSteps = null) + assertSame(original, original.forDisplay(preferPhone = true)) + } + + @Test + fun forDisplayInPhoneModeProjectsPhoneStepsOntoSteps() { + val original = row(steps = 5_000, phoneSteps = 8_200) + val shown = original.forDisplay(preferPhone = true) + assertEquals(8_200, shown.steps) + } + + @Test + fun forDisplayLeavesEveryOtherColumnUntouched() { + // Path B's whole point: only `steps` is projected. If calories/distance ever changed + // under source switching, the ring's real values would be lost. + val original = row( + steps = 5_000, phoneSteps = 8_200, + calories = 210.5, distanceMeters = 4_321.0, source = "ring", + ) + val shown = original.forDisplay(preferPhone = true) + assertEquals(210.5, shown.calories, 0.0) + assertEquals(4_321.0, shown.distanceMeters, 0.0) + assertEquals("ring", shown.source) + assertEquals(8_200, shown.phoneSteps) // phoneSteps itself is unchanged + assertEquals(1_700_000_000_000L, shown.date) + } + + @Test + fun forDisplayZeroPhoneStepsProjectsZeroNotFallback() { + // Same as the midnight-gap scalar case, but through the whole-row path the ViewModels + // actually use. + val original = row(steps = 5_000, phoneSteps = 0) + val shown = original.forDisplay(preferPhone = true) + assertEquals(0, shown.steps) + } +} diff --git a/app/src/test/java/com/pulseloop/health/HealthConnectTypeMappingsTest.kt b/app/src/test/java/com/pulseloop/health/HealthConnectTypeMappingsTest.kt index e72f9af7..63578b28 100644 --- a/app/src/test/java/com/pulseloop/health/HealthConnectTypeMappingsTest.kt +++ b/app/src/test/java/com/pulseloop/health/HealthConnectTypeMappingsTest.kt @@ -366,4 +366,22 @@ class HealthConnectTypeMappingsTest { val out = HealthConnectTypeMappings.normalizeSleepStages(s0, s1, listOf(span(-120, -60), span(300, 360))) assertTrue(out.isEmpty()) } + + // ── EXCLUDED_SOURCES ── + + @Test + fun excludedSourcesCoversDemoAndMock() { + // Seed/placeholder rows must never leave the app (mirrors iOS). + // + // `"phone"` used to be in this set as well, to break a Health Connect self-feedback loop + // under the old design where the phone wrote its step count into `activity_daily.steps` + // — the same column the exporter reads. Under the separate-column design (PR #98) the + // phone's value lives in `phoneSteps`, which the exporter never reads, so the loop is + // broken by construction and the exclusion is gone. A phone-first day still produces no + // record — but via the plausibility guards (`isPlausibleSteps` floors at 1) rather than a + // source-based skip. See the KDoc on HealthConnectTypeMappings.EXCLUDED_SOURCES. + assertTrue(HealthConnectTypeMappings.EXCLUDED_SOURCES.contains("demo")) + assertTrue(HealthConnectTypeMappings.EXCLUDED_SOURCES.contains("mock")) + assertFalse(HealthConnectTypeMappings.EXCLUDED_SOURCES.contains("phone")) + } } diff --git a/app/src/test/java/com/pulseloop/service/EventPersistencePhoneWriteTest.kt b/app/src/test/java/com/pulseloop/service/EventPersistencePhoneWriteTest.kt new file mode 100644 index 00000000..bd0fb322 --- /dev/null +++ b/app/src/test/java/com/pulseloop/service/EventPersistencePhoneWriteTest.kt @@ -0,0 +1,155 @@ +package com.pulseloop.service + +import com.pulseloop.data.entity.ActivityDailyEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The phone step write's column-only contract (PR #98, Path B). Phone and ring data live in + * separate columns, and the phone write touches exactly one of them — that is what makes the + * source switch reversible and what stops the phone from clobbering the ring's calories and + * distance. [phoneStepRow] is the pure decision this rests on (same pattern as + * `EventPersistenceIdentityTest`'s helpers); the Room call around it is one line. + * + * The four [phoneStepRow] rules asserted here: + * - only `phoneSteps`/`syncedAt` change on an existing row — `updatedAt` is preserved so the + * exporter does not re-select the row for unchanged ring data (review item 10) + * - a brand-new row is created with `source = "phone"` + * - writing the same value twice is a no-op + * - `phoneSteps = 0` is a real value distinct from null + * + * Plus [clearPhoneStepsOnRow]'s contract (review item 4): + * - the returned row differs only in `phoneSteps = null` — no `updatedAt` bump (see item 10) + * - the call is a no-op when `phoneSteps` is already null, or when the row is absent + */ +class EventPersistencePhoneWriteTest { + + private val dayStart = 1_700_000_000_000L + + private fun ringRow( + steps: Int = 5_000, + phoneSteps: Int? = null, + calories: Double = 210.5, + distanceMeters: Double = 4_321.0, + source: String = "ring", + ) = ActivityDailyEntity( + date = dayStart, + steps = steps, + phoneSteps = phoneSteps, + calories = calories, + distanceMeters = distanceMeters, + source = source, + ) + + @Test + fun phoneWriteTouchesPhoneStepsAndSyncedAtOnly() { + val existing = ringRow().copy(updatedAt = 500L) + val written = phoneStepRow(existing, dayStart, steps = 8_200, now = 999L)!! + + assertEquals(8_200, written.phoneSteps) + assertEquals(999L, written.syncedAt) // syncedAt bumps + assertEquals(500L, written.updatedAt) // preserved — review item 10 + + // Ring's own columns, untouched: + assertEquals(5_000, written.steps) + assertEquals(210.5, written.calories, 0.0) + assertEquals(4_321.0, written.distanceMeters, 0.0) + assertEquals("ring", written.source) + } + + @Test + fun changedPhoneValueAlsoPreservesUpdatedAt() { + // The realistic case: an existing day has phoneSteps = 8_200, the user walks 300 + // more, refresh() publishes 8_500. phoneSteps and syncedAt move; updatedAt must not, + // or ActivityExporter re-selects the row and re-exports unchanged ring steps/ + // calories/distance (review item 10). + val existing = ringRow(phoneSteps = 8_200).copy(updatedAt = 500L) + val written = phoneStepRow(existing, dayStart, steps = 8_500, now = 999L)!! + + assertEquals(8_500, written.phoneSteps) + assertEquals(999L, written.syncedAt) + assertEquals(500L, written.updatedAt) + } + + @Test + fun firstPhoneWriteOnADayCreatesRowWithPhoneSource() { + val written = phoneStepRow(null, dayStart = dayStart, steps = 8_200, now = 999L)!! + + assertEquals(dayStart, written.date) + assertEquals(8_200, written.phoneSteps) + assertEquals("phone", written.source) + assertEquals(999L, written.syncedAt) + assertEquals(999L, written.updatedAt) // new rows stamp updatedAt so the exporter picks them up + assertEquals(0, written.steps) // default — nothing has written the ring column yet + assertEquals(0.0, written.calories, 0.0) // default + assertEquals(0.0, written.distanceMeters, 0.0) + } + + @Test + fun sameValueTwiceIsANoOp() { + // `null` returned means "don't write" — the caller skips the upsert entirely. + assertNull(phoneStepRow(ringRow(phoneSteps = 8_200), dayStart, steps = 8_200)) + assertNull(phoneStepRow(ringRow(phoneSteps = 0), dayStart, steps = 0)) + } + + @Test + fun differentValueWrites() { + // 8_200 → 8_500: today's phone count moved; a write is required. + assertNotNull(phoneStepRow(ringRow(phoneSteps = 8_200), dayStart, steps = 8_500)) + } + + @Test + fun nullToZeroWrites() { + // The midnight-gap case: phone has begun counting and counted zero. This is the write + // that makes "today's phone value is 0" a real state — skipping it would leave the day's + // phoneSteps null and the display showing the ring's stale total. + val written = phoneStepRow(ringRow(phoneSteps = null), dayStart, steps = 0)!! + assertEquals(0, written.phoneSteps) + } + + @Test + fun zeroToNullWouldWrite() { + // Not a state we expect in production, but the helper's contract must not confuse + // "explicit zero" with "no value": only equal-non-null values are the no-op case. + assertNotNull(phoneStepRow(ringRow(phoneSteps = 0), dayStart, steps = 1)) + } + + // ── clearPhoneStepsOnRow (review item 4) ───────────────────────────────── + + @Test + fun clearPhoneStepsNullsTheColumnWithoutTouchingAnythingElse() { + val existing = ringRow(phoneSteps = 8_200, source = "ring").copy( + activeMinutes = 42, syncedAt = 500L, updatedAt = 500L, + ) + val cleared = clearPhoneStepsOnRow(existing)!! + + assertNull(cleared.phoneSteps) + // Ring data columns untouched: + assertEquals(5_000, cleared.steps) + assertEquals(210.5, cleared.calories, 0.0) + assertEquals(4_321.0, cleared.distanceMeters, 0.0) + assertEquals("ring", cleared.source) + assertEquals(42, cleared.activeMinutes) + // Metadata untouched too: syncedAt is preserved, and updatedAt deliberately is + // — ActivityExporter selects on it, and a metadata-only change would re-export + // unchanged ring data (item 10's rule). + assertEquals(500L, cleared.syncedAt) + assertEquals(500L, cleared.updatedAt) + } + + @Test + fun clearPhoneStepsIsANoOpWhenAlreadyNull() { + // Every failed foreground fires PhoneStepSourceUnavailable. Only the first one + // (while phoneSteps was non-null) should write; the rest must be no-ops. + assertNull(clearPhoneStepsOnRow(ringRow(phoneSteps = null))) + } + + @Test + fun clearPhoneStepsIsANoOpWhenRowIsAbsent() { + // No row for today: nothing to clear. The ring hasn't written and the phone hasn't + // either, so display falls back to the null-row default (0 / no data) already. + assertNull(clearPhoneStepsOnRow(null)) + } +} diff --git a/app/src/test/java/com/pulseloop/service/PhoneStepSourceTest.kt b/app/src/test/java/com/pulseloop/service/PhoneStepSourceTest.kt new file mode 100644 index 00000000..84d94ca4 --- /dev/null +++ b/app/src/test/java/com/pulseloop/service/PhoneStepSourceTest.kt @@ -0,0 +1,46 @@ +package com.pulseloop.service + +import com.pulseloop.ring.PulseEvent +import com.pulseloop.settings.StepSourcePrefs +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import java.time.Instant + +/** + * Regression guards for the phone step source. Deeper coverage of the Health Connect reads + * (`PhoneStepManager`) would need a mocked HC client and is out of scope here — these tests + * protect the invariants that are easy to break silently: + * + * - the two source constants stay distinct (a typo collapsing them would silently break the + * preference switch) + * - the event carries only the step count (adding a hardcoded distance/calorie back would + * bypass DailyCalorieEstimator without any compile error) + * + * The read-time source selection itself (`ActivityDailyEntity.displaySteps` / `forDisplay`) is + * covered in `app/src/test/java/com/pulseloop/data/ActivityDailyDisplayTest.kt`. The phone + * write's column-only behaviour is covered (Room-backed) in + * `app/src/test/java/com/pulseloop/service/EventPersistencePhoneWriteTest.kt`. + * The EXCLUDED_SOURCES assertion that used to live here was superseded by + * `HealthConnectTypeMappingsTest.excludedSourcesCoversDemoAndMock` in commit 41beee0 — `"phone"` + * was dropped from the set, so the old assertion was simply wrong. + */ +class PhoneStepSourceTest { + + @Test + fun sourceConstantsAreDistinct() { + assertNotEquals(StepSourcePrefs.SOURCE_RING, StepSourcePrefs.SOURCE_PHONE) + } + + @Test + fun phoneStepsUpdateCarriesOnlySteps() { + // Constructing the event with only (timestamp, steps) is itself the regression guard: + // if anyone reintroduces `distanceMeters` or `calories` as required constructor + // parameters, this file stops compiling. + val event = PulseEvent.PhoneStepsUpdate( + timestamp = Instant.ofEpochMilli(0L), + steps = 123, + ) + assertEquals(123, event.steps) + } +}