Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
32ca8d5
ci: test feature branches
DBozhinovski Jul 16, 2026
b9fadad
ci: allow manual feature builds
DBozhinovski Jul 16, 2026
6be668f
feat: add YCBT R10M ring support
DBozhinovski Jul 16, 2026
ffea7ea
fix: resolve YCBT compile integration errors
DBozhinovski Jul 16, 2026
54cba1e
test: fix YCBT Kotlin fixtures
DBozhinovski Jul 16, 2026
d963329
feat: integrate YCBT lifecycle and capabilities
DBozhinovski Jul 16, 2026
4cc085f
fix: harden YCBT protocol edge cases
DBozhinovski Jul 16, 2026
39cff09
feat: complete YCBT refresh and spot flows
DBozhinovski Jul 16, 2026
c128c49
ci: publish YCBT test APK
DBozhinovski Jul 16, 2026
033621f
fix: keep every ring brand visible in pairing
DBozhinovski Jul 17, 2026
9935894
fix: subscribe to YCBT command replies
DBozhinovski Jul 17, 2026
5ae43b6
fix: keep BLE disconnect diagnostics visible
DBozhinovski Jul 17, 2026
5e54aa3
fix: use conservative BLE setup for YCBT rings
DBozhinovski Jul 17, 2026
1e3c994
fix: skip unsupported R10M chip query
DBozhinovski Jul 17, 2026
c6cdaf4
fix: wake R10M immediately after subscription
DBozhinovski Jul 17, 2026
f906796
fix: call YCBT wakeup through concrete engine
DBozhinovski Jul 17, 2026
90b3a5a
fix: serialize YCBT writes by protocol reply
DBozhinovski Jul 17, 2026
b5455e1
fix: match vendor YCBT write callback queue
DBozhinovski Jul 17, 2026
945e945
fix: reproduce current SmartHealth YCBT handshake
DBozhinovski Jul 17, 2026
fc2e61e
test: use YCBT frame helper for vendor vector
DBozhinovski Jul 17, 2026
178a08e
fix: enable YCBT reply channels as indications
DBozhinovski Jul 17, 2026
2816f8a
fix: route YCBT vitals through spot measurement modes
DBozhinovski Jul 17, 2026
f6eb7ec
refactor: harden YCBT sync and measurement routing
DBozhinovski Jul 17, 2026
1acd016
fix: label live blood pressure diagnostics
DBozhinovski Jul 17, 2026
be5c037
chore: polish diagnostics and CI for upstream
DBozhinovski Jul 17, 2026
fe48463
fix: harden GATT failure and live event handling
DBozhinovski Jul 17, 2026
aefa646
fix: stabilize YCBT sleep and activity refresh
DBozhinovski Jul 18, 2026
f25cb46
test: align YCBT refresh expectations
DBozhinovski Jul 18, 2026
65fddc3
fix: persist sleep stages atomically
DBozhinovski Jul 18, 2026
3b648c9
fix: refresh YCBT activity after reconnect sync
DBozhinovski Jul 18, 2026
82d1ef9
fix: refresh vitals after committed measurements
DBozhinovski Jul 18, 2026
80082ae
fix: harden YCBT sync persistence
DBozhinovski Jul 20, 2026
609eb45
Merge origin/main into YCBT support
DBozhinovski Jul 21, 2026
849131d
fix: address YCBT review feedback
DBozhinovski Jul 22, 2026
b0b5fd9
Merge remote-tracking branch 'origin/main' into test/ycbt-sleep-steps…
DBozhinovski Jul 22, 2026
a2286ef
fix(ycbt): resolve review blockers — transfer deadlock and coordinato…
foureight84 Jul 22, 2026
f7dfd04
Merge remote-tracking branch 'origin/main' into fix/pr31-merge-main
foureight84 Jul 25, 2026
27700cf
fix(ycbt): re-key legacy HRV rows; correct the R10M card
foureight84 Jul 25, 2026
9b1fcd8
fix(data): delete the duplicate measurement rows earlier syncs accumu…
foureight84 Jul 25, 2026
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
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI

