Skip to content
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
27 changes: 25 additions & 2 deletions app/src/main/java/com/pulseloop/health/exporters/VitalsExporter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long>): 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.<X>.name (EventPersistenceSubscriber,
Expand Down Expand Up @@ -227,11 +237,24 @@ class VitalsExporter(private val db: PulseLoopDatabase) {
val highWaters = mutableListOf<Long>()
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 (shouldExportSpo2(row.value, row.timestamp, estimatedSpo2)) {
OxygenSaturationRecord(instant, offset, Percentage(row.value), metadata(row, "spo2", device))
} else null
"hrv" -> if (HealthConnectTypeMappings.isPlausibleHrvRmssd(row.value)) {
Expand All @@ -254,7 +277,7 @@ class VitalsExporter(private val db: PulseLoopDatabase) {
metadata = metadata(row, "glucose", device),
)
} else null
"resp_rate" -> if (HealthConnectTypeMappings.isPlausibleRespRate(row.value)) {
"resp_rate" -> if (shouldExportRespRate(row.value, isYcbt)) {
RespiratoryRateRecord(
time = instant,
zoneOffset = offset,
Expand Down
10 changes: 8 additions & 2 deletions app/src/main/java/com/pulseloop/ring/YCBTEncoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,9 +79,14 @@ class YCBTSettingsEncoder {

/** The five background samplers, each {enable, intervalMinutes}. */
fun monitorCommands(settings: MeasurementSettings): List<ByteArray> {
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
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 @@ -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 }
}
}
}

Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/com/pulseloop/service/RingSyncWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<RingSyncWorker>(
Expand Down Expand Up @@ -63,11 +67,19 @@ 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.
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)

Expand All @@ -85,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
Expand Down Expand Up @@ -130,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()
Expand Down
71 changes: 71 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,71 @@
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]. [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 = 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
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
}
Loading
Loading