From e26b9effe68d1f5b3b233509ecc0457f893a1c63 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Wed, 21 Oct 2026 02:13:06 -0700 Subject: [PATCH 1/7] fix(sync): persist background-sync data in a process the UI never started RingSyncWorker publishes what it pulls from the ring onto PulseEventBus, which keeps no backlog (replay = 0). The only collector that writes it to Room was built and started by the UI's startup (AppServices / PulseLoopApp), so in a process Android launched just for the worker, every event was published to no one and dropped. The subscriber is now one per process and started from PulseLoopApplication.onCreate; AppServices uses the same instance. Groundwork for #103's scheduled spot SpO2, which runs from the background too. --- .../main/java/com/pulseloop/AppServices.kt | 9 ++------ .../com/pulseloop/PulseLoopApplication.kt | 4 ++++ .../service/EventPersistenceSubscriber.kt | 23 +++++++++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/pulseloop/AppServices.kt b/app/src/main/java/com/pulseloop/AppServices.kt index 6c740795..c47af7ec 100644 --- a/app/src/main/java/com/pulseloop/AppServices.kt +++ b/app/src/main/java/com/pulseloop/AppServices.kt @@ -13,7 +13,6 @@ import com.pulseloop.service.GpsRouteRecorder import com.pulseloop.service.LiveWorkoutManager import com.pulseloop.service.RingSyncCoordinator import com.pulseloop.settings.ApiKeyStore -import com.pulseloop.widgets.WidgetSnapshotPublisher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -39,12 +38,8 @@ class AppServices private constructor(context: Context) { val gpsRecorder = GpsRouteRecorder(context, db) val liveWorkout = LiveWorkoutManager(coordinator, db, gpsRecorder, context) - // Every persisted ring-sync batch republishes the widget snapshot (debounced 2 s), - // mirroring the iOS PulseDataChange → WidgetSnapshotPublisher pipeline. - val persistence = EventPersistenceSubscriber( - context, db, - onDataPersisted = { WidgetSnapshotPublisher.publishDebounced(context) }, - ) + /** Process-wide, not per-[AppServices]: background workers need it without the UI's services. */ + val persistence = EventPersistenceSubscriber.shared(context) val batteryAlerts = BatteryAlertMonitor(context) val providerStore = CoachProviderSettingsStore(context) val summaryCoordinator = CoachSummaryCoordinator(db, apiKeyStore, providerStore) diff --git a/app/src/main/java/com/pulseloop/PulseLoopApplication.kt b/app/src/main/java/com/pulseloop/PulseLoopApplication.kt index abceff5f..9689d3dd 100644 --- a/app/src/main/java/com/pulseloop/PulseLoopApplication.kt +++ b/app/src/main/java/com/pulseloop/PulseLoopApplication.kt @@ -8,6 +8,7 @@ import com.pulseloop.data.DataRepairs import com.pulseloop.data.PulseLoopDatabase import com.pulseloop.diagnostics.CrashLogger import com.pulseloop.diagnostics.DiagnosticsSubscriber +import com.pulseloop.service.EventPersistenceSubscriber import com.pulseloop.settings.QuietHoursPrefs import com.pulseloop.widgets.WidgetRefreshWorker import com.pulseloop.widgets.WidgetSnapshotPublisher @@ -31,6 +32,9 @@ 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. + 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 // ring can't connect before this completes a couple of DELETE statements. diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index 8cd61fc3..7584b1f1 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -8,6 +8,7 @@ import com.pulseloop.data.entity.* import com.pulseloop.health.HealthConnectExportWorker import com.pulseloop.settings.QuietHoursPrefs import com.pulseloop.ring.* +import com.pulseloop.widgets.WidgetSnapshotPublisher import kotlinx.coroutines.* /** @@ -825,6 +826,28 @@ class EventPersistenceSubscriber( /** How far back [spotReadings] is primed from the table on first use. */ const val SPOT_LOOKBACK_MS = 7L * 24 * 60 * 60_000 private const val MAX_SLEEP_TIMELINE_MINUTES = 24 * 60 + + @Volatile private var shared: EventPersistenceSubscriber? = null + + /** + * The process's one subscriber, started from `PulseLoopApplication.onCreate`. + * + * It used to be built and started only by the UI's startup (`AppServices` / + * `PulseLoopApp`). A process Android launched just to run `RingSyncWorker` never got + * there, and the bus keeps no backlog (`replay = 0`), so everything that background sync + * pulled from the ring was published to no one and dropped. + */ + fun shared(context: Context): EventPersistenceSubscriber = + shared ?: synchronized(this) { + shared ?: context.applicationContext.let { app -> + // Every persisted ring-sync batch republishes the widget snapshot (debounced + // 2 s), mirroring the iOS PulseDataChange → WidgetSnapshotPublisher pipeline. + EventPersistenceSubscriber( + app, PulseLoopDatabase.getInstance(app), + onDataPersisted = { WidgetSnapshotPublisher.publishDebounced(app) }, + ) + }.also { shared = it } + } } } From b14702209bab56fb4d9abee66859f57740a98063 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Wed, 21 Oct 2026 02:17:30 -0700 Subject: [PATCH 2/7] feat(spo2): opt-in app-started spot SpO2 on the SpO2 interval (#103) On YCBT rings the hourly SpO2 is computed from heart rate. The ring's manual measurement is real, so the app can now run it on the SpO2 interval: Measurement Frequency -> "Measure SpO2 from the app", off by default, offered for YCBT rings with manual SpO2. - Slots sit halfway between the ring's own readings on a grid from local midnight (60 min -> :30, 30 -> :15/:45). A run WorkManager starts within 5 min of a ring reading, or that would still be measuring at the next one, is skipped. - ScheduledSpo2Worker is one-time work that appends the next slot as it finishes; app start and RingSyncWorker revive the chain if a run was cancelled. - The ring stops the measurement when the link drops, so the run holds the connection until 04 0e: through the app's link when it has one, otherwise a private client with its own RingSyncCoordinator, which runs the normal startup sync first and stores the reading exactly as the Vitals button does. --- .../main/java/com/pulseloop/AppServices.kt | 4 + .../com/pulseloop/service/RingSyncWorker.kt | 4 + .../pulseloop/service/ScheduledSpo2Slots.kt | 56 ++++++ .../pulseloop/service/ScheduledSpo2Worker.kt | 170 ++++++++++++++++++ .../pulseloop/settings/ScheduledSpo2Prefs.kt | 40 +++++ .../java/com/pulseloop/ui/PulseLoopApp.kt | 2 + .../ui/screens/SettingsSubScreens.kt | 18 ++ .../service/ScheduledSpo2SlotsTest.kt | 85 +++++++++ 8 files changed, 379 insertions(+) create mode 100644 app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt create mode 100644 app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt create mode 100644 app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt create mode 100644 app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt diff --git a/app/src/main/java/com/pulseloop/AppServices.kt b/app/src/main/java/com/pulseloop/AppServices.kt index c47af7ec..8c59e441 100644 --- a/app/src/main/java/com/pulseloop/AppServices.kt +++ b/app/src/main/java/com/pulseloop/AppServices.kt @@ -80,6 +80,10 @@ class AppServices private constructor(context: Context) { instance ?: synchronized(this) { instance ?: AppServices(context.applicationContext).also { instance = it } } + + /** The services if the UI already built them, without building them: a background worker + * must not create the app's ring client, whose watchdog would start reconnecting. */ + fun peek(): AppServices? = instance } } diff --git a/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt b/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt index 134b4f01..44551eea 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt @@ -63,6 +63,10 @@ class RingSyncWorker( } override suspend fun doWork(): Result { + // The scheduled spot SpO₂ chain (issue #103) dies if one of its runs is cancelled; this + // periodic job is what reliably comes back, so it revives it. + ScheduledSpo2Worker.ensureScheduled(applicationContext) + // The foreground app owns the live GATT. An overdue periodic job can start at the same // moment as MainActivity after process launch; opening a second client interleaves both // command queues and lets this worker's cleanup disconnect the UI-owned session. diff --git a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt new file mode 100644 index 00000000..944b87b5 --- /dev/null +++ b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt @@ -0,0 +1,56 @@ +package com.pulseloop.service + +import java.time.LocalTime +import java.time.ZonedDateTime + +/** + * When the app starts its own spot SpO₂ (issue #103). + * + * The ring runs its own all-day reading on a grid from local midnight at the configured interval + * (hourly on the reporter's ring: 06:00:42, 07:00:39, 08:00:26) and serves one measurement at a + * time: a second start dropped the first. So the app aims at the **midpoint** between two of the + * ring's readings — 60 min → :30, 30 min → :15 and :45 — the slot furthest from both. + * + * WorkManager runs late, and overnight Doze can hold a run for a long time. [isClear] is the guard + * for that: a run that would start within a few minutes of a ring reading, or still be measuring + * when the next one starts, is skipped instead. + * + * Local wall-clock seconds rather than epoch arithmetic, so a DST day keeps its slots on the half + * hour instead of shifting them by the hour the clock jumped. + */ +internal object ScheduledSpo2Slots { + + /** The ring ends a spot SpO₂ with `04 0e` at 50–63 s (issue #59 captures); the coordinator's + * ceiling for it is 75 s. */ + const val MEASUREMENT_SECONDS = 75 + + /** How far a run must start from the ring's own reading: five minutes, or a quarter of a + * shorter interval so a 15-minute grid still has room. */ + fun marginSeconds(intervalMinutes: Int): Int = minOf(5 * 60, intervalMinutes * 60 / 4) + + /** The first slot strictly after [now]. */ + fun nextSlot(now: ZonedDateTime, intervalMinutes: Int): ZonedDateTime { + val interval = intervalMinutes * 60 + val half = interval / 2 + val secs = now.toLocalTime().toSecondOfDay() + val k = if (secs < half) 0 else (secs - half) / interval + 1 + val slot = half + k * interval + val day = if (slot >= SECONDS_PER_DAY) now.toLocalDate().plusDays(1) else now.toLocalDate() + val slotOfDay = if (slot >= SECONDS_PER_DAY) half else slot + return day.atTime(LocalTime.ofSecondOfDay(slotOfDay.toLong())).atZone(now.zone) + } + + /** Whether a measurement starting at [now] stays clear of the ring's readings either side. */ + fun isClear(now: ZonedDateTime, intervalMinutes: Int): Boolean { + val interval = intervalMinutes * 60 + val margin = marginSeconds(intervalMinutes) + val secs = now.toLocalTime().toSecondOfDay() + val sincePrevious = secs % interval + // The grid restarts at midnight, so an interval that doesn't divide the day has a short + // last step before it. + val untilNext = minOf(interval - sincePrevious, SECONDS_PER_DAY - secs) + return sincePrevious >= margin && untilNext >= margin + MEASUREMENT_SECONDS + } + + private const val SECONDS_PER_DAY = 24 * 60 * 60 +} diff --git a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt new file mode 100644 index 00000000..7364bab5 --- /dev/null +++ b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt @@ -0,0 +1,170 @@ +package com.pulseloop.service + +import android.content.Context +import android.util.Log +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.work.* +import com.pulseloop.AppServices +import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.ring.PulseEvent +import com.pulseloop.ring.PulseEventBus +import com.pulseloop.ring.RingBLEClient +import com.pulseloop.ring.RingConnectionState +import com.pulseloop.settings.ApiKeyStore +import com.pulseloop.settings.ScheduledSpo2Prefs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.time.Duration +import java.time.ZonedDateTime +import java.util.concurrent.TimeUnit + +/** + * Starts a real spot SpO₂ at each slot of the SpO₂ interval (issue #103), when the user opted in + * ([ScheduledSpo2Prefs]). + * + * One-time work that enqueues the next slot as it finishes, rather than periodic work: a periodic + * request can't be pinned to the half hour, and its 15-minute floor and flex would put runs on top + * of the ring's own reading. It is best effort — Doze defers it, and [ScheduledSpo2Slots.isClear] + * skips a run that lands too close to the ring's reading. A cancelled chain is revived by + * [ensureScheduled] from app start and from every [RingSyncWorker] run. + * + * The ring stops the measurement the moment the link drops (the reporter's test: start, disconnect + * 3 s later, LED off, nothing stored), so the run holds the connection until the ring's `04 0e`. + * It uses the app's own connection when there is one, and otherwise opens a private one the way + * [RingSyncWorker] does, with its own [RingSyncCoordinator] so the reading is settled and stored + * exactly as the Vitals button's is. + */ +class ScheduledSpo2Worker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + companion object { + private const val WORK_NAME = "scheduled_spot_spo2" + private const val TAG = "ScheduledSpo2Worker" + private const val CONNECT_TIMEOUT_MS = 20_000L + /** Startup history has to drain before the sensor is taken — the ring serves one thing at a time. */ + private const val SYNC_DRAIN_TIMEOUT_MS = 45_000L + + /** After a settings change: replace whatever is queued with the next slot, or cancel. */ + suspend fun reschedule(context: Context) { + enqueueNext(context, ExistingWorkPolicy.REPLACE) + } + + /** Revive the chain if it died (cancelled, or never started); a queued run is left alone. */ + suspend fun ensureScheduled(context: Context) { + enqueueNext(context, ExistingWorkPolicy.KEEP) + } + + private suspend fun enqueueNext(context: Context, policy: ExistingWorkPolicy) { + val wm = WorkManager.getInstance(context) + if (!ScheduledSpo2Prefs(context).enabled) { + wm.cancelUniqueWork(WORK_NAME) + return + } + // A run re-checks the interval when it starts, so a change after this only moves one run. + val interval = withContext(Dispatchers.IO) { + intervalMinutes(PulseLoopDatabase.getInstance(context)) + } ?: run { wm.cancelUniqueWork(WORK_NAME); return } + val now = ZonedDateTime.now() + val delayMs = Duration.between(now, ScheduledSpo2Slots.nextSlot(now, interval)).toMillis() + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delayMs, TimeUnit.MILLISECONDS) + .setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build()) + .build() + wm.enqueueUniqueWork(WORK_NAME, policy, request) + } + + /** The SpO₂ interval the ring is set to, or null when its SpO₂ monitor is off or unset. */ + private suspend fun intervalMinutes(db: PulseLoopDatabase): Int? { + val settings = loadPersistedMeasurementSettings(db) ?: return null + if (!settings.spo2Enabled) return null + val minutes = settings.spo2IntervalMinutes.takeIf { it > 0 } ?: settings.hrIntervalMinutes + return minutes.coerceIn(5, 60) + } + } + + override suspend fun doWork(): Result { + if (!ScheduledSpo2Prefs(applicationContext).enabled) return Result.success() + val db = PulseLoopDatabase.getInstance(applicationContext) + try { + val interval = intervalMinutes(db) ?: return Result.success() + if (!ScheduledSpo2Prefs.isSupported(db.deviceDao().currentReal())) return Result.success() + if (!ScheduledSpo2Slots.isClear(ZonedDateTime.now(), interval)) { + Log.i(TAG, "Skipped: too close to the ring's own reading") + return Result.success() + } + measure(db) + } catch (e: Exception) { + Log.w(TAG, "Scheduled SpO2 failed", e) + } finally { + // Append: the next run waits for this one, and REPLACE from inside a running worker + // would cancel it. Always succeed, since a failed run fails its appended successor. + enqueueNext(applicationContext, ExistingWorkPolicy.APPEND_OR_REPLACE) + } + return Result.success() + } + + private suspend fun measure(db: PulseLoopDatabase) { + // The app's own link, if it has one: a second client can't connect while it holds GATT. + AppServices.peek()?.coordinator?.let { coordinator -> + val ran = withContext(Dispatchers.Main) { + if (!coordinator.isConnected) return@withContext false + // A workout polls SpO₂ itself, and a measurement in flight holds the sensor. + if (!coordinator.workoutHRActive) coordinator.measureSpO2Only() + true + } + if (ran) return + } + // The app is opening and owns the connection it is about to make. + if (isAppForeground()) return + + val client = RingBLEClient(applicationContext, transientOwner = true) + if (!client.hasPermissions()) { client.destroy(); return } + val coordinator = RingSyncCoordinator(client, db, ApiKeyStore(applicationContext)) + try { + withContext(Dispatchers.Main) { coordinator.start() } + // The same startup the app runs on connect: claims the ring and pushes settings, and + // pulls history while it is at it. + client.onConnected = { withContext(Dispatchers.Main) { coordinator.runStartupSequence() } } + client.connectLastKnown() + + val connected = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { + while (!coordinator.isConnected) delay(500) + true + } ?: false + if (!connected) return + + // runStartupSequence opens the progress just after connect; wait for it to open, then + // to drain (the same wait factoryResetRing uses before it takes the ring). + withTimeoutOrNull(3_000L) { coordinator.syncProgress.first { it != null } } + withTimeoutOrNull(SYNC_DRAIN_TIMEOUT_MS) { + coordinator.syncProgress.first { it == null || it >= 100 } + } + delay(800) + // Hand the ring over to the app if it opened meanwhile; once measuring, finish — the + // reading is lost if the link drops, and the app only waits for one minute. + if (isAppForeground() || !coordinator.isConnected) return + val value = withContext(Dispatchers.Main) { coordinator.measureSpO2Only() } + Log.i(TAG, "Scheduled SpO2: ${value ?: "no reading"}") + // The stop frame is only enqueued; let it reach the ring before the link closes. + client.awaitOpsFlushed() + } finally { + withContext(Dispatchers.Main) { coordinator.stop() } + val releasedConnection = client.destroy() + if (releasedConnection && !isAppForeground()) { + PulseEventBus.publishBlocking( + PulseEvent.DeviceStateChanged(RingConnectionState.DISCONNECTED, null) + ) + } + } + } + + private suspend fun isAppForeground(): Boolean = withContext(Dispatchers.Main.immediate) { + ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } +} diff --git a/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt b/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt new file mode 100644 index 00000000..f1edbb73 --- /dev/null +++ b/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt @@ -0,0 +1,40 @@ +package com.pulseloop.settings + +import android.content.Context +import com.pulseloop.data.entity.DeviceEntity +import com.pulseloop.ring.RingDeviceType +import com.pulseloop.ring.WearableCapability + +/** + * The opt-in behind issue #103: have the app start a real spot SpO₂ on the SpO₂ interval. + * + * On a YCBT ring the hourly SpO₂ is computed from heart rate (one HR always maps to the same + * value), so the history line is an estimate. The ring's manual measurement is real — red LED, + * logged on its own — so the app runs that instead, between the ring's own readings + * ([com.pulseloop.service.ScheduledSpo2Slots]). + * + * Off by default: each run holds the connection and the optical sensor for about a minute. + * In SharedPreferences rather than `device_measurement_configs` to stay clear of a schema bump. + */ +class ScheduledSpo2Prefs(context: Context) { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + var enabled: Boolean + get() = prefs.getBoolean(KEY_ENABLED, false) + set(value) { prefs.edit().putBoolean(KEY_ENABLED, value).apply() } + + companion object { + private const val PREFS_NAME = "scheduled_spo2" + private const val KEY_ENABLED = "enabled" + + /** + * Rings this is offered for: a manual SpO₂ that ends with the ring's own `04 0e` verdict, + * so the run has a known end and the connection can be released right after it. Only the + * YCBT family signals that today (issue #59). + */ + fun isSupported(device: DeviceEntity?): Boolean = + device != null && device.deviceType == RingDeviceType.YCBT && + WearableCapability.MANUAL_SPO2 in device.capabilities + } +} diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index 724a8a69..34548602 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -214,6 +214,8 @@ fun PulseLoopApp() { // Schedule periodic background sync (matches official app behavior) RingSyncWorker.schedule(context.applicationContext) + // Opt-in app-started spot SpO₂ (issue #103); revives the chain if it was cancelled. + com.pulseloop.service.ScheduledSpo2Worker.ensureScheduled(context.applicationContext) // NOTE: demo data is never auto-seeded. A clean install starts empty so the UI // reflects only real ring data. Demo data is seeded exclusively via the diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt index c7feeac0..57816e09 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt @@ -70,11 +70,13 @@ import com.pulseloop.service.GlucoseUnit import com.pulseloop.service.MetricZone import com.pulseloop.service.RingSyncCoordinator import com.pulseloop.service.RingSyncWorker +import com.pulseloop.service.ScheduledSpo2Worker import com.pulseloop.service.VitalColorToken import com.pulseloop.service.VitalSample import com.pulseloop.service.ZoneSeverity import com.pulseloop.settings.ApiKeyStore import com.pulseloop.settings.QuietHoursPrefs +import com.pulseloop.settings.ScheduledSpo2Prefs import com.pulseloop.settings.UnitSystem import com.pulseloop.settings.ZenModeListener import com.pulseloop.ui.components.DeviceHeroStatus @@ -1518,6 +1520,9 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U var cfgStress by remember { mutableStateOf(true) } var cfgHrv by remember { mutableStateOf(true) } var cfgTemp by remember { mutableStateOf(true) } + val scheduledSpo2Prefs = remember { ScheduledSpo2Prefs(context) } + val supportsScheduledSpo2 = ScheduledSpo2Prefs.isSupported(device) + var cfgAppSpo2 by remember { mutableStateOf(false) } var cfgLoaded by remember { mutableStateOf(false) } var cfgSavedMsg by remember { mutableStateOf(null) } @@ -1534,6 +1539,7 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U cfgStress = (config?.stressEnabled ?: true) && supportsStressSetting cfgHrv = (config?.hrvEnabled ?: true) && WearableCapability.HRV in capabilities cfgTemp = (config?.temperatureEnabled ?: true) && WearableCapability.TEMPERATURE in capabilities + cfgAppSpo2 = scheduledSpo2Prefs.enabled cfgLoaded = true } @@ -1606,6 +1612,16 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U steps = spo2IntervalSteps, ) } + // Issue #103: this ring's own SpO₂ is computed from heart rate, so the app + // can run the ring's real spot measurement on the interval instead. + if (cfgSpo2 && supportsScheduledSpo2) { + VitalToggle("Measure SpO₂ from the app", cfgAppSpo2) { cfgAppSpo2 = it } + Text( + "This ring's all-day SpO₂ is estimated from heart rate. When on, PulseLoop starts a real spot measurement halfway between the ring's readings, about a minute each, which uses more battery. Android may delay or skip runs while the phone is asleep.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } if (supportsStressSetting) { VitalToggle("Stress", cfgStress) { cfgStress = it } @@ -1637,6 +1653,8 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U ) ) coordinator?.applyMeasurementSettings() + scheduledSpo2Prefs.enabled = cfgAppSpo2 && cfgSpo2 && supportsScheduledSpo2 + ScheduledSpo2Worker.reschedule(context.applicationContext) cfgSavedMsg = if (coordinator?.isConnected == true) "Saved & sent to ring ✓" else "Saved — will sync on next connect" } diff --git a/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt b/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt new file mode 100644 index 00000000..b5c5060e --- /dev/null +++ b/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt @@ -0,0 +1,85 @@ +package com.pulseloop.service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime + +/** Issue #103: the app's own spot SpO₂ lands between the ring's readings, never on top of them. */ +class ScheduledSpo2SlotsTest { + + private val zone = ZoneId.of("Europe/Paris") + + private fun at(h: Int, m: Int, s: Int = 0, day: Int = 21, month: Int = 10): ZonedDateTime = + LocalDateTime.of(2026, month, day, h, m, s).atZone(zone) + + @Test + fun `hourly lands at half past`() { + // Just after the ring's own 10:00:42 reading — the reporter's timing. + assertEquals(at(10, 30), ScheduledSpo2Slots.nextSlot(at(10, 0, 42), 60)) + assertEquals(at(10, 30), ScheduledSpo2Slots.nextSlot(at(9, 30, 1), 60)) + } + + @Test + fun `next slot is strictly after now`() { + assertEquals(at(11, 30), ScheduledSpo2Slots.nextSlot(at(10, 30), 60)) + } + + @Test + fun `half hour interval lands at quarter past and quarter to`() { + assertEquals(at(10, 15), ScheduledSpo2Slots.nextSlot(at(10, 5), 30)) + assertEquals(at(10, 45), ScheduledSpo2Slots.nextSlot(at(10, 20), 30)) + } + + @Test + fun `last slot of the day rolls over to the first of the next`() { + assertEquals(at(0, 30, day = 22), ScheduledSpo2Slots.nextSlot(at(23, 45), 60)) + } + + @Test + fun `interval that does not divide the day restarts its grid at midnight`() { + // 35 min: grid …23:20, 23:55, then 00:00; slots 23:37:30, then 00:17:30 the next day. + assertEquals(at(23, 37, 30), ScheduledSpo2Slots.nextSlot(at(23, 20), 35)) + assertEquals(at(0, 17, 30, day = 22), ScheduledSpo2Slots.nextSlot(at(23, 40), 35)) + // 23:57 sits two minutes after the 23:55 reading. + assertFalse(ScheduledSpo2Slots.isClear(at(23, 57), 35)) + } + + @Test + fun `slots stay on the half hour across a DST change`() { + // Europe/Paris falls back on 2026-10-25 (03:00 → 02:00): the slot after 01:40 is still 02:30. + val slot = ScheduledSpo2Slots.nextSlot(at(1, 40, day = 25), 60) + assertEquals(2, slot.hour) + assertEquals(30, slot.minute) + } + + @Test + fun `a late run near the top of the hour is not clear`() { + assertTrue(ScheduledSpo2Slots.isClear(at(10, 30), 60)) + assertFalse(ScheduledSpo2Slots.isClear(at(10, 2), 60)) + // Starting at 10:57 would still be measuring at 10:58:15 — too close to 11:00. + assertFalse(ScheduledSpo2Slots.isClear(at(10, 57), 60)) + assertTrue(ScheduledSpo2Slots.isClear(at(10, 53), 60)) + } + + @Test + fun `short intervals use a smaller margin`() { + // 15 min: margin 3:45, so a run at 10:07:30 (the slot) is clear and 10:02 is not. + assertTrue(ScheduledSpo2Slots.isClear(at(10, 7, 30), 15)) + assertFalse(ScheduledSpo2Slots.isClear(at(10, 2), 15)) + } + + @Test + fun `every slot is itself clear`() { + for (interval in listOf(15, 20, 30, 35, 45, 60)) { + var t = at(0, 0) + repeat(200) { + t = ScheduledSpo2Slots.nextSlot(t, interval) + assertTrue("interval $interval slot $t", ScheduledSpo2Slots.isClear(t, interval)) + } + } + } +} From 479c96b1fb93633246a1af95926ec4ee50fb0722 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Thu, 22 Oct 2026 17:30:41 -0700 Subject: [PATCH 3/7] feat(spo2): longer app SpO2 intervals and a 120 min heart-rate option (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The measurement-frequency sliders stopped at 60 min because that is the vendor app's own menu. Two things go past it now, each on its own evidence: - Measure SpO2 from the app gets an interval of its own (60/120/180 min), independent of the ring's SpO2 setting — the app starts these runs, so the ring's limits don't apply, and each run is ~50 s of red LED that 2-3 h spacing spares at night. The slots keep their half-interval offset from the ring's grid; isClear still skips a run that lands near a ring reading. - The heart-rate slider goes to 120 min for the YCBT family: the ring ACKed 01 0c 01 78 with the app off and read at 10:00 and 12:00 (issue #103, 2026-10-22). That is the only value tested, so nothing longer and no other family. The other monitor commands keep the 60-min ceiling their bytes always carried — 120 is not evidenced on their keys, and the blood-oxygen key's grid is what the scheduled spot has to stay between. Slots at 120/180 min: unit tests against a 60- and a 30-min ring grid; 1591 tests, 0 failures. --- .../java/com/pulseloop/ring/YCBTEncoder.kt | 10 ++++- .../pulseloop/service/ScheduledSpo2Slots.kt | 17 ++++++-- .../pulseloop/service/ScheduledSpo2Worker.kt | 23 ++++++---- .../pulseloop/settings/ScheduledSpo2Prefs.kt | 21 ++++++++++ .../ui/screens/SettingsSubScreens.kt | 42 +++++++++++++++---- .../com/pulseloop/ring/YCBTEncoderTest.kt | 15 +++++++ .../service/ScheduledSpo2SlotsTest.kt | 32 ++++++++++++++ .../settings/ScheduledSpo2PrefsTest.kt | 30 +++++++++++++ 8 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 app/src/test/java/com/pulseloop/settings/ScheduledSpo2PrefsTest.kt diff --git a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt index 2f8aa5bf..3d75a628 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt @@ -25,6 +25,7 @@ class YCBTSettingsEncoder { companion object { const val MINIMUM_INTERVAL_MINUTES = 30 const val DEFAULT_INTERVAL_MINUTES = 60 + private const val MONITOR_INTERVAL_CEILING_MINUTES = 60 fun clampInterval(minutes: Int): Int { if (minutes <= 0) return DEFAULT_INTERVAL_MINUTES @@ -78,9 +79,14 @@ class YCBTSettingsEncoder { /** The five background samplers, each {enable, intervalMinutes}. */ fun monitorCommands(settings: MeasurementSettings): List { - val interval = clampInterval(settings.hrIntervalMinutes).toByte() + val hrInterval = clampInterval(settings.hrIntervalMinutes) + // 120 min is only evidenced on the heart-rate key itself (issue #103: the ring ACKed + // `01 0c 01 78` and read at 10:00 and 12:00). Every other sampler keeps the 60-min ceiling + // the UI always had, so their on-air bytes are unchanged — in particular the blood-oxygen + // key, whose grid the scheduled spot SpO₂ has to stay between. + val interval = minOf(hrInterval, MONITOR_INTERVAL_CEILING_MINUTES).toByte() return listOf( - heartMonitor(enabled = settings.hrEnabled, intervalMinutes = interval), + heartMonitor(enabled = settings.hrEnabled, intervalMinutes = hrInterval.toByte()), bloodPressureMonitor(enabled = settings.hrEnabled, intervalMinutes = interval), temperatureMonitor(enabled = settings.temperatureEnabled, intervalMinutes = interval), // Blood oxygen carries its own interval where the user set one (issue #66); 0 means diff --git a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt index 944b87b5..6bccefc4 100644 --- a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt +++ b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt @@ -28,10 +28,21 @@ internal object ScheduledSpo2Slots { * shorter interval so a 15-minute grid still has room. */ fun marginSeconds(intervalMinutes: Int): Int = minOf(5 * 60, intervalMinutes * 60 / 4) - /** The first slot strictly after [now]. */ - fun nextSlot(now: ZonedDateTime, intervalMinutes: Int): ZonedDateTime { + /** + * The first slot strictly after [now]. [intervalMinutes] is the app's own cadence; + * [ringIntervalMinutes] is the interval the ring reads SpO₂ on — slots start half of it past + * local midnight and repeat every [intervalMinutes]. At the ring's own cadence that is the + * midpoint between two of its readings; on a longer app interval (120/180 min) the grid keeps + * the same half-interval offset, which holds it clear of the ring's readings whenever the + * intervals divide evenly — and [isClear] skips any slot that lands close regardless. + */ + fun nextSlot( + now: ZonedDateTime, + intervalMinutes: Int, + ringIntervalMinutes: Int = intervalMinutes, + ): ZonedDateTime { val interval = intervalMinutes * 60 - val half = interval / 2 + val half = ringIntervalMinutes * 60 / 2 val secs = now.toLocalTime().toSecondOfDay() val k = if (secs < half) 0 else (secs - half) / interval + 1 val slot = half + k * interval diff --git a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt index 7364bab5..202f00e0 100644 --- a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt +++ b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt @@ -23,8 +23,9 @@ import java.time.ZonedDateTime import java.util.concurrent.TimeUnit /** - * Starts a real spot SpO₂ at each slot of the SpO₂ interval (issue #103), when the user opted in - * ([ScheduledSpo2Prefs]). + * Starts a real spot SpO₂ on the app's own schedule (issue #103), when the user opted in + * ([ScheduledSpo2Prefs] — a cadence of its own, 60/120/180 min, independent of the ring's SpO₂ + * interval). * * One-time work that enqueues the next slot as it finishes, rather than periodic work: a periodic * request can't be pinned to the half hour, and its 15-minute floor and flex would put runs on top @@ -66,12 +67,13 @@ class ScheduledSpo2Worker( wm.cancelUniqueWork(WORK_NAME) return } - // A run re-checks the interval when it starts, so a change after this only moves one run. - val interval = withContext(Dispatchers.IO) { + // A run re-checks the intervals when it starts, so a change after this only moves one run. + val appInterval = ScheduledSpo2Prefs(context).intervalMinutes + val ringInterval = withContext(Dispatchers.IO) { intervalMinutes(PulseLoopDatabase.getInstance(context)) } ?: run { wm.cancelUniqueWork(WORK_NAME); return } val now = ZonedDateTime.now() - val delayMs = Duration.between(now, ScheduledSpo2Slots.nextSlot(now, interval)).toMillis() + val delayMs = Duration.between(now, ScheduledSpo2Slots.nextSlot(now, appInterval, ringInterval)).toMillis() val request = OneTimeWorkRequestBuilder() .setInitialDelay(delayMs, TimeUnit.MILLISECONDS) .setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build()) @@ -79,7 +81,12 @@ class ScheduledSpo2Worker( wm.enqueueUniqueWork(WORK_NAME, policy, request) } - /** The SpO₂ interval the ring is set to, or null when its SpO₂ monitor is off or unset. */ + /** + * The SpO₂ interval the ring is set to, or null when its SpO₂ monitor is off or unset. Never + * above 60: the heart-rate interval can now go to 120 (issue #103), but the blood-oxygen + * command keeps the old ceiling — 120 min is only evidenced on the heart-rate key — so the + * ring's SpO₂ grid, which the app's slots live between, is too. + */ private suspend fun intervalMinutes(db: PulseLoopDatabase): Int? { val settings = loadPersistedMeasurementSettings(db) ?: return null if (!settings.spo2Enabled) return null @@ -92,9 +99,9 @@ class ScheduledSpo2Worker( if (!ScheduledSpo2Prefs(applicationContext).enabled) return Result.success() val db = PulseLoopDatabase.getInstance(applicationContext) try { - val interval = intervalMinutes(db) ?: return Result.success() + val ringInterval = intervalMinutes(db) ?: return Result.success() if (!ScheduledSpo2Prefs.isSupported(db.deviceDao().currentReal())) return Result.success() - if (!ScheduledSpo2Slots.isClear(ZonedDateTime.now(), interval)) { + if (!ScheduledSpo2Slots.isClear(ZonedDateTime.now(), ringInterval)) { Log.i(TAG, "Skipped: too close to the ring's own reading") return Result.success() } diff --git a/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt b/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt index f1edbb73..74f03702 100644 --- a/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt +++ b/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt @@ -24,9 +24,30 @@ class ScheduledSpo2Prefs(context: Context) { get() = prefs.getBoolean(KEY_ENABLED, false) set(value) { prefs.edit().putBoolean(KEY_ENABLED, value).apply() } + /** + * How often the app starts its own spot SpO₂, in minutes — independent of the interval the + * ring reads SpO₂ on (issue #103: each app run holds the red LED and the link for ~50 s, so + * at 2–3 h the battery and the night-time flashes are both spared). The ring's own SpO₂ + * monitor keeps its own setting; the slots stay between its readings either way. + */ + var intervalMinutes: Int + get() = prefs.getInt(KEY_INTERVAL_MINUTES, DEFAULT_INTERVAL_MINUTES) + set(value) { prefs.edit().putInt(KEY_INTERVAL_MINUTES, sanitizeIntervalMinutes(value)).apply() } + companion object { private const val PREFS_NAME = "scheduled_spo2" private const val KEY_ENABLED = "enabled" + private const val KEY_INTERVAL_MINUTES = "interval_minutes" + + /** Options are 60/120/180; 60 is what the app ran at before a choice existed. */ + const val DEFAULT_INTERVAL_MINUTES = 60 + const val MIN_INTERVAL_MINUTES = 60 + const val MAX_INTERVAL_MINUTES = 180 + + fun sanitizeIntervalMinutes(minutes: Int): Int { + val snapped = ((minutes + 30) / 60) * 60 + return snapped.coerceIn(MIN_INTERVAL_MINUTES, MAX_INTERVAL_MINUTES) + } /** * Rings this is offered for: a manual SpO₂ that ends with the ring's own `04 0e` verdict, diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt index 57816e09..4c3d08e5 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt @@ -1500,7 +1500,12 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U val device = db.deviceDao().currentFlow().collectAsState(initial = null).value val capabilities = device?.capabilities.orEmpty() val minimumInterval = if (device?.deviceType == RingDeviceType.YCBT) 30 else 5 - val intervalSteps = ((60 - minimumInterval) / 5 - 1).coerceAtLeast(0) + // 120 min is the only interval beyond the vendor app's own menu (which stops at 60) that is + // tested on hardware: the ring ACKed `01 0c 01 78` and read at 10:00 and 12:00 (issue #103, + // 2026-10-22). Nothing longer until someone tries it, and YCBT only — it is not the vendor + // app's behaviour, it is one ring's. + val maximumHrInterval = if (device?.deviceType == RingDeviceType.YCBT) 120 else 60 + val intervalSteps = ((maximumHrInterval - minimumInterval) / 5 - 1).coerceAtLeast(0) // Only offered where the ring's protocol actually carries a separate blood-oxygen interval — // elsewhere the slider moved, the value saved, and "Saved & sent to ring ✓" appeared over a // setting nothing on the wire could express (issue #66). @@ -1523,6 +1528,7 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U val scheduledSpo2Prefs = remember { ScheduledSpo2Prefs(context) } val supportsScheduledSpo2 = ScheduledSpo2Prefs.isSupported(device) var cfgAppSpo2 by remember { mutableStateOf(false) } + var cfgAppSpo2Interval by remember { mutableStateOf(ScheduledSpo2Prefs.DEFAULT_INTERVAL_MINUTES) } var cfgLoaded by remember { mutableStateOf(false) } var cfgSavedMsg by remember { mutableStateOf(null) } @@ -1531,7 +1537,7 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U cfgLoaded = false val config = db.deviceMeasurementConfigDao().byDevice(id) cfgHrEnabled = config?.hrEnabled ?: true - cfgHrInterval = (config?.hrIntervalMinutes ?: minimumInterval).coerceIn(minimumInterval, 60) + cfgHrInterval = (config?.hrIntervalMinutes ?: minimumInterval).coerceIn(minimumInterval, maximumHrInterval) cfgSpo2 = (config?.spo2Enabled ?: true) && WearableCapability.SPO2 in capabilities cfgSpo2Interval = (config?.spo2IntervalMinutes ?: 0).let { if (it <= 0) 0 else it.coerceIn(minimumInterval, 60) @@ -1540,6 +1546,7 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U cfgHrv = (config?.hrvEnabled ?: true) && WearableCapability.HRV in capabilities cfgTemp = (config?.temperatureEnabled ?: true) && WearableCapability.TEMPERATURE in capabilities cfgAppSpo2 = scheduledSpo2Prefs.enabled + cfgAppSpo2Interval = scheduledSpo2Prefs.intervalMinutes cfgLoaded = true } @@ -1580,8 +1587,8 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U Slider( enabled = cfgLoaded, value = cfgHrInterval.toFloat(), - onValueChange = { cfgHrInterval = ((it / 5).toInt() * 5).coerceIn(minimumInterval, 60); cfgSavedMsg = null }, - valueRange = minimumInterval.toFloat()..60f, + onValueChange = { cfgHrInterval = ((it / 5).toInt() * 5).coerceIn(minimumInterval, maximumHrInterval); cfgSavedMsg = null }, + valueRange = minimumInterval.toFloat()..maximumHrInterval.toFloat(), steps = intervalSteps, ) } @@ -1594,7 +1601,9 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U // running both monitors hourly that is most of the battery (issue #66). if (cfgSpo2 && supportsSpo2Interval) { Text( - if (cfgSpo2Interval <= 0) "Same as heart rate" else "Every $cfgSpo2Interval min", + // The blood-oxygen command keeps the 60-min ceiling whatever the + // heart-rate slider shows (no evidence past 60 on that key). + if (cfgSpo2Interval <= 0) "Same as heart rate, at most every 60 min" else "Every $cfgSpo2Interval min", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1617,10 +1626,28 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U if (cfgSpo2 && supportsScheduledSpo2) { VitalToggle("Measure SpO₂ from the app", cfgAppSpo2) { cfgAppSpo2 = it } Text( - "This ring's all-day SpO₂ is estimated from heart rate. When on, PulseLoop starts a real spot measurement halfway between the ring's readings, about a minute each, which uses more battery. Android may delay or skip runs while the phone is asleep.", + "This ring's all-day SpO₂ is estimated from heart rate. When on, PulseLoop starts a real spot measurement on the schedule below, between the ring's own readings, about a minute each, which uses more battery. Android may delay or skip runs while the phone is asleep.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + // Independent of the ring's SpO₂ interval: the app starts these runs + // itself, so the ring's limits don't apply (issue #103). Each run is + // ~50 s of red LED, which is what 2–3 h spacing is for. + Text( + if (cfgAppSpo2Interval == 60) "Every hour" else "Every ${cfgAppSpo2Interval / 60} hours", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + enabled = cfgLoaded, + value = cfgAppSpo2Interval.toFloat(), + onValueChange = { + cfgAppSpo2Interval = ScheduledSpo2Prefs.sanitizeIntervalMinutes(((it / 60).toInt() * 60).coerceIn(60, 180)) + cfgSavedMsg = null + }, + valueRange = 60f..180f, + steps = 2, + ) } } if (supportsStressSetting) { @@ -1641,7 +1668,7 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U db.deviceMeasurementConfigDao().upsert( com.pulseloop.data.entity.DeviceMeasurementConfigEntity( deviceId = device.id, - hrIntervalMinutes = cfgHrInterval.coerceIn(minimumInterval, 60), + hrIntervalMinutes = cfgHrInterval.coerceIn(minimumInterval, maximumHrInterval), hrEnabled = cfgHrEnabled, spo2Enabled = cfgSpo2 && WearableCapability.SPO2 in capabilities, spo2IntervalMinutes = if (!supportsSpo2Interval || cfgSpo2Interval <= 0) 0 @@ -1654,6 +1681,7 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U ) coordinator?.applyMeasurementSettings() scheduledSpo2Prefs.enabled = cfgAppSpo2 && cfgSpo2 && supportsScheduledSpo2 + scheduledSpo2Prefs.intervalMinutes = cfgAppSpo2Interval ScheduledSpo2Worker.reschedule(context.applicationContext) cfgSavedMsg = if (coordinator?.isConnected == true) "Saved & sent to ring ✓" else "Saved — will sync on next connect" diff --git a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt index a20ea671..c48a557f 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTEncoderTest.kt @@ -63,6 +63,21 @@ class YCBTEncoderTest { assertArrayEquals(byteArrayOf(0x01, 0x45, 0x00, 60, 0, 0, 0), commands[3]) } + @Test + fun `heart rate carries 120 minutes while the other monitors keep the 60 minute ceiling`() { + // 120 is evidenced on the heart-rate key only (issue #103); the other samplers' bytes are + // unchanged from what the UI's 60-min cap always produced. + val settings = MeasurementSettings( + hrEnabled = true, hrIntervalMinutes = 120, + spo2Enabled = true, stressEnabled = true, hrvEnabled = true, temperatureEnabled = true, + ) + val commands = encoder.monitorCommands(settings, allMonitorCapabilities) + assertArrayEquals(byteArrayOf(0x01, 0x0c, 0x01, 120), commands[0]) + assertArrayEquals(byteArrayOf(0x01, 0x20, 0x01, 60), commands[1]) + assertArrayEquals(byteArrayOf(0x01, 0x26, 0x01, 60), commands[2]) + assertArrayEquals(byteArrayOf(0x01, 0x45, 0x01, 60, 0, 0, 0), commands[3]) + } + @Test fun `userInfo carries real profile`() { val profile = UserProfileValues(metric = true, gender = 0x01u, age = 31u, heightCm = 183u, weightKg = 78u) diff --git a/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt b/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt index b5c5060e..e89ac96b 100644 --- a/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt +++ b/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt @@ -82,4 +82,36 @@ class ScheduledSpo2SlotsTest { } } } + + @Test + fun `two hour app interval keeps the half past slot on an hourly ring grid`() { + // The app's own grid starts at :30 (half the ring's interval) and repeats every 2 h. + assertEquals(at(10, 30), ScheduledSpo2Slots.nextSlot(at(10, 0, 42), 120, 60)) + assertEquals(at(12, 30), ScheduledSpo2Slots.nextSlot(at(10, 30), 120, 60)) + assertEquals(at(0, 30, day = 22), ScheduledSpo2Slots.nextSlot(at(22, 31), 120, 60)) + } + + @Test + fun `three hour app interval slots every third hour`() { + assertEquals(at(12, 30), ScheduledSpo2Slots.nextSlot(at(10, 0, 42), 180, 60)) + // 00:30 + 8 × 3 h wraps exactly onto 00:30, so the offset survives the rollover. + assertEquals(at(0, 30, day = 22), ScheduledSpo2Slots.nextSlot(at(23, 45), 180, 60)) + } + + @Test + fun `every 2 and 3 hour slot stays clear of an hourly ring grid`() { + for (appInterval in listOf(120, 180)) { + var t = at(0, 0) + repeat(40) { + t = ScheduledSpo2Slots.nextSlot(t, appInterval, 60) + assertTrue("app $appInterval slot $t", ScheduledSpo2Slots.isClear(t, 60)) + } + } + } + + @Test + fun `half hour ring grid puts the two hour slots at quarter past`() { + assertEquals(at(10, 15), ScheduledSpo2Slots.nextSlot(at(10, 5), 120, 30)) + assertTrue(ScheduledSpo2Slots.isClear(at(10, 15), 30)) + } } diff --git a/app/src/test/java/com/pulseloop/settings/ScheduledSpo2PrefsTest.kt b/app/src/test/java/com/pulseloop/settings/ScheduledSpo2PrefsTest.kt new file mode 100644 index 00000000..7a40567b --- /dev/null +++ b/app/src/test/java/com/pulseloop/settings/ScheduledSpo2PrefsTest.kt @@ -0,0 +1,30 @@ +package com.pulseloop.settings + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The interval picker behind issue #103's request to space the app's own spot SpO₂ past an hour. + * Pure arithmetic — the SharedPreferences round-trip needs a device. + */ +class ScheduledSpo2PrefsTest { + + @Test + fun `the offered options pass through unchanged`() { + for (minutes in listOf(60, 120, 180)) { + assertEquals(minutes, ScheduledSpo2Prefs.sanitizeIntervalMinutes(minutes)) + } + } + + @Test + fun `anything else snaps to the nearest option and stays inside the range`() { + assertEquals(60, ScheduledSpo2Prefs.sanitizeIntervalMinutes(0)) + assertEquals(60, ScheduledSpo2Prefs.sanitizeIntervalMinutes(59)) + assertEquals(60, ScheduledSpo2Prefs.sanitizeIntervalMinutes(89)) + assertEquals(120, ScheduledSpo2Prefs.sanitizeIntervalMinutes(90)) + assertEquals(180, ScheduledSpo2Prefs.sanitizeIntervalMinutes(150)) + assertEquals(180, ScheduledSpo2Prefs.sanitizeIntervalMinutes(151)) + assertEquals(180, ScheduledSpo2Prefs.sanitizeIntervalMinutes(300)) + assertEquals(60, ScheduledSpo2Prefs.sanitizeIntervalMinutes(-30)) + } +} From 856b734f6d8fc476010442642b2e23fde0156889 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Fri, 23 Oct 2026 04:19:10 -0700 Subject: [PATCH 4/7] fix(spo2): offer both #103 controls to COLMI_SMART_HEALTH rings too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Albabit's ring (issue #103, 2026-10-23) is catalogued COLMI_SMART_HEALTH (model colmi-smarthealth) — same YCBT driver, but its own RingDeviceType, so the == YCBT checks hid the scheduled spot SpO2 toggle and capped the HR slider at 5-60. Gate the scheduled-SpO2 support check and the raised measurement bounds (min 30, HR max 120) on both YCBT-protocol families; same for the stress-toggle exclusion, since YCBTEncoder.monitorCommands carries no stress key for either family. Manual-SpO2 capability still required, and the worker path shares the one gate. --- .../pulseloop/settings/ScheduledSpo2Prefs.kt | 13 ++++--- .../ui/screens/SettingsSubScreens.kt | 18 +++++++--- .../settings/ScheduledSpo2PrefsTest.kt | 34 +++++++++++++++++++ 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt b/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt index 74f03702..2106790f 100644 --- a/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt +++ b/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt @@ -51,11 +51,16 @@ class ScheduledSpo2Prefs(context: Context) { /** * Rings this is offered for: a manual SpO₂ that ends with the ring's own `04 0e` verdict, - * so the run has a known end and the connection can be released right after it. Only the - * YCBT family signals that today (issue #59). + * so the run has a known end and the connection can be released right after it. The YCBT + * protocol families signal that today (issue #59) — `COLMI_SMART_HEALTH` included, which + * rides the same [com.pulseloop.ring.YCBTDriver] but is its own `RingDeviceType`, so the + * original `== YCBT` check hid both #103 controls from those rings (issue #103, + * Albabit's `colmi-smarthealth` unit, 2026-10-23). */ fun isSupported(device: DeviceEntity?): Boolean = - device != null && device.deviceType == RingDeviceType.YCBT && - WearableCapability.MANUAL_SPO2 in device.capabilities + device != null && device.deviceType in setOf( + RingDeviceType.YCBT, + RingDeviceType.COLMI_SMART_HEALTH, + ) && WearableCapability.MANUAL_SPO2 in device.capabilities } } diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt index 4c3d08e5..bbc09dcd 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt @@ -1499,12 +1499,16 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U val scope = rememberCoroutineScope() val device = db.deviceDao().currentFlow().collectAsState(initial = null).value val capabilities = device?.capabilities.orEmpty() - val minimumInterval = if (device?.deviceType == RingDeviceType.YCBT) 30 else 5 + // Both YCBT-protocol families get the raised bounds: `COLMI_SMART_HEALTH` rides the same + // [com.pulseloop.ring.YCBTDriver], so its ring can carry the same settings (issue #103, + // Albabit's `colmi-smarthealth` unit was capped at 5–60 until 2026-10-23). + val isYcbtProtocol = device?.deviceType in setOf(RingDeviceType.YCBT, RingDeviceType.COLMI_SMART_HEALTH) + val minimumInterval = if (isYcbtProtocol) 30 else 5 // 120 min is the only interval beyond the vendor app's own menu (which stops at 60) that is // tested on hardware: the ring ACKed `01 0c 01 78` and read at 10:00 and 12:00 (issue #103, - // 2026-10-22). Nothing longer until someone tries it, and YCBT only — it is not the vendor - // app's behaviour, it is one ring's. - val maximumHrInterval = if (device?.deviceType == RingDeviceType.YCBT) 120 else 60 + // 2026-10-22). Nothing longer until someone tries it, and YCBT-protocol rings only — it is + // not the vendor app's behaviour, it is one ring's. + val maximumHrInterval = if (isYcbtProtocol) 120 else 60 val intervalSteps = ((maximumHrInterval - minimumInterval) / 5 - 1).coerceAtLeast(0) // Only offered where the ring's protocol actually carries a separate blood-oxygen interval — // elsewhere the slider moved, the value saved, and "Saved & sent to ring ✓" appeared over a @@ -1514,8 +1518,12 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U // so it needs its own step count: reusing the HR one put ticks 5.45 min apart, none of which // the 5-minute snap could land on. val spo2IntervalSteps = ((60 - (minimumInterval - 5)) / 5 - 1).coerceAtLeast(0) + // Same exclusion as [RingDeviceType.YCBT]: the YCBT wire format has no stress-monitor + // setting ([com.pulseloop.ring.YCBTEncoder.monitorCommands] carries HR/BP/temp/SpO₂/HRV + // only), so a toggle here would be a no-op the ring never sees — the capability bit alone + // is not enough to offer it. val supportsStressSetting = WearableCapability.STRESS in capabilities && - device?.deviceType != RingDeviceType.YCBT + !isYcbtProtocol var cfgHrEnabled by remember { mutableStateOf(true) } var cfgHrInterval by remember { mutableStateOf(5) } diff --git a/app/src/test/java/com/pulseloop/settings/ScheduledSpo2PrefsTest.kt b/app/src/test/java/com/pulseloop/settings/ScheduledSpo2PrefsTest.kt index 7a40567b..7bcb9261 100644 --- a/app/src/test/java/com/pulseloop/settings/ScheduledSpo2PrefsTest.kt +++ b/app/src/test/java/com/pulseloop/settings/ScheduledSpo2PrefsTest.kt @@ -1,6 +1,11 @@ package com.pulseloop.settings +import com.pulseloop.data.entity.DeviceEntity +import com.pulseloop.ring.RingDeviceType +import com.pulseloop.ring.WearableCapability import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -27,4 +32,33 @@ class ScheduledSpo2PrefsTest { assertEquals(180, ScheduledSpo2Prefs.sanitizeIntervalMinutes(300)) assertEquals(60, ScheduledSpo2Prefs.sanitizeIntervalMinutes(-30)) } + + /** Albabit's ring (issue #103, 2026-10-23): `colmi-smarthealth`, SmartHealth/YCBT protocol, + * `manualSpo2` in its capabilities — both #103 controls must reach it, not just `YCBT`. */ + @Test + fun `COLMI_SMART_HEALTH with MANUAL_SPO2 is supported`() { + val device = DeviceEntity( + deviceTypeRaw = RingDeviceType.COLMI_SMART_HEALTH.name, + capabilitiesRaw = WearableCapability.MANUAL_SPO2.key, + ) + assertTrue(ScheduledSpo2Prefs.isSupported(device)) + } + + @Test + fun `YCBT with MANUAL_SPO2 is still supported`() { + val device = DeviceEntity( + deviceTypeRaw = RingDeviceType.YCBT.name, + capabilitiesRaw = WearableCapability.MANUAL_SPO2.key, + ) + assertTrue(ScheduledSpo2Prefs.isSupported(device)) + } + + @Test + fun `the MANUAL_SPO2 capability is still required`() { + val device = DeviceEntity( + deviceTypeRaw = RingDeviceType.COLMI_SMART_HEALTH.name, + capabilitiesRaw = WearableCapability.SPO2.key, + ) + assertFalse(ScheduledSpo2Prefs.isSupported(device)) + } } From b0795c3239b4181ae96eaaa5c69a7355abf52972 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Fri, 23 Oct 2026 23:43:58 -0700 Subject: [PATCH 5/7] fix(spo2): resolve slider stops, sync contention, slot alignment, and estimated filtering (#103) - Fix app SpO2 slider stops with steps=1 (60, 120, 180 min). - Retry GATT connection in ScheduledSpo2Worker to wait out background sync. - Base slot scheduling and isClear on HR interval for YCBT rings. - Allow scheduling app spot SpO2 even if ring SpO2 toggle is disabled. - Add toggle to hide estimated SpO2 from vitals card and detail chart. - Display Estimated tag on individual reading rows in detail screen. - Exclude estimated SpO2 and derived respiratory rate from Health Connect export. --- .../health/exporters/VitalsExporter.kt | 17 +++- .../pulseloop/service/ScheduledSpo2Slots.kt | 6 +- .../pulseloop/service/ScheduledSpo2Worker.kt | 35 +++++-- .../pulseloop/settings/ScheduledSpo2Prefs.kt | 9 ++ .../java/com/pulseloop/ui/PulseLoopApp.kt | 2 +- .../java/com/pulseloop/ui/screens/Screens.kt | 14 ++- .../ui/screens/SettingsSubScreens.kt | 95 +++++++++++++------ .../com/pulseloop/ui/viewmodels/ViewModels.kt | 46 ++++++--- .../service/ScheduledSpo2SlotsTest.kt | 19 ++++ 9 files changed, 188 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/pulseloop/health/exporters/VitalsExporter.kt b/app/src/main/java/com/pulseloop/health/exporters/VitalsExporter.kt index 0c5a887d..f647affb 100644 --- a/app/src/main/java/com/pulseloop/health/exporters/VitalsExporter.kt +++ b/app/src/main/java/com/pulseloop/health/exporters/VitalsExporter.kt @@ -227,11 +227,24 @@ class VitalsExporter(private val db: PulseLoopDatabase) { val highWaters = mutableListOf() var droppedHigh: Long? = selected.filter { it.sourceRaw in EXCLUDED_SOURCES } .maxOfOrNull { it.createdAt } + + val estimatedSpo2 = if (kindKey == "spo2" && rows.isNotEmpty()) { + val minTs = rows.minOf { it.timestamp } + val maxTs = rows.maxOf { it.timestamp } + com.pulseloop.service.EstimatedSpo2.estimatedTimestamps(db.measurementDao(), minTs, maxTs) + } else emptySet() + + val currentRealDevice = if (kindKey == "resp_rate") db.deviceDao().currentReal() else null + val isYcbt = currentRealDevice?.deviceType in setOf( + com.pulseloop.ring.RingDeviceType.YCBT, + com.pulseloop.ring.RingDeviceType.COLMI_SMART_HEALTH, + ) + for (row in rows) { val instant = Instant.ofEpochMilli(row.timestamp) val offset = HealthConnectTypeMappings.zoneOffsetAt(instant, zone) val record: Record? = when (kindKey) { - "spo2" -> if (HealthConnectTypeMappings.isPlausibleSpO2(row.value)) { + "spo2" -> if (HealthConnectTypeMappings.isPlausibleSpO2(row.value) && row.timestamp !in estimatedSpo2) { OxygenSaturationRecord(instant, offset, Percentage(row.value), metadata(row, "spo2", device)) } else null "hrv" -> if (HealthConnectTypeMappings.isPlausibleHrvRmssd(row.value)) { @@ -254,7 +267,7 @@ class VitalsExporter(private val db: PulseLoopDatabase) { metadata = metadata(row, "glucose", device), ) } else null - "resp_rate" -> if (HealthConnectTypeMappings.isPlausibleRespRate(row.value)) { + "resp_rate" -> if (!isYcbt && HealthConnectTypeMappings.isPlausibleRespRate(row.value)) { RespiratoryRateRecord( time = instant, zoneOffset = offset, diff --git a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt index 6bccefc4..e7a3e3e6 100644 --- a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt +++ b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt @@ -42,7 +42,11 @@ internal object ScheduledSpo2Slots { ringIntervalMinutes: Int = intervalMinutes, ): ZonedDateTime { val interval = intervalMinutes * 60 - val half = ringIntervalMinutes * 60 / 2 + val half = when { + ringIntervalMinutes == 120 && intervalMinutes == 120 -> 60 * 60 + ringIntervalMinutes >= 60 -> 30 * 60 + else -> ringIntervalMinutes * 60 / 2 + } val secs = now.toLocalTime().toSecondOfDay() val k = if (secs < half) 0 else (secs - half) / interval + 1 val slot = half + k * interval diff --git a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt index 202f00e0..a37148a8 100644 --- a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt +++ b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt @@ -47,7 +47,7 @@ class ScheduledSpo2Worker( companion object { private const val WORK_NAME = "scheduled_spot_spo2" private const val TAG = "ScheduledSpo2Worker" - private const val CONNECT_TIMEOUT_MS = 20_000L + private const val CONNECT_TIMEOUT_MS = 45_000L /** Startup history has to drain before the sensor is taken — the ring serves one thing at a time. */ private const val SYNC_DRAIN_TIMEOUT_MS = 45_000L @@ -82,16 +82,26 @@ class ScheduledSpo2Worker( } /** - * The SpO₂ interval the ring is set to, or null when its SpO₂ monitor is off or unset. Never - * above 60: the heart-rate interval can now go to 120 (issue #103), but the blood-oxygen - * command keeps the old ceiling — 120 min is only evidenced on the heart-rate key — so the - * ring's SpO₂ grid, which the app's slots live between, is too. - */ + * The interval the ring measures on, to keep the app's spot SpO₂ clear of. + * On YCBT rings (including Colmi SmartHealth), background measurements follow the + * heart-rate schedule (the ring derives SpO₂ during HR measurements and ignores 0x26). + * Even if the user turns off the ring's SpO₂ toggle, the ring still measures on the HR + * interval. Supports HR intervals up to 120 min (issue #103). + */ private suspend fun intervalMinutes(db: PulseLoopDatabase): Int? { val settings = loadPersistedMeasurementSettings(db) ?: return null - if (!settings.spo2Enabled) return null - val minutes = settings.spo2IntervalMinutes.takeIf { it > 0 } ?: settings.hrIntervalMinutes - return minutes.coerceIn(5, 60) + val device = db.deviceDao().currentReal() + val isYcbt = device?.deviceType in setOf( + com.pulseloop.ring.RingDeviceType.YCBT, + com.pulseloop.ring.RingDeviceType.COLMI_SMART_HEALTH, + ) + val minutes = if (isYcbt) { + settings.hrIntervalMinutes.takeIf { it > 0 } ?: 60 + } else { + if (!settings.spo2Enabled) return null + settings.spo2IntervalMinutes.takeIf { it > 0 } ?: settings.hrIntervalMinutes + } + return minutes.coerceIn(5, 120) } } @@ -141,7 +151,12 @@ class ScheduledSpo2Worker( client.connectLastKnown() val connected = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { - while (!coordinator.isConnected) delay(500) + while (!coordinator.isConnected) { + if (client.state.value.connectionState == RingConnectionState.DISCONNECTED) { + client.connectLastKnown() + } + delay(1000) + } true } ?: false if (!connected) return diff --git a/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt b/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt index 2106790f..272e71d3 100644 --- a/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt +++ b/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt @@ -34,10 +34,19 @@ class ScheduledSpo2Prefs(context: Context) { get() = prefs.getInt(KEY_INTERVAL_MINUTES, DEFAULT_INTERVAL_MINUTES) set(value) { prefs.edit().putInt(KEY_INTERVAL_MINUTES, sanitizeIntervalMinutes(value)).apply() } + /** + * Whether to hide estimated hourly SpO₂ (computed by the ring from heart rate) and only + * display real spot measurements. Defaults to true. + */ + var hideEstimated: Boolean + get() = prefs.getBoolean(KEY_HIDE_ESTIMATED, true) + set(value) { prefs.edit().putBoolean(KEY_HIDE_ESTIMATED, value).apply() } + companion object { private const val PREFS_NAME = "scheduled_spo2" private const val KEY_ENABLED = "enabled" private const val KEY_INTERVAL_MINUTES = "interval_minutes" + private const val KEY_HIDE_ESTIMATED = "hide_estimated" /** Options are 60/120/180; 60 is what the app ran at before a choice existed. */ const val DEFAULT_INTERVAL_MINUTES = 60 diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index 34548602..acddf1c8 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -127,7 +127,7 @@ fun PulseLoopApp() { // ── ViewModels ─────────────────────────────────────────────────── val todayVM = remember { TodayViewModel(db, apiKeyStore) } - val vitalsVM = remember { VitalsViewModel(db, apiKeyStore) } + 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) }) diff --git a/app/src/main/java/com/pulseloop/ui/screens/Screens.kt b/app/src/main/java/com/pulseloop/ui/screens/Screens.kt index 3c02f95b..8a2e223d 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/Screens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/Screens.kt @@ -647,7 +647,8 @@ fun VitalDetailScreen( // Glucose display unit for the detail chart's zones/axis/stats (iOS #43 §3); the VM already // converts the plotted readings, this converts the reference layer the screen owns. val gUnit = apiKeyStore?.preferredGlucoseUnit ?: com.pulseloop.service.GlucoseUnit.MGDL - val vm = remember { VitalDetailViewModel(db, metric, apiKeyStore, units) } + val scheduledSpo2Prefs = remember { com.pulseloop.settings.ScheduledSpo2Prefs(context) } + val vm = remember { VitalDetailViewModel(db, metric, apiKeyStore, units, scheduledSpo2Prefs) } val state by vm.state.collectAsState() val title = metricDisplayName(metric) @@ -1008,6 +1009,17 @@ private fun ReadingsCard( color = PulseColors.textMuted, ) } + if (reading.isEstimated) { + Spacer(Modifier.width(4.dp)) + Text( + "Estimated", + fontSize = 10.sp, + color = PulseColors.warning, + modifier = Modifier + .background(PulseColors.warning.copy(alpha = 0.15f), androidx.compose.foundation.shape.RoundedCornerShape(4.dp)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) + } IconButton( onClick = { pendingDelete = reading }, modifier = Modifier.size(36.dp), diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt index bbc09dcd..7e788888 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt @@ -50,6 +50,8 @@ import com.pulseloop.coach.config.MiniMaxModel import com.pulseloop.coach.config.OpenRouterModel import com.pulseloop.data.DemoDataSeeder import com.pulseloop.data.PulseLoopDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import com.pulseloop.data.entity.UserGoalEntity import com.pulseloop.health.HealthConnectAvailability import com.pulseloop.health.HealthConnectPermissions @@ -1537,6 +1539,7 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U val supportsScheduledSpo2 = ScheduledSpo2Prefs.isSupported(device) var cfgAppSpo2 by remember { mutableStateOf(false) } var cfgAppSpo2Interval by remember { mutableStateOf(ScheduledSpo2Prefs.DEFAULT_INTERVAL_MINUTES) } + var cfgHideEstimatedSpo2 by remember { mutableStateOf(true) } var cfgLoaded by remember { mutableStateOf(false) } var cfgSavedMsg by remember { mutableStateOf(null) } @@ -1555,6 +1558,7 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U cfgTemp = (config?.temperatureEnabled ?: true) && WearableCapability.TEMPERATURE in capabilities cfgAppSpo2 = scheduledSpo2Prefs.enabled cfgAppSpo2Interval = scheduledSpo2Prefs.intervalMinutes + cfgHideEstimatedSpo2 = scheduledSpo2Prefs.hideEstimated cfgLoaded = true } @@ -1630,32 +1634,46 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U ) } // Issue #103: this ring's own SpO₂ is computed from heart rate, so the app - // can run the ring's real spot measurement on the interval instead. - if (cfgSpo2 && supportsScheduledSpo2) { - VitalToggle("Measure SpO₂ from the app", cfgAppSpo2) { cfgAppSpo2 = it } - Text( - "This ring's all-day SpO₂ is estimated from heart rate. When on, PulseLoop starts a real spot measurement on the schedule below, between the ring's own readings, about a minute each, which uses more battery. Android may delay or skip runs while the phone is asleep.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - // Independent of the ring's SpO₂ interval: the app starts these runs - // itself, so the ring's limits don't apply (issue #103). Each run is - // ~50 s of red LED, which is what 2–3 h spacing is for. - Text( - if (cfgAppSpo2Interval == 60) "Every hour" else "Every ${cfgAppSpo2Interval / 60} hours", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Slider( - enabled = cfgLoaded, - value = cfgAppSpo2Interval.toFloat(), - onValueChange = { - cfgAppSpo2Interval = ScheduledSpo2Prefs.sanitizeIntervalMinutes(((it / 60).toInt() * 60).coerceIn(60, 180)) + // can run the ring's real spot measurement on the schedule instead. + if (supportsScheduledSpo2) { + VitalToggle("Measure SpO₂ from the app", cfgAppSpo2) { + cfgAppSpo2 = it + cfgSavedMsg = null + } + if (cfgAppSpo2) { + Text( + "This ring's all-day SpO₂ is estimated from heart rate. When on, PulseLoop starts a real spot measurement on the schedule below, between the ring's own readings, about a minute each, which uses more battery. Android may delay or skip runs while the phone is asleep.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // Independent of the ring's SpO₂ interval: the app starts these runs + // itself, so the ring's limits don't apply (issue #103). Each run is + // ~50 s of red LED, which is what 2–3 h spacing is for. + Text( + if (cfgAppSpo2Interval == 60) "Every hour" else "Every ${cfgAppSpo2Interval / 60} hours", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + enabled = cfgLoaded, + value = cfgAppSpo2Interval.toFloat(), + onValueChange = { + cfgAppSpo2Interval = ScheduledSpo2Prefs.sanitizeIntervalMinutes(kotlin.math.round(it / 60f).toInt() * 60) + cfgSavedMsg = null + }, + valueRange = 60f..180f, + steps = 1, + ) + VitalToggle("Hide estimated SpO₂", cfgHideEstimatedSpo2) { + cfgHideEstimatedSpo2 = it cfgSavedMsg = null - }, - valueRange = 60f..180f, - steps = 2, - ) + } + Text( + "Display only real spot measurements on charts and vitals cards, hiding the hourly estimates.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } if (supportsStressSetting) { @@ -1688,8 +1706,9 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U ) ) coordinator?.applyMeasurementSettings() - scheduledSpo2Prefs.enabled = cfgAppSpo2 && cfgSpo2 && supportsScheduledSpo2 + scheduledSpo2Prefs.enabled = cfgAppSpo2 && supportsScheduledSpo2 scheduledSpo2Prefs.intervalMinutes = cfgAppSpo2Interval + scheduledSpo2Prefs.hideEstimated = cfgHideEstimatedSpo2 ScheduledSpo2Worker.reschedule(context.applicationContext) cfgSavedMsg = if (coordinator?.isConnected == true) "Saved & sent to ring ✓" else "Saved — will sync on next connect" @@ -3157,6 +3176,17 @@ fun HealthConnectSettingsScreen(onBack: () -> Unit) { } } + var currentRealDevice by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + currentRealDevice = withContext(Dispatchers.IO) { + PulseLoopDatabase.getInstance(context).deviceDao().currentReal() + } + } + val isYcbtDevice = currentRealDevice?.deviceType in setOf( + com.pulseloop.ring.RingDeviceType.YCBT, + com.pulseloop.ring.RingDeviceType.COLMI_SMART_HEALTH, + ) + SettingsSubScreen(title = "Health Connect", onBack = onBack) { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { @@ -3265,15 +3295,22 @@ fun HealthConnectSettingsScreen(onBack: () -> Unit) { HealthConnectPermissions.DataTypeRow.RESTING_HEART_RATE to "Resting heart rate", ) for ((row, label) in rows) { + val isRespRateOnYcbt = row == HealthConnectPermissions.DataTypeRow.RESPIRATORY_RATE && isYcbtDevice val rowPerms = HealthConnectPermissions.permissionsForRow(row) - val rowGranted = rowPerms.isEmpty() || rowPerms.any { it in granted } + val rowGranted = !isRespRateOnYcbt && (rowPerms.isEmpty() || rowPerms.any { it in granted }) Row( Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { Text(label, style = MaterialTheme.typography.bodyMedium) - if (!rowGranted) { + if (isRespRateOnYcbt) { + Text( + "Derived from heart rate by current ring — export disabled", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (!rowGranted) { Text( "Awaiting permission", style = MaterialTheme.typography.bodySmall, @@ -3282,7 +3319,7 @@ fun HealthConnectSettingsScreen(onBack: () -> Unit) { } } Switch( - checked = prefs.toggleFor(row), + checked = if (isRespRateOnYcbt) false else prefs.toggleFor(row), enabled = rowGranted, onCheckedChange = { on -> store.update { p -> p.withToggleFor(row, on) } 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 e8a79943..95ecae28 100644 --- a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt @@ -611,7 +611,11 @@ class ActivityViewModel(db: PulseLoopDatabase) : ViewModel() { * Uses reactive polling so data appears as soon as the ring syncs. */ @OptIn(kotlinx.coroutines.FlowPreview::class) -class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore? = null) : ViewModel() { +class VitalsViewModel( + private val db: PulseLoopDatabase, + private val apiKeyStore: ApiKeyStore? = null, + private val scheduledSpo2Prefs: com.pulseloop.settings.ScheduledSpo2Prefs? = null, +) : ViewModel() { data class VitalsState( val hrSamples: List = emptyList(), val spo2Samples: List = emptyList(), @@ -694,7 +698,7 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore } private suspend fun refresh(db: PulseLoopDatabase) { - _state.value = buildState(db, apiKeyStore) + _state.value = buildState(db, apiKeyStore, scheduledSpo2Prefs) } companion object { @@ -705,7 +709,11 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore * ([com.pulseloop.widgets.WidgetSnapshotPublisher]) — a widget tile is built from exactly the * same series fetches, offsets, and capability gating as its Vitals/Today card. */ - suspend fun buildState(db: PulseLoopDatabase, apiKeyStore: ApiKeyStore? = null): VitalsState { + suspend fun buildState( + db: PulseLoopDatabase, + apiKeyStore: ApiKeyStore? = null, + scheduledSpo2Prefs: com.pulseloop.settings.ScheduledSpo2Prefs? = null, + ): VitalsState { val now = System.currentTimeMillis() val twentyFourHoursAgo = now - 24 * 3600_000L val device = db.deviceDao().current() @@ -772,10 +780,17 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore val spo2Estimated = if (spo2.isEmpty()) emptySet() else EstimatedSpo2.estimatedTimestamps(db.measurementDao(), twentyFourHoursAgo, now, now) + val hideEstimated = scheduledSpo2Prefs?.hideEstimated ?: false + val displayedSpo2 = if (hideEstimated && spo2Estimated.isNotEmpty()) { + spo2.filterNot { it.timestamp in spo2Estimated } + } else { + spo2 + } + return VitalsState( hrSamples = hr.map { it.value }, - spo2Samples = spo2.map { it.value }, - spo2IsEstimated = spo2.any { it.timestamp in spo2Estimated }, + spo2Samples = displayedSpo2.map { it.value }, + spo2IsEstimated = !hideEstimated && displayedSpo2.any { it.timestamp in spo2Estimated }, hrvSamples = hrv.map { it.value }, stressSamples = stress.map { it.value }.ifEmpty { derivedStress }, stressIsDerived = stress.isEmpty() && derivedStress.isNotEmpty(), @@ -783,7 +798,7 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore tempSamples = temp.map { it.value }, latestHr = hr.lastOrNull()?.value?.toInt(), restingHr = HeartRateZones.restingHeartRate(hr.map { it.value }), - latestSpo2 = spo2.lastOrNull()?.value?.toInt(), + latestSpo2 = displayedSpo2.lastOrNull()?.value?.toInt(), latestHrv = hrv.lastOrNull()?.value, latestStress = stress.lastOrNull()?.value ?: derivedStress.lastOrNull(), latestFatigue = fatigue.lastOrNull()?.value, @@ -1163,6 +1178,7 @@ class VitalDetailViewModel( private val metric: String, private val apiKeyStore: ApiKeyStore? = null, private val unitSystem: UnitSystem = UnitSystem.METRIC, + private val scheduledSpo2Prefs: com.pulseloop.settings.ScheduledSpo2Prefs? = null, ) : ViewModel() { enum class Trend { UP, DOWN, FLAT } @@ -1216,6 +1232,8 @@ class VitalDetailViewModel( /** True when the ring supplied this from its own log, so a re-sync could restore it — * which is why deleting it also writes a tombstone. Shown as provenance in the list. */ val fromHistory: Boolean = false, + /** True when this hourly SpO₂ reading was computed from heart rate rather than measured. */ + val isEstimated: Boolean = false, ) private val _state = MutableStateFlow(DetailState()) @@ -1407,15 +1425,20 @@ class VitalDetailViewModel( // chart carries the same disclaimer. Only hourly rows count; spot readings never do. val estimatedSpo2 = if (kind == MeasurementKind.SPO2 && samples.isNotEmpty()) EstimatedSpo2.estimatedTimestamps(dao, windowStart, windowEnd) else emptySet() - val times = if (derived.isNotEmpty()) derived.map { it.timestampMs } else samples.map { it.timestamp } - val points = if (derived.isNotEmpty()) derived.map { it.value } else samples.map { convert(it.value) } + val hideEstimated = kind == MeasurementKind.SPO2 && scheduledSpo2Prefs?.hideEstimated == true + val effectiveSamples = if (hideEstimated && estimatedSpo2.isNotEmpty()) + samples.filterNot { it.timestamp in estimatedSpo2 } else samples + val times = if (derived.isNotEmpty()) derived.map { it.timestampMs } else effectiveSamples.map { it.timestamp } + val points = if (derived.isNotEmpty()) derived.map { it.value } else effectiveSamples.map { convert(it.value) } val labels = buildLabels(times, period) val prevSamples = dao.range(kindName, prevStart, prevEnd) + val prevEffective = if (hideEstimated && estimatedSpo2.isNotEmpty()) + prevSamples.filterNot { it.timestamp in estimatedSpo2 } else prevSamples val prevDerived = if (derived.isNotEmpty()) derivedStressIn(prevStart, prevEnd) else emptyList() val prevAvg = when { prevDerived.isNotEmpty() -> prevDerived.map { it.value }.average() - prevSamples.isNotEmpty() -> convert(prevSamples.map { it.value }.average()) + prevEffective.isNotEmpty() -> convert(prevEffective.map { it.value }.average()) else -> null } val thisAvg = if (points.isNotEmpty()) points.average() else null @@ -1448,8 +1471,9 @@ class VitalDetailViewModel( timestamp = it.timestamp, value = convert(it.value), fromHistory = it.id.startsWith(MeasurementDeletionDao.HISTORY_ID_PREFIX), + isEstimated = it.timestamp in estimatedSpo2, ) - }.asReversed() + }.let { if (hideEstimated) it.filterNot { r -> r.isEstimated } else it }.asReversed() _state.update { it.copy( anchor = anchor, @@ -1463,7 +1487,7 @@ class VitalDetailViewModel( resting = resting, trend = trend, engineZones = engineZones, - isDerived = derived.isNotEmpty() || samples.any { it.timestamp in estimatedSpo2 }, + isDerived = derived.isNotEmpty() || (!hideEstimated && samples.any { it.timestamp in estimatedSpo2 }), loading = false, ) } } diff --git a/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt b/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt index e89ac96b..f561f1e9 100644 --- a/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt +++ b/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt @@ -114,4 +114,23 @@ class ScheduledSpo2SlotsTest { assertEquals(at(10, 15), ScheduledSpo2Slots.nextSlot(at(10, 5), 120, 30)) assertTrue(ScheduledSpo2Slots.isClear(at(10, 15), 30)) } + + @Test + fun `two hour ring grid with two hour app interval lands at odd hours`() { + assertEquals(at(11, 0), ScheduledSpo2Slots.nextSlot(at(10, 5), 120, 120)) + assertEquals(at(13, 0), ScheduledSpo2Slots.nextSlot(at(11, 0), 120, 120)) + assertTrue(ScheduledSpo2Slots.isClear(at(11, 0), 120)) + // Late run at 22:25 (delayed from 22:00) is clear of 00:00 ring reading + assertTrue(ScheduledSpo2Slots.isClear(at(22, 25), 120)) + // 23:58 is 2 min before midnight reading => not clear + assertFalse(ScheduledSpo2Slots.isClear(at(23, 58), 120)) + } + + @Test + fun `two hour ring grid with hourly app interval lands at half past`() { + assertEquals(at(10, 30), ScheduledSpo2Slots.nextSlot(at(10, 5), 60, 120)) + assertEquals(at(11, 30), ScheduledSpo2Slots.nextSlot(at(10, 30), 60, 120)) + assertTrue(ScheduledSpo2Slots.isClear(at(10, 30), 120)) + assertTrue(ScheduledSpo2Slots.isClear(at(11, 30), 120)) + } } From 4f7c380681da2db6bd12566b446e49c1ff7aaf22 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Fri, 23 Oct 2026 23:49:25 -0700 Subject: [PATCH 6/7] test(spo2): verify interval calculation, slot clearing, HC filtering, and prefs (#103) - Verify computeRingIntervalMinutes uses HR interval on YCBT even when SpO2 is off. - Verify 180 min app SpO2 on 120 min ring grid stays clear over repeated cycles. - Verify VitalsExporter excludes estimated SpO2 and YCBT respiratory rate. - Verify ScheduledSpo2Prefs hideEstimated and interval settings round-trip. --- .../health/exporters/VitalsExporter.kt | 14 +++- .../pulseloop/service/ScheduledSpo2Worker.kt | 28 ++++++-- .../pulseloop/settings/ScheduledSpo2Prefs.kt | 4 +- .../health/VitalsExporterFilteringTest.kt | 50 ++++++++++++++ .../service/ScheduledSpo2SlotsTest.kt | 10 +++ .../service/ScheduledSpo2WorkerTest.kt | 68 +++++++++++++++++++ .../settings/ScheduledSpo2PrefsTest.kt | 52 +++++++++++++- 7 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/com/pulseloop/health/VitalsExporterFilteringTest.kt create mode 100644 app/src/test/java/com/pulseloop/service/ScheduledSpo2WorkerTest.kt diff --git a/app/src/main/java/com/pulseloop/health/exporters/VitalsExporter.kt b/app/src/main/java/com/pulseloop/health/exporters/VitalsExporter.kt index f647affb..50b065cc 100644 --- a/app/src/main/java/com/pulseloop/health/exporters/VitalsExporter.kt +++ b/app/src/main/java/com/pulseloop/health/exporters/VitalsExporter.kt @@ -44,6 +44,16 @@ import java.time.ZoneId */ class VitalsExporter(private val db: PulseLoopDatabase) { + companion object { + /** Issue #103: only export genuine spot/measured SpO2; exclude hourly estimates derived from HR. */ + fun shouldExportSpo2(value: Double, timestamp: Long, estimatedTimestamps: Set): Boolean = + HealthConnectTypeMappings.isPlausibleSpO2(value) && timestamp !in estimatedTimestamps + + /** Issue #103: do not export respiratory rate for rings that derive it from heart rate. */ + fun shouldExportRespRate(value: Double, isYcbt: Boolean): Boolean = + !isYcbt && HealthConnectTypeMappings.isPlausibleRespRate(value) + } + /** * kindKey (the id token, e.g. "hr") -> the `measurements.kindRaw` column value to query. * Every write path persists kindRaw = MeasurementKind..name (EventPersistenceSubscriber, @@ -244,7 +254,7 @@ class VitalsExporter(private val db: PulseLoopDatabase) { val instant = Instant.ofEpochMilli(row.timestamp) val offset = HealthConnectTypeMappings.zoneOffsetAt(instant, zone) val record: Record? = when (kindKey) { - "spo2" -> if (HealthConnectTypeMappings.isPlausibleSpO2(row.value) && row.timestamp !in estimatedSpo2) { + "spo2" -> if (shouldExportSpo2(row.value, row.timestamp, estimatedSpo2)) { OxygenSaturationRecord(instant, offset, Percentage(row.value), metadata(row, "spo2", device)) } else null "hrv" -> if (HealthConnectTypeMappings.isPlausibleHrvRmssd(row.value)) { @@ -267,7 +277,7 @@ class VitalsExporter(private val db: PulseLoopDatabase) { metadata = metadata(row, "glucose", device), ) } else null - "resp_rate" -> if (!isYcbt && HealthConnectTypeMappings.isPlausibleRespRate(row.value)) { + "resp_rate" -> if (shouldExportRespRate(row.value, isYcbt)) { RespiratoryRateRecord( time = instant, zoneOffset = offset, diff --git a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt index a37148a8..9beda480 100644 --- a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt +++ b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt @@ -88,6 +88,21 @@ class ScheduledSpo2Worker( * Even if the user turns off the ring's SpO₂ toggle, the ring still measures on the HR * interval. Supports HR intervals up to 120 min (issue #103). */ + internal fun computeRingIntervalMinutes( + isYcbt: Boolean, + hrIntervalMinutes: Int, + spo2IntervalMinutes: Int, + spo2Enabled: Boolean, + ): Int? { + val minutes = if (isYcbt) { + hrIntervalMinutes.takeIf { it > 0 } ?: 60 + } else { + if (!spo2Enabled) return null + spo2IntervalMinutes.takeIf { it > 0 } ?: hrIntervalMinutes + } + return minutes.coerceIn(5, 120) + } + private suspend fun intervalMinutes(db: PulseLoopDatabase): Int? { val settings = loadPersistedMeasurementSettings(db) ?: return null val device = db.deviceDao().currentReal() @@ -95,13 +110,12 @@ class ScheduledSpo2Worker( com.pulseloop.ring.RingDeviceType.YCBT, com.pulseloop.ring.RingDeviceType.COLMI_SMART_HEALTH, ) - val minutes = if (isYcbt) { - settings.hrIntervalMinutes.takeIf { it > 0 } ?: 60 - } else { - if (!settings.spo2Enabled) return null - settings.spo2IntervalMinutes.takeIf { it > 0 } ?: settings.hrIntervalMinutes - } - return minutes.coerceIn(5, 120) + return computeRingIntervalMinutes( + isYcbt = isYcbt, + hrIntervalMinutes = settings.hrIntervalMinutes, + spo2IntervalMinutes = settings.spo2IntervalMinutes, + spo2Enabled = settings.spo2Enabled, + ) } } diff --git a/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt b/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt index 272e71d3..8b148071 100644 --- a/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt +++ b/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt @@ -16,9 +16,9 @@ import com.pulseloop.ring.WearableCapability * Off by default: each run holds the connection and the optical sensor for about a minute. * In SharedPreferences rather than `device_measurement_configs` to stay clear of a schema bump. */ -class ScheduledSpo2Prefs(context: Context) { +class ScheduledSpo2Prefs internal constructor(private val prefs: android.content.SharedPreferences) { - private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + constructor(context: Context) : this(context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)) var enabled: Boolean get() = prefs.getBoolean(KEY_ENABLED, false) diff --git a/app/src/test/java/com/pulseloop/health/VitalsExporterFilteringTest.kt b/app/src/test/java/com/pulseloop/health/VitalsExporterFilteringTest.kt new file mode 100644 index 00000000..9b55ee99 --- /dev/null +++ b/app/src/test/java/com/pulseloop/health/VitalsExporterFilteringTest.kt @@ -0,0 +1,50 @@ +package com.pulseloop.health + +import com.pulseloop.health.exporters.VitalsExporter +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Issue #103: tests that estimated SpO2 and derived respiratory rate are excluded from Health Connect export. + */ +class VitalsExporterFilteringTest { + + @Test + fun `genuine spot SpO2 reading is exported`() { + val timestamp = 1729000000000L + val estimatedSet = setOf(1729003600000L, 1729007200000L) + assertTrue(VitalsExporter.shouldExportSpo2(98.0, timestamp, estimatedSet)) + } + + @Test + fun `hourly SpO2 estimated from heart rate is excluded`() { + val timestamp = 1729003600000L + val estimatedSet = setOf(timestamp, 1729007200000L) + assertFalse(VitalsExporter.shouldExportSpo2(98.0, timestamp, estimatedSet)) + } + + @Test + fun `implausible SpO2 is excluded even if not estimated`() { + val timestamp = 1729000000000L + assertFalse(VitalsExporter.shouldExportSpo2(105.0, timestamp, emptySet())) + assertFalse(VitalsExporter.shouldExportSpo2(0.0, timestamp, emptySet())) + } + + @Test + fun `respiratory rate on YCBT ring is excluded from export`() { + // YCBT rings derive respiratory rate as ~0.2 * HR without a real sensor + assertFalse(VitalsExporter.shouldExportRespRate(16.0, isYcbt = true)) + } + + @Test + fun `respiratory rate on non-YCBT ring with native sensor is exported`() { + assertTrue(VitalsExporter.shouldExportRespRate(16.0, isYcbt = false)) + } + + @Test + fun `implausible respiratory rate is excluded on any ring`() { + assertFalse(VitalsExporter.shouldExportRespRate(0.0, isYcbt = false)) + assertFalse(VitalsExporter.shouldExportRespRate(100.0, isYcbt = false)) + } +} diff --git a/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt b/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt index f561f1e9..9dcfb7a5 100644 --- a/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt +++ b/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt @@ -133,4 +133,14 @@ class ScheduledSpo2SlotsTest { assertTrue(ScheduledSpo2Slots.isClear(at(10, 30), 120)) assertTrue(ScheduledSpo2Slots.isClear(at(11, 30), 120)) } + + @Test + fun `two hour ring grid with three hour app interval lands at half past and stays clear`() { + assertEquals(at(12, 30), ScheduledSpo2Slots.nextSlot(at(10, 5), 180, 120)) + var t = at(0, 0) + repeat(40) { + t = ScheduledSpo2Slots.nextSlot(t, 180, 120) + assertTrue("180m app slot $t on 120m ring grid", ScheduledSpo2Slots.isClear(t, 120)) + } + } } diff --git a/app/src/test/java/com/pulseloop/service/ScheduledSpo2WorkerTest.kt b/app/src/test/java/com/pulseloop/service/ScheduledSpo2WorkerTest.kt new file mode 100644 index 00000000..a6975bc2 --- /dev/null +++ b/app/src/test/java/com/pulseloop/service/ScheduledSpo2WorkerTest.kt @@ -0,0 +1,68 @@ +package com.pulseloop.service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Issue #103: tests for ScheduledSpo2Worker interval computation and scheduling guards. + */ +class ScheduledSpo2WorkerTest { + + @Test + fun `ycbt uses hr interval even when spo2 is disabled`() { + val interval = ScheduledSpo2Worker.computeRingIntervalMinutes( + isYcbt = true, + hrIntervalMinutes = 120, + spo2IntervalMinutes = 0, + spo2Enabled = false, + ) + // Turning off the ring's fake SpO2 toggle must not cancel the app's spot SpO2 schedule + assertEquals(120, interval) + } + + @Test + fun `ycbt ignores spo2 interval and uses hr interval`() { + val interval = ScheduledSpo2Worker.computeRingIntervalMinutes( + isYcbt = true, + hrIntervalMinutes = 120, + spo2IntervalMinutes = 30, + spo2Enabled = true, + ) + // Ring ignores 01 26 and measures on HR cadence, so interval must be 120 to avoid false skips + assertEquals(120, interval) + } + + @Test + fun `ycbt defaults to 60 min when hr interval is unset`() { + val interval = ScheduledSpo2Worker.computeRingIntervalMinutes( + isYcbt = true, + hrIntervalMinutes = 0, + spo2IntervalMinutes = 30, + spo2Enabled = true, + ) + assertEquals(60, interval) + } + + @Test + fun `non ycbt returns null when spo2 is disabled`() { + val interval = ScheduledSpo2Worker.computeRingIntervalMinutes( + isYcbt = false, + hrIntervalMinutes = 60, + spo2IntervalMinutes = 30, + spo2Enabled = false, + ) + assertNull(interval) + } + + @Test + fun `non ycbt uses spo2 interval when enabled`() { + val interval = ScheduledSpo2Worker.computeRingIntervalMinutes( + isYcbt = false, + hrIntervalMinutes = 60, + spo2IntervalMinutes = 30, + spo2Enabled = true, + ) + assertEquals(30, interval) + } +} diff --git a/app/src/test/java/com/pulseloop/settings/ScheduledSpo2PrefsTest.kt b/app/src/test/java/com/pulseloop/settings/ScheduledSpo2PrefsTest.kt index 7bcb9261..46301ed2 100644 --- a/app/src/test/java/com/pulseloop/settings/ScheduledSpo2PrefsTest.kt +++ b/app/src/test/java/com/pulseloop/settings/ScheduledSpo2PrefsTest.kt @@ -1,5 +1,6 @@ package com.pulseloop.settings +import android.content.SharedPreferences import com.pulseloop.data.entity.DeviceEntity import com.pulseloop.ring.RingDeviceType import com.pulseloop.ring.WearableCapability @@ -10,10 +11,59 @@ import org.junit.Test /** * The interval picker behind issue #103's request to space the app's own spot SpO₂ past an hour. - * Pure arithmetic — the SharedPreferences round-trip needs a device. */ class ScheduledSpo2PrefsTest { + private class FakeSharedPreferences : SharedPreferences { + val map = HashMap() + override fun getAll(): MutableMap = map + override fun getString(key: String?, defValue: String?): String? = map[key] as? String ?: defValue + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + map[key] as? MutableSet ?: defValues + override fun getInt(key: String?, defValue: Int): Int = map[key] as? Int ?: defValue + override fun getLong(key: String?, defValue: Long): Long = map[key] as? Long ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = map[key] as? Float ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = map[key] as? Boolean ?: defValue + override fun contains(key: String?): Boolean = map.containsKey(key) + override fun edit(): SharedPreferences.Editor = FakeEditor(this) + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + + private class FakeEditor(private val prefs: FakeSharedPreferences) : SharedPreferences.Editor { + override fun putString(key: String?, value: String?): SharedPreferences.Editor { if (value != null) prefs.map[key!!] = value; return this } + override fun putStringSet(key: String?, values: MutableSet?): SharedPreferences.Editor = this + override fun putInt(key: String?, value: Int): SharedPreferences.Editor { prefs.map[key!!] = value; return this } + override fun putLong(key: String?, value: Long): SharedPreferences.Editor { prefs.map[key!!] = value; return this } + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = this + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor { prefs.map[key!!] = value; return this } + override fun remove(key: String?): SharedPreferences.Editor { prefs.map.remove(key); return this } + override fun clear(): SharedPreferences.Editor { prefs.map.clear(); return this } + override fun commit(): Boolean = true + override fun apply() {} + } + } + + @Test + fun `hideEstimated defaults to true and round trips`() { + val fakePrefs = FakeSharedPreferences() + val prefs = ScheduledSpo2Prefs(fakePrefs) + assertTrue(prefs.hideEstimated) + prefs.hideEstimated = false + assertFalse(prefs.hideEstimated) + prefs.hideEstimated = true + assertTrue(prefs.hideEstimated) + } + + @Test + fun `enabled defaults to false and interval defaults to 60`() { + val fakePrefs = FakeSharedPreferences() + val prefs = ScheduledSpo2Prefs(fakePrefs) + assertFalse(prefs.enabled) + assertEquals(60, prefs.intervalMinutes) + prefs.intervalMinutes = 120 + assertEquals(120, prefs.intervalMinutes) + } + @Test fun `the offered options pass through unchanged`() { for (minutes in listOf(60, 120, 180)) { From 826fb22296ddda27bf17178cc3b52c0dab98aed9 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sun, 25 Oct 2026 00:54:33 -0700 Subject: [PATCH 7/7] fix(spo2): filter series on tile, coordinate sync gatt, connect 120m chart (#103) - Feed displayedSpo2 into spo2Series so Today/Vitals cards and widgets filter estimated readings. - Coordinate GATT access: pause ScheduledSpo2Worker timeout while RingSyncWorker syncs; yield sync when measuring. - Scale day-view chart maxGapMs with sample interval (max(90m, interval * 1.5)) so 120m HR/SpO2 readings connect. --- .../com/pulseloop/service/RingSyncWorker.kt | 10 +++++ .../pulseloop/service/ScheduledSpo2Worker.kt | 37 +++++++++++++++---- .../com/pulseloop/ui/components/Charts.kt | 5 ++- .../pulseloop/ui/components/ZoneLineChart.kt | 7 +++- .../ui/components/ZoneLineSplitter.kt | 21 ++++++++--- .../com/pulseloop/ui/viewmodels/ViewModels.kt | 2 +- .../pulseloop/widgets/WidgetTileRenderer.kt | 6 ++- .../service/ScheduledSpo2WorkerTest.kt | 6 +++ .../ui/components/ZoneLineSplitterTest.kt | 13 +++++++ 9 files changed, 90 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt b/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt index 44551eea..4e74a053 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt @@ -32,6 +32,10 @@ class RingSyncWorker( private const val SYNC_TIMEOUT_SECONDS = 45L private const val TAG = "RingSyncWorker" + @Volatile + var isSyncing: Boolean = false + internal set + /** Schedule periodic background ring sync. Call from MainActivity. */ fun schedule(context: Context) { val request = PeriodicWorkRequestBuilder( @@ -72,6 +76,10 @@ class RingSyncWorker( // command queues and lets this worker's cleanup disconnect the UI-owned session. if (isAppForeground()) return Result.success() + // If scheduled spot SpO2 is in flight, yield this periodic sync window — the SpO2 worker + // runs startup sync anyway so history is still pulled without fighting over GATT (issue #103). + if (ScheduledSpo2Worker.isMeasuring) return Result.success() + val keyStore = ApiKeyStore(applicationContext) val db = PulseLoopDatabase.getInstance(applicationContext) @@ -89,6 +97,7 @@ class RingSyncWorker( val measurementSettings = loadPersistedMeasurementSettings(db) val profileValues = loadPersistedUserProfile(db, keyStore) + isSyncing = true return try { withTimeout(SYNC_TIMEOUT_SECONDS * 1000L) { // Connect to last-known ring @@ -134,6 +143,7 @@ class RingSyncWorker( } catch (e: Exception) { Result.retry() } finally { + isSyncing = false // This client is worker-owned. Cancel its watchdog as well as closing GATT so it // cannot reconnect after doWork() has returned. val releasedConnection = bleClient.destroy() diff --git a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt index 9beda480..a5dcf15a 100644 --- a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt +++ b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt @@ -51,6 +51,10 @@ class ScheduledSpo2Worker( /** Startup history has to drain before the sensor is taken — the ring serves one thing at a time. */ private const val SYNC_DRAIN_TIMEOUT_MS = 45_000L + @Volatile + var isMeasuring: Boolean = false + internal set + /** After a settings change: replace whatever is queued with the next slot, or cancel. */ suspend fun reschedule(context: Context) { enqueueNext(context, ExistingWorkPolicy.REPLACE) @@ -141,6 +145,15 @@ class ScheduledSpo2Worker( } private suspend fun measure(db: PulseLoopDatabase) { + isMeasuring = true + try { + measureInternal(db) + } finally { + isMeasuring = false + } + } + + private suspend fun measureInternal(db: PulseLoopDatabase) { // The app's own link, if it has one: a second client can't connect while it holds GATT. AppServices.peek()?.coordinator?.let { coordinator -> val ran = withContext(Dispatchers.Main) { @@ -164,16 +177,24 @@ class ScheduledSpo2Worker( client.onConnected = { withContext(Dispatchers.Main) { coordinator.runStartupSequence() } } client.connectLastKnown() - val connected = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { - while (!coordinator.isConnected) { - if (client.state.value.connectionState == RingConnectionState.DISCONNECTED) { - client.connectLastKnown() - } + // If background sync is currently running, pause our connect timeout countdown so we + // don't burn the connection budget while sync holds GATT (issue #103). + var remainingTimeoutMs = CONNECT_TIMEOUT_MS + var syncWaitMs = 60_000L + while (!coordinator.isConnected && remainingTimeoutMs > 0) { + if (isAppForeground()) return + if (RingSyncWorker.isSyncing && syncWaitMs > 0) { delay(1000) + syncWaitMs -= 1000 + continue } - true - } ?: false - if (!connected) return + if (client.state.value.connectionState == RingConnectionState.DISCONNECTED) { + client.connectLastKnown() + } + delay(1000) + remainingTimeoutMs -= 1000 + } + if (!coordinator.isConnected) return // runStartupSequence opens the progress just after connect; wait for it to open, then // to drain (the same wait factoryResetRing uses before it takes the ring). diff --git a/app/src/main/java/com/pulseloop/ui/components/Charts.kt b/app/src/main/java/com/pulseloop/ui/components/Charts.kt index 1c54f1c8..7026f8f7 100644 --- a/app/src/main/java/com/pulseloop/ui/components/Charts.kt +++ b/app/src/main/java/com/pulseloop/ui/components/Charts.kt @@ -433,7 +433,10 @@ fun TrendChart( // The primary line is drawn zone-split and gap-broken (iOS #35): each sample pair // splits at zone boundaries so a piece never spans two zones, and runs separated // by more than the range-tuned max gap break instead of bridging. - val maxGapMs = if (useTime) ZoneLineSplitter.maxGapMs(fullRangeMs) else Long.MAX_VALUE + val minInterval = if (timestamps.size >= 2) { + (1 until timestamps.size).map { timestamps[it] - timestamps[it - 1] }.filter { it > 0 }.minOrNull() + } else null + val maxGapMs = if (useTime) ZoneLineSplitter.maxGapMs(fullRangeMs, minInterval) else Long.MAX_VALUE val primaryOffsets = points.mapIndexed { i, v -> Offset(xForIndex(i), yForValue(v)) } val secondaryOffsets = secondary.mapIndexed { i, v -> Offset(xForSecondary(i), yForValue(v)) } clipRect(plotLeft, plotTop, plotRight, plotBottom) { diff --git a/app/src/main/java/com/pulseloop/ui/components/ZoneLineChart.kt b/app/src/main/java/com/pulseloop/ui/components/ZoneLineChart.kt index bf81b649..48e40372 100644 --- a/app/src/main/java/com/pulseloop/ui/components/ZoneLineChart.kt +++ b/app/src/main/java/com/pulseloop/ui/components/ZoneLineChart.kt @@ -114,7 +114,12 @@ fun ZoneLineChart( ) } // Zone-colored line, broken across data gaps. - val runs = ZoneLineSplitter.segmentsByGap(samples.map { it.timestampMs }, maxGapMs) + val timestamps = samples.map { it.timestampMs } + val minInterval = if (timestamps.size >= 2) { + (1 until timestamps.size).map { timestamps[it] - timestamps[it - 1] }.filter { it > 0 }.minOrNull() + } else null + val effectiveMaxGapMs = ZoneLineSplitter.maxGapMs(span, minInterval).coerceAtLeast(maxGapMs) + val runs = ZoneLineSplitter.segmentsByGap(timestamps, effectiveMaxGapMs) runs.forEach { run -> // A gap-isolated single sample would otherwise draw nothing — sparse series // (daily HRV/temp, fatigue) must still show their readings. diff --git a/app/src/main/java/com/pulseloop/ui/components/ZoneLineSplitter.kt b/app/src/main/java/com/pulseloop/ui/components/ZoneLineSplitter.kt index 1abb8ea5..018642bb 100644 --- a/app/src/main/java/com/pulseloop/ui/components/ZoneLineSplitter.kt +++ b/app/src/main/java/com/pulseloop/ui/components/ZoneLineSplitter.kt @@ -70,11 +70,22 @@ object ZoneLineSplitter { * The maximum allowed gap before the line breaks, tuned to the plotted span so denser * windows break sooner. Mirrors ChartSampleBuilder.maxGap: 24h→90 min, 7d→36 h, * 30d→4 days, longer→45 days. + * + * In day view ([spanMs] <= 26h), [sampleIntervalMs] scales the break threshold to + * `max(90m, interval * 1.5)` so user-configured 120 min or 180 min intervals keep their + * points connected while still breaking across real gaps (issue #103). */ - fun maxGapMs(spanMs: Long): Long = when { - spanMs <= 26 * 3600_000L -> 90 * 60_000L - spanMs <= 8 * 86_400_000L -> 36 * 3600_000L - spanMs <= 32 * 86_400_000L -> 4 * 86_400_000L - else -> 45 * 86_400_000L + fun maxGapMs(spanMs: Long, sampleIntervalMs: Long? = null): Long { + val baseGap = when { + spanMs <= 26 * 3600_000L -> 90 * 60_000L + spanMs <= 8 * 86_400_000L -> 36 * 3600_000L + spanMs <= 32 * 86_400_000L -> 4 * 86_400_000L + else -> 45 * 86_400_000L + } + return if (sampleIntervalMs != null && sampleIntervalMs > 0 && spanMs <= 26 * 3600_000L) { + maxOf(baseGap, (sampleIntervalMs * 1.5).toLong()) + } else { + baseGap + } } } 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 95ecae28..a2b215b7 100644 --- a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt @@ -816,7 +816,7 @@ class VitalsViewModel( bpDiaSamples = bpDia.map { it.value }, glucoseSamples = gluc.map { it.value + glucoseOffset }, hrSeries = hr.map { VitalSample(it.timestamp, it.value) }, - spo2Series = spo2.map { VitalSample(it.timestamp, it.value) }, + spo2Series = displayedSpo2.map { VitalSample(it.timestamp, it.value) }, hrvSeries = hrv.map { VitalSample(it.timestamp, it.value) }, stressSeries = stress.map { VitalSample(it.timestamp, it.value) }.ifEmpty { derivedStressSeries }, fatigueSeries = fatigue.map { VitalSample(it.timestamp, it.value) }, diff --git a/app/src/main/java/com/pulseloop/widgets/WidgetTileRenderer.kt b/app/src/main/java/com/pulseloop/widgets/WidgetTileRenderer.kt index df7cdd34..07824f02 100644 --- a/app/src/main/java/com/pulseloop/widgets/WidgetTileRenderer.kt +++ b/app/src/main/java/com/pulseloop/widgets/WidgetTileRenderer.kt @@ -439,7 +439,11 @@ class WidgetTileRenderer(private val density: Float) { } // Zone-colored line, broken across data gaps (90 min — the card-chart gap on 24h ranges). val timestampsMs = samples.map { it.t * 1000L } - val runs = ZoneLineSplitter.segmentsByGap(timestampsMs, 90 * 60_000L) + val minInterval = if (timestampsMs.size >= 2) { + (1 until timestampsMs.size).map { timestampsMs[it] - timestampsMs[it - 1] }.filter { it > 0 }.minOrNull() + } else null + val maxGapMs = ZoneLineSplitter.maxGapMs(24 * 3600_000L, minInterval) + val runs = ZoneLineSplitter.segmentsByGap(timestampsMs, maxGapMs) runs.forEach { run -> if (run.first == run.last) { val s = samples[run.first] diff --git a/app/src/test/java/com/pulseloop/service/ScheduledSpo2WorkerTest.kt b/app/src/test/java/com/pulseloop/service/ScheduledSpo2WorkerTest.kt index a6975bc2..ae8869f4 100644 --- a/app/src/test/java/com/pulseloop/service/ScheduledSpo2WorkerTest.kt +++ b/app/src/test/java/com/pulseloop/service/ScheduledSpo2WorkerTest.kt @@ -65,4 +65,10 @@ class ScheduledSpo2WorkerTest { ) assertEquals(30, interval) } + + @Test + fun `syncing and measuring lock flags default to false and are observable`() { + assertEquals(false, RingSyncWorker.isSyncing) + assertEquals(false, ScheduledSpo2Worker.isMeasuring) + } } diff --git a/app/src/test/java/com/pulseloop/ui/components/ZoneLineSplitterTest.kt b/app/src/test/java/com/pulseloop/ui/components/ZoneLineSplitterTest.kt index 4549047b..16ff2bfb 100644 --- a/app/src/test/java/com/pulseloop/ui/components/ZoneLineSplitterTest.kt +++ b/app/src/test/java/com/pulseloop/ui/components/ZoneLineSplitterTest.kt @@ -55,4 +55,17 @@ class ZoneLineSplitterTest { assertEquals(4 * 86_400_000L, ZoneLineSplitter.maxGapMs(30 * 86_400_000L)) assertEquals(45 * 86_400_000L, ZoneLineSplitter.maxGapMs(365 * 86_400_000L)) } + + @Test + fun `max gap in day view expands for wider measurement intervals`() { + val daySpan = 24 * 3600_000L + // Default / 60m interval stays 90m + assertEquals(90 * 60_000L, ZoneLineSplitter.maxGapMs(daySpan, 60 * 60_000L)) + // 120m interval expands to 180m (120 * 1.5) so consecutive points stay connected + assertEquals(180 * 60_000L, ZoneLineSplitter.maxGapMs(daySpan, 120 * 60_000L)) + // 180m interval expands to 270m (180 * 1.5) + assertEquals(270 * 60_000L, ZoneLineSplitter.maxGapMs(daySpan, 180 * 60_000L)) + // Multi-day spans ignore sampleIntervalMs + assertEquals(36 * 3600_000L, ZoneLineSplitter.maxGapMs(7 * 86_400_000L, 120 * 60_000L)) + } }