on:
workflow_dispatch:
push:
branches:
- main
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: gradle/actions/setup-gradle@v4
- name: Unit tests
run: ./gradlew --no-daemon testDebugUnitTest
- name: Build debug APK
run: ./gradlew --no-daemon :app:assembleDebug
- name: Upload test APK
uses: actions/upload-artifact@v4
with:
name: pulseloop-debug-apk
path: app/build/outputs/apk/debug/*.apk
if-no-files-found: error
retention-days: 14
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class CoachSummaryCoordinator(
is PulseEvent.ActivityUpdate,
is PulseEvent.HeartRateSample,
is PulseEvent.Spo2Result,
is PulseEvent.BloodSugarSample,
is PulseEvent.HistoryMeasurement -> {
pendingToday = true
scheduleRefresh()
Expand Down
11 changes: 3 additions & 8 deletions app/src/main/java/com/pulseloop/data/DataRepairs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,9 @@ object DataRepairs {
* window are additionally self-healed by `applyActivityBucket` on the next sync. Today's
* row is out of scope — the live cumulative total re-ratchets on the next update.
*
* No sleep counterpart is needed: sleep_sessions AND sleep_stage_blocks are cleared and
* rebuilt from the ring on every connect (see EventPersistenceSubscriber's CONNECTED
* handling), so rows keyed by the old start-of-day grouping — sessions split across
* midnight and stage blocks filed under what is now a different night's id — disappear
* on the first sync after this update. The same reasoning retires iOS's one-time
* `migrateSleepSessionSegmentsIfNeeded` (PR #83): a waking day whose nap was merged into that
* morning's night re-splits into distinct sessions on the next sync via
* EventPersistenceSubscriber.reconcileWakingDay, and demo days re-split on the next reseed.
* Complete ring sleep records independently replace their waking-day session and remove any
* overlapping legacy midnight-split parents. Connection events must not clear real sleep:
* replacement history is asynchronous and may be empty or consumed.
*/
suspend fun runIfNeeded(context: Context, db: PulseLoopDatabase = PulseLoopDatabase.getInstance(context)) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
Expand Down
163 changes: 161 additions & 2 deletions app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import com.pulseloop.data.entity.*
BatterySampleEntity::class,
CoachNotificationRecordEntity::class,
],
version = 12,
version = 16,
exportSchema = false,
)
abstract class PulseLoopDatabase : RoomDatabase() {
Expand Down Expand Up @@ -205,14 +205,173 @@ abstract class PulseLoopDatabase : RoomDatabase() {
}
}

/** v12 → v13: index replayed sensor-history identity without deleting valid collisions. */
private val MIGRATION_12_13 = object : Migration(12, 13) {
override fun migrate(db: SupportSQLiteDatabase) {
// Feature APKs briefly used version 8 for this index before main assigned v8 to
// battery history. Keep the migration valid for both upgrade lineages.
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `battery_samples` (
`id` TEXT NOT NULL,
`percent` INTEGER NOT NULL,
`timestamp` INTEGER NOT NULL,
`createdAt` INTEGER NOT NULL,
PRIMARY KEY(`id`)
)
""".trimIndent()
)
db.execSQL("CREATE INDEX IF NOT EXISTS `index_battery_samples_timestamp` ON `battery_samples` (`timestamp`)")
adoptStableMeasurementIdentities(db)
}
}

/** v13 → v14: replace the pre-review unique identity index without dropping rows. */
private val MIGRATION_13_14 = object : Migration(13, 14) {
override fun migrate(db: SupportSQLiteDatabase) {
adoptStableMeasurementIdentities(db)
}
}

/** v14 → v15: re-run identity adoption now that it also covers HRV rows stored as 'live'.
* Same reasoning as v13 → v14: a test APK that already reached v14 ran the pre-fix version
* of [adoptStableMeasurementIdentities] and would otherwise keep its un-keyed HRV rows,
* which double the HRV series on the next re-sync. The function is idempotent, so re-running
* it is free for anyone whose rows are already adopted. */
private val MIGRATION_14_15 = object : Migration(14, 15) {
override fun migrate(db: SupportSQLiteDatabase) {
adoptStableMeasurementIdentities(db)
}
}

/**
* v15 → v16: delete the duplicate rows the pre-identity code accumulated.
*
* Before measurements had stable ids, a ring's history *replay* was persisted with a fresh
* random id every time, so each re-sync appended another row at a slot already stored — on
* a real Colmi that meant HRV, stress and temperature growing by one row per slot per sync,
* forever (nothing prunes this table). [adoptStableMeasurementIdentities] stops the growth
* by giving one row per slot the canonical `history:<key>:<timestamp>` id that later syncs
* upsert onto, but it deliberately leaves the already-accumulated copies in place. They are
* not harmless: `dailyAggregates`/`hourlyAggregates` compute `AVG(value)` over raw rows with
* no `sourceRaw` filter, so a slot replayed more often than its neighbours drags the average
* toward its value.
*
* Deleting is restricted to rows that are **provably redundant**: a non-canonical row is
* removed only when a canonical row exists for the same `(kindRaw, timestamp)` *and* holds
* the same `value`. A row whose value differs is a distinct reading and is always kept, so
* this can never destroy information — every deleted row's (kind, timestamp, value) is still
* represented by the canonical row that survives.
*
* **Interruption safety.** This is deliberately one statement. SQLite applies a single
* `DELETE` atomically via its journal, so a process kill mid-migration can only leave the
* table fully cleaned or wholly untouched — never half-deleted. Room additionally runs
* migrations inside the transaction `SQLiteOpenHelper` opens around `onUpgrade`, so the
* schema-version bump and this delete commit together: an interrupted upgrade rolls back to
* v15 and simply re-runs on the next launch. The statement is also idempotent — once the
* redundant rows are gone it matches nothing — so re-running after a rollback is a no-op.
*/
private val MIGRATION_15_16 = object : Migration(15, 16) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
DELETE FROM `measurements`
WHERE `id` NOT LIKE 'history:%'
AND EXISTS (
SELECT 1 FROM `measurements` AS `canonical`
WHERE `canonical`.`id` LIKE 'history:%'
AND `canonical`.`kindRaw` = `measurements`.`kindRaw`
AND `canonical`.`timestamp` = `measurements`.`timestamp`
AND `canonical`.`value` = `measurements`.`value`
)
""".trimIndent()
)
}
}

private fun adoptStableMeasurementIdentities(db: SupportSQLiteDatabase) {
db.execSQL("DROP INDEX IF EXISTS `index_measurements_kindRaw_timestamp_sourceRaw`")
db.execSQL(
"UPDATE `measurements` SET `sourceRaw` = 'live' " +
"WHERE `sourceRaw` = 'colmi' AND `kindRaw` IN ('HRV', 'TEMPERATURE')"
)
listOf(
"HEART_RATE" to "hr",
"SPO2" to "spo2",
"STRESS" to "stress",
"FATIGUE" to "fatigue",
"HRV" to "hrv",
"TEMPERATURE" to "temp",
"BLOOD_PRESSURE_SYSTOLIC" to "bp_sys",
"BLOOD_PRESSURE_DIASTOLIC" to "bp_dia",
"BLOOD_SUGAR" to "glucose",
"RESPIRATORY_RATE" to "resp_rate",
"VO2MAX" to "vo2max",
).forEach { (kind, key) ->
adoptStableMeasurementIdentity(db, kind = kind, source = "history", key = key)
}
adoptStableMeasurementIdentity(db, kind = "STRESS", source = "colmi", key = "stress")
adoptStableMeasurementIdentity(db, kind = "TEMPERATURE", source = "live", key = "temp")
// HRV needs the same 'live' pass as TEMPERATURE above, for the same reason: Colmi's
// HRV *history* used to persist as an `HrvSample` — random id, sourceRaw 'live' — and
// now decodes to a `HistoryMeasurement`, which keys on `history:hrv:<timestamp>`.
// Without re-keying the old rows they don't collide with the new ones, so a re-sync
// writes a second row at every timestamp already stored, and `range()` (which filters
// on kindRaw + timestamp, never sourceRaw) returns both — doubling the HRV series.
adoptStableMeasurementIdentity(db, kind = "HRV", source = "live", key = "hrv")
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_measurements_kindRaw_timestamp_sourceRaw` " +
"ON `measurements` (`kindRaw`, `timestamp`, `sourceRaw`)"
)
}

private fun adoptStableMeasurementIdentity(
db: SupportSQLiteDatabase,
kind: String,
source: String,
key: String,
) {
db.execSQL(
"""
UPDATE `measurements`
SET `id` = 'history:$key:' || `timestamp`
WHERE `kindRaw` = '$kind' AND `sourceRaw` = '$source'
AND `rowid` IN (
SELECT MIN(`rowid`) FROM `measurements`
WHERE `kindRaw` = '$kind' AND `sourceRaw` = '$source'
GROUP BY `timestamp`
)
AND NOT EXISTS (
SELECT 1 FROM `measurements` AS `existing`
WHERE `existing`.`id` = 'history:$key:' || `measurements`.`timestamp`
)
""".trimIndent()
)
}

fun getInstance(context: Context): PulseLoopDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
PulseLoopDatabase::class.java,
"pulseloop.db"
)
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
.addMigrations(
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11,
MIGRATION_11_12,
MIGRATION_12_13,
MIGRATION_13_14,
MIGRATION_14_15,
MIGRATION_15_16,
)
// Downgrades only (sideloading an older APK). A blanket destructive
// fallback would silently wipe every measurement, sleep session, and
// coach conversation on any future version bump that misses a
Expand Down
19 changes: 9 additions & 10 deletions app/src/main/java/com/pulseloop/data/dao/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ interface DeviceDao {

@Dao
interface MeasurementDao {
/** Invalidates after committed measurement inserts; consumers can debounce bursty history. */
@Query("SELECT COUNT(*) FROM measurements")
fun changeFlow(): Flow<Int>

@Query("SELECT * FROM measurements WHERE kindRaw = :kind AND timestamp BETWEEN :start AND :end ORDER BY timestamp ASC")
suspend fun range(kind: String, start: Long, end: Long): List<MeasurementEntity>

Expand All @@ -63,16 +67,8 @@ interface MeasurementDao {
@Insert
suspend fun insert(measurement: MeasurementEntity)

/** Look up an already-persisted history row at an exact timestamp — the identity a ring's
* history replay is deduped on (a ring re-sends the same log every re-sync, with
* deterministic per-record epochs). */
@Query("SELECT * FROM measurements WHERE kindRaw = :kind AND timestamp = :timestamp AND sourceRaw = 'history' LIMIT 1")
suspend fun findHistoryAt(kind: String, timestamp: Long): MeasurementEntity?

/** Update a row's value in place — used when a re-synced history sample revises an existing
* one (the ring can refine an averaged block) without creating a duplicate row. */
@Query("UPDATE measurements SET value = :value WHERE id = :id")
suspend fun updateValue(id: String, value: Double)
@Upsert
suspend fun upsert(measurement: MeasurementEntity)

@Query("DELETE FROM measurements WHERE sourceRaw = 'demo'")
suspend fun clearDemo()
Expand Down Expand Up @@ -215,6 +211,9 @@ interface SleepSessionDao {
@Query("SELECT * FROM sleep_sessions WHERE date = :day AND sourceRaw != 'demo' LIMIT 1")
suspend fun ringByDay(day: Long): SleepSessionEntity?

@Query("SELECT * FROM sleep_sessions WHERE sourceRaw != 'demo' AND startAt < :end AND endAt > :start")
suspend fun ringOverlapping(start: Long, end: Long): List<SleepSessionEntity>

/** All synced (non-demo) sessions for a waking day, earliest first — the reconcile target. */
@Query("SELECT * FROM sleep_sessions WHERE date = :day AND sourceRaw != 'demo' ORDER BY startAt ASC")
suspend fun ringAllByDay(day: Long): List<SleepSessionEntity>
Expand Down
7 changes: 6 additions & 1 deletion app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ data class DeviceEntity(
*/
@Entity(
tableName = "measurements",
indices = [Index("timestamp"), Index("activitySessionId"), Index("kindRaw")],
indices = [
Index("timestamp"),
Index("activitySessionId"),
Index("kindRaw"),
Index(value = ["kindRaw", "timestamp", "sourceRaw"]),
],
)
data class MeasurementEntity(
@PrimaryKey val id: String = java.util.UUID.randomUUID().toString(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.*
import com.pulseloop.MainActivity
import com.pulseloop.coach.config.CoachSleepSyncGate
Expand All @@ -21,12 +23,16 @@ import com.pulseloop.data.entity.CoachNotificationRecordEntity
import com.pulseloop.ring.PulseEvent
import com.pulseloop.ring.PulseEventBus
import com.pulseloop.ring.RingBLEClient
import com.pulseloop.ring.RingConnectionState
import com.pulseloop.service.loadPersistedMeasurementSettings
import com.pulseloop.service.loadPersistedUserProfile
import com.pulseloop.settings.ApiKeyStore
import kotlinx.coroutines.async
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.*
import java.time.LocalDateTime
Expand Down Expand Up @@ -264,8 +270,9 @@ class CoachNotificationWorker(
awaitSyncDone()
return
}
if (isAppForeground()) return

val bleClient = RingBLEClient(applicationContext)
val bleClient = RingBLEClient(applicationContext, transientOwner = true)
if (!bleClient.hasPermissions()) {
// destroy(), not just drop the reference: the client's init-started connection
// watchdog would otherwise keep firing into permission-less connect attempts.
Expand All @@ -288,16 +295,33 @@ class CoachNotificationWorker(
engine?.runStartup()
}
bleClient.connectLastKnown()
while (!doneSignal.isCompleted && !isAppForeground()) delay(500)
if (isAppForeground()) {
doneSignal.cancel()
return@withTimeoutOrNull
}
doneSignal.await()
}
} finally {
// destroy(), not disconnect(): the client's connection watchdog (started in init)
// survives disconnect() and re-attaches the ring ~15s after the worker exits —
// re-firing onConnected → a full runStartup, then holding the ring with no UI.
bleClient.destroy()
val releasedConnection = bleClient.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)
}

/** Give an in-flight sync (driven by whoever owns the live link) a bounded chance to finish. */
private suspend fun awaitSyncDone() {
withTimeoutOrNull(SYNC_WAIT_TIMEOUT_MS) {
Expand Down
Loading
Loading