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)
+ }
+}