Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions app/src/main/java/com/pulseloop/AppServices.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -85,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
}
}

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/pulseloop/PulseLoopApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

/**
Expand Down Expand Up @@ -866,6 +867,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 }
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/pulseloop/service/RingSyncWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 56 additions & 0 deletions app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt
Original file line number Diff line number Diff line change
@@ -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
}
170 changes: 170 additions & 0 deletions app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt
Original file line number Diff line number Diff line change
@@ -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<ScheduledSpo2Worker>()
.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)
}
}
40 changes: 40 additions & 0 deletions app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 2 additions & 0 deletions app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,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
Expand Down
Loading
Loading