From f4e1b3f4f4478bef4d79ef41cbd3d90a673c266a Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Mon, 29 Jun 2026 12:14:22 -0700 Subject: [PATCH 1/6] Fix Colmi R10 pairing hang via serialized GATT operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The R10 connected and discovered services but never finished pairing, sitting on "Connecting…" forever. Root cause: Android's BLE stack allows only one outstanding GATT operation at a time, and onServicesDiscovered issued a firmware readCharacteristic (on the 0x180A DIS service the R10 exposes but the R02 does not) before the CCCD descriptor write that gates the CONNECTED transition — so the descriptor write was silently dropped. - Replace the write-only queue with a unified FIFO GATT operation queue (CommandWrite / Read / DescriptorWrite). Every read, write, and CCCD descriptor write now runs strictly one-at-a-time, each retired by its completion callback. Notify-CCCD writes are enqueued first so the ring reaches CONNECTED before informational battery/firmware reads. - Check the boolean each GATT call returns; log and skip on rejection instead of stranding the queue. The completion-timeout guard now covers every op type, not just writes. - Fix the cosmetic pairing badge: derive the real model from the advertised name ("COLMI R10_1203" → "Colmi R10") instead of the family displayName, which mislabeled every Colmi as "Colmi R02". Verified on a Pixel 8: the R10 now reaches CONNECTED and syncs live data. --- .../java/com/pulseloop/ring/RingBLEClient.kt | 226 +++++++++++------- .../com/pulseloop/ui/screens/PairingScreen.kt | 26 +- 2 files changed, 164 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 3a4e228..62c8d79 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -92,12 +92,30 @@ class RingBLEClient(private val context: Context) { private var forgetPending = false private var forgetJob: Job? = null - // MARK: Write serialization + // MARK: GATT operation serialization + // + // Android's BLE stack permits exactly ONE outstanding GATT operation at a time. + // Issuing a read / characteristic-write / descriptor-write while another is still + // in flight makes the new call return false and be silently dropped — the classic + // cause of a ring that connects and discovers services but never finishes enabling + // notifications, so it hangs forever on "Connecting…". (Seen on the Colmi R10, which + // — unlike the R02 — exposes a 0x180A DIS service, so a firmware read was issued and + // blocked the CCCD descriptor write that gates the CONNECTED transition.) + // + // Every GATT operation is funnelled through this single FIFO queue so they run + // strictly one-at-a-time, each retired by its matching callback (onCharacteristicWrite + // / onCharacteristicRead / onDescriptorWrite → completeOp) which pumps the next one. + + private sealed interface GattOp { + /** A command/keepalive payload for the write (or command) channel. */ + class CommandWrite(val data: ByteArray, val useCommandChannel: Boolean) : GattOp + class Read(val characteristic: BluetoothGattCharacteristic) : GattOp + class DescriptorWrite(val descriptor: BluetoothGattDescriptor, val value: ByteArray) : GattOp + } - private data class QueuedWrite(val data: ByteArray, val useCommandChannel: Boolean) - private val writeQueue = mutableListOf() - private var writeInFlight = false - private var writeSeq = 0 + private val opQueue = ArrayDeque() + private var opInFlight = false + private var opSeq = 0 // MARK: Connection state @@ -317,7 +335,7 @@ class RingBLEClient(private val context: Context) { } bluetoothGatt = null writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null - writeInFlight = false; writeQueue.clear() + opInFlight = false; opQueue.clear() prefs.edit().remove(LAST_PERIPHERAL_KEY).remove(LAST_DEVICE_TYPE_KEY).apply() updateState { copy(connectionState = RingConnectionState.IDLE, activeDeviceType = null, activeCapabilities = emptySet()) } } @@ -325,14 +343,12 @@ class RingBLEClient(private val context: Context) { fun enqueueWrite(data: ByteArray) { val framed = activeDriver?.frame(data) ?: data val useCommand = activeDriver?.usesCommandChannel(framed) ?: false - writeQueue.add(QueuedWrite(framed, useCommand)) - pumpWrites() + enqueueOp(GattOp.CommandWrite(framed, useCommand)) } fun readBattery() { - val gatt = bluetoothGatt ?: return val ch = batteryChar ?: return - gatt.readCharacteristic(ch) + enqueueOp(GattOp.Read(ch)) } private val lastKnownIdentifier: String? @@ -356,7 +372,7 @@ class RingBLEClient(private val context: Context) { } bluetoothGatt = null writeChar = null; commandChar = null; notifyChars.clear(); batteryChar = null - writeInFlight = false; writeQueue.clear() + opInFlight = false; opQueue.clear() val coordinator = coordinators.firstOrNull { it.deviceType == deviceType } ?: JringCoordinator installDriver(coordinator) connectingStartedAt = System.currentTimeMillis() @@ -412,33 +428,70 @@ class RingBLEClient(private val context: Context) { } } - private fun pumpWrites() { + private fun enqueueOp(op: GattOp) { + opQueue.addLast(op) + pumpOps() + } + + /** Retire the in-flight op (from a GATT callback or the timeout) and pump the next. */ + private fun completeOp() { + opInFlight = false + pumpOps() + } + + private fun pumpOps() { val gatt = bluetoothGatt ?: return - val wChar = writeChar ?: return - if (writeInFlight || writeQueue.isEmpty()) return + if (opInFlight || opQueue.isEmpty()) return + + val op = opQueue.first() + val issued: Boolean = when (op) { + is GattOp.CommandWrite -> { + val wChar = writeChar + if (wChar == null) { + // Write channel not bound yet (services not discovered) — leave the op + // queued; onServicesDiscovered will pump again once chars are wired up. + return + } + opQueue.removeFirst() + val target = if (op.useCommandChannel) commandChar ?: wChar else wChar + target.value = op.data + PulseEventBus.publishBlocking( + PulseEvent.RawPacket(PacketDirection.OUTGOING, op.data, + RingDecodedEvent.CommandAck(commandId = if (op.data.isNotEmpty()) op.data[0].toUByte() else 0u)) + ) + gatt.writeCharacteristic(target) + } + is GattOp.Read -> { + opQueue.removeFirst() + gatt.readCharacteristic(op.characteristic) + } + is GattOp.DescriptorWrite -> { + opQueue.removeFirst() + op.descriptor.value = op.value + gatt.writeDescriptor(op.descriptor) + } + } - val item = writeQueue.removeFirst() - val target = if (item.useCommandChannel) commandChar ?: wChar else wChar - target.value = item.data - writeInFlight = true + if (!issued) { + // The stack rejected the op at issue time (characteristic not readable/writable, + // or a transient busy state). Don't strand the queue — log and move to the next. + Log.w("RingBLEClient", "GATT op rejected at issue: ${op::class.simpleName}") + pumpOps() + return + } - PulseEventBus.publishBlocking( - PulseEvent.RawPacket(PacketDirection.OUTGOING, item.data, - RingDecodedEvent.CommandAck(commandId = if (item.data.isNotEmpty()) item.data[0].toUByte() else 0u)) - ) - gatt.writeCharacteristic(target) + opInFlight = true - // Guard against a missing onCharacteristicWrite callback. If the ACK never comes, - // writeInFlight would stay true forever and the entire command queue (history - // queries, keepalive, …) would deadlock — exactly the "one command then silence" - // failure. Time the write out and unblock the queue. - val seq = ++writeSeq + // Guard against a missing completion callback. If the ACK never comes, opInFlight + // would stay true forever and the entire queue (history queries, keepalive, + // notification setup, …) would deadlock — exactly the "one command then silence" + // failure. Time the op out and unblock the queue. + val seq = ++opSeq scope.launch { - delay(WRITE_TIMEOUT_MS) - if (writeInFlight && seq == writeSeq) { - Log.w("RingBLEClient", "Write ACK timed out — unblocking queue") - writeInFlight = false - pumpWrites() + delay(OP_TIMEOUT_MS) + if (opInFlight && seq == opSeq) { + Log.w("RingBLEClient", "GATT op ACK timed out — unblocking queue") + completeOp() } } } @@ -548,47 +601,13 @@ class RingBLEClient(private val context: Context) { val driver = activeDriver ?: return - // Standard BLE health services — blood pressure (0x1810) + glucose (0x1808) - val bpServiceUuid = java.util.UUID.fromString("00001810-0000-1000-8000-00805f9b34fb") - val bpMeasureUuid = java.util.UUID.fromString("00002a35-0000-1000-8000-00805f9b34fb") - val glucoseServiceUuid = java.util.UUID.fromString("00001808-0000-1000-8000-00805f9b34fb") - val glucoseMeasureUuid = java.util.UUID.fromString("00002a18-0000-1000-8000-00805f9b34fb") - for (service in gatt.services) { - when (service.uuid) { - bpServiceUuid -> { - service.getCharacteristic(bpMeasureUuid)?.let { - gatt.setCharacteristicNotification(it, true) - it.getDescriptor(CCCD_UUID)?.let { desc -> - desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - gatt.writeDescriptor(desc) - } - } - } - glucoseServiceUuid -> { - service.getCharacteristic(glucoseMeasureUuid)?.let { - gatt.setCharacteristicNotification(it, true) - it.getDescriptor(CCCD_UUID)?.let { desc -> - desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - gatt.writeDescriptor(desc) - } - } - } - } - } - - // Read firmware: scan ALL services for 0x2A26/0x2A28. - // The 56ff ring exposes these even without advertising 0x180A DIS. - val fwUuid = java.util.UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb") - val swUuid = java.util.UUID.fromString("00002a28-0000-1000-8000-00805f9b34fb") - var fwRead = false - for (service in gatt.services) { - if (service.uuid == DIS_SERVICE_UUID) { - service.getCharacteristic(FW_REV_UUID)?.let { gatt.readCharacteristic(it); fwRead = true } - } - service.getCharacteristic(fwUuid)?.let { gatt.readCharacteristic(it); fwRead = true } - service.getCharacteristic(swUuid)?.let { gatt.readCharacteristic(it) } - } - + // Bind the ring's own service first and enable its notifications BEFORE any + // other GATT work. The CONNECTED transition is gated on a notify-CCCD descriptor + // write completing (see onDescriptorWrite), and every GATT op now runs strictly + // one-at-a-time via the op queue — so queuing the notify writes first means the + // ring becomes usable as soon as possible instead of waiting behind firmware/ + // battery reads. (This is the R10 fix: its 0x180A DIS firmware read used to be + // issued ahead of the CCCD write and silently dropped it, so it never connected.) for (service in gatt.services) { val svcUuid = service.uuid.toString() val isRingSvc = driver.serviceUUIDs.any { it == svcUuid } @@ -604,23 +623,54 @@ class RingBLEClient(private val context: Context) { notifyChars[ch.uuid] = ch gatt.setCharacteristicNotification(ch, true) ch.getDescriptor(CCCD_UUID)?.let { desc -> - desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - gatt.writeDescriptor(desc) + enqueueOp(GattOp.DescriptorWrite(desc, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)) } } - uuid == driver.batteryCharUUID -> { - batteryChar = ch - gatt.readCharacteristic(ch) - } + uuid == driver.batteryCharUUID -> batteryChar = ch } } } + + // Standard BLE health services — blood pressure (0x1810) + glucose (0x1808). + val bpServiceUuid = java.util.UUID.fromString("00001810-0000-1000-8000-00805f9b34fb") + val bpMeasureUuid = java.util.UUID.fromString("00002a35-0000-1000-8000-00805f9b34fb") + val glucoseServiceUuid = java.util.UUID.fromString("00001808-0000-1000-8000-00805f9b34fb") + val glucoseMeasureUuid = java.util.UUID.fromString("00002a18-0000-1000-8000-00805f9b34fb") + for (service in gatt.services) { + val measureUuid = when (service.uuid) { + bpServiceUuid -> bpMeasureUuid + glucoseServiceUuid -> glucoseMeasureUuid + else -> null + } ?: continue + service.getCharacteristic(measureUuid)?.let { ch -> + gatt.setCharacteristicNotification(ch, true) + ch.getDescriptor(CCCD_UUID)?.let { desc -> + enqueueOp(GattOp.DescriptorWrite(desc, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)) + } + } + } + + // Informational reads come last so they never block the notify-CCCD writes that + // gate CONNECTED. Battery first, then firmware (scan ALL services for 0x2A26/0x2A28; + // the 56ff ring exposes these even without advertising the 0x180A DIS service). + batteryChar?.let { enqueueOp(GattOp.Read(it)) } + + val fwUuid = java.util.UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb") + val swUuid = java.util.UUID.fromString("00002a28-0000-1000-8000-00805f9b34fb") + for (service in gatt.services) { + if (service.uuid == DIS_SERVICE_UUID) { + service.getCharacteristic(FW_REV_UUID)?.let { enqueueOp(GattOp.Read(it)) } + } + service.getCharacteristic(fwUuid)?.let { enqueueOp(GattOp.Read(it)) } + service.getCharacteristic(swUuid)?.let { enqueueOp(GattOp.Read(it)) } + } } override fun onCharacteristicRead( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int ) { - lastActivityAt = System.currentTimeMillis() + lastActivityAt = System.currentTimeMillis() // GATT read completed — link is alive + completeOp() // retire the in-flight op regardless of payload, before any early return if (status != BluetoothGatt.GATT_SUCCESS) return if (characteristic.uuid.toString() == activeDriver?.batteryCharUUID) { val value = characteristic.value @@ -644,8 +694,7 @@ class RingBLEClient(private val context: Context) { gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int ) { lastActivityAt = System.currentTimeMillis() // GATT ACK — link is alive - writeInFlight = false - pumpWrites() + completeOp() } override fun onCharacteristicChanged( @@ -702,6 +751,9 @@ class RingBLEClient(private val context: Context) { override fun onDescriptorWrite( gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int ) { + lastActivityAt = System.currentTimeMillis() // descriptor ACK — link is alive + completeOp() // retire the in-flight op before any early return, so the queue drains + // Notification enabled — fire onConnected once at least one notify is live val driver = activeDriver ?: return val ch = descriptor.characteristic @@ -728,7 +780,6 @@ class RingBLEClient(private val context: Context) { readBattery() scope.launch { onConnected?.invoke() } - pumpWrites() } override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { @@ -744,7 +795,7 @@ class RingBLEClient(private val context: Context) { try { gatt.close() } catch (_: Exception) {} return } - writeInFlight = false; writeQueue.clear() + opInFlight = false; opQueue.clear() stopKeepalive() PulseEventBus.publishBlocking( @@ -773,8 +824,9 @@ class RingBLEClient(private val context: Context) { private const val LINK_STALE_MS = 50_000L /** A CONNECTING attempt that hasn't completed in this long is retried from scratch. */ private const val CONNECT_TIMEOUT_MS = 30_000L - /** Max wait for a write ACK before unblocking the queue (prevents a stuck writeInFlight). */ - private const val WRITE_TIMEOUT_MS = 4_000L + /** Max wait for any GATT op's completion callback before unblocking the queue + * (prevents a stuck opInFlight stranding every subsequent operation). */ + private const val OP_TIMEOUT_MS = 4_000L /** Max wait for the ring's UNBOND_ACK after a forget before forcing teardown. */ private const val UNBIND_ACK_TIMEOUT_MS = 1_500L private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") diff --git a/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt index 35b4597..e7f061e 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt @@ -13,8 +13,32 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.pulseloop.ring.RingBLEClient +import com.pulseloop.ring.RingDeviceType import kotlinx.coroutines.flow.collectLatest +/** + * Friendly model label for the pairing badge. + * + * The whole Colmi/Yawell family (R02, R03, R06, R07, R09, R10, R11, R12, H59…) is served by + * a single driver whose [RingDeviceType] is [RingDeviceType.COLMI_R02], so its `displayName` + * would mislabel e.g. an R10 as "Colmi R02". Derive the real model from the advertised BLE + * name instead ("COLMI R10_1203" → "Colmi R10", "R02_AB12" → "Colmi R02"), falling back to + * the family display name when the name carries no recognizable model token. + */ +private fun ringModelLabel(name: String, deviceType: RingDeviceType): String { + if (deviceType != RingDeviceType.COLMI_R02) return deviceType.displayName + val token = name.trim() + .removePrefix("COLMI ").removePrefix("Colmi ") + .substringBefore('_') + .trim() + // Colmi model tokens look like R02 / R10 / R11C / H59 — letter(s) + digits (+ optional letter). + return if (token.matches(Regex("^[A-Za-z]{1,2}[0-9]{2,3}[A-Za-z]?$"))) { + "Colmi ${token.uppercase()}" + } else { + deviceType.displayName + } +} + /** * Pairing screen — scan for nearby rings and connect. * Ported from PairingView.swift. @@ -165,7 +189,7 @@ fun PairingScreen( Text("${ring.rssi} dBm", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) ring.deviceType?.let { Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.small) { - Text(it.displayName, Modifier.padding(horizontal = 8.dp, vertical = 2.dp), style = MaterialTheme.typography.labelSmall) + Text(ringModelLabel(ring.name, it), Modifier.padding(horizontal = 8.dp, vertical = 2.dp), style = MaterialTheme.typography.labelSmall) } } } From 0530583001f6db0596a94cb8bea07921cc3d0ecb Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Mon, 29 Jun 2026 12:14:45 -0700 Subject: [PATCH 2/6] Add on-demand HR + live SpO2 spot measurement for Colmi rings Colmi rings never showed the Vitals "Measure" button: it was gated on BLOOD_PRESSURE/BLOOD_SUGAR (the Jring combined 0x23 packet), which Colmi hardware doesn't have. Colmi did already support on-demand HR (0x69) but only exposed it via the coach tool, and had no live SpO2 path at all. Add live SpO2 via the real-time command family, then surface a spot measurement (HR + SpO2) both as a Vitals button and automatically on connect. - ColmiEncoder/Protocol: live SpO2 start ([0x69, reading_type=3, START]) and stop ([0x6A, 3, 0, 0]), per the colmi_r02_client real-time protocol. - ColmiDecoder: branch the 0x69 real-time response on reading_type so type 3 decodes to Spo2Result (value at v[3]); the HR path is unchanged. - ColmiSyncEngine: startSpO2/stopSpO2 now drive the live spot command (historical big-data SpO2 sync is untouched). Add MANUAL_SPO2 capability. - RingSyncCoordinator: measureSpot() runs HR then SpO2, capability-gated; autoMeasureOnConnect() fires it ~2s after connect. Wired into onConnected. - Vitals screen: show the Measure button for rings with manual HR/SpO2 (spot mode) alongside the existing combined flow, with its own countdown and "measuring heart rate & SpO2" copy. Verified on a Pixel 8 with an R10: auto-measure on connect populated HR and SpO2 (96%), the value confirmed against the ring's raw 0x69/3 frame, and the manual Measure button runs the spot flow. --- .../java/com/pulseloop/ring/ColmiDecoder.kt | 14 +++++++-- .../java/com/pulseloop/ring/ColmiEncoder.kt | 23 +++++++++++++- .../java/com/pulseloop/ring/ColmiProtocol.kt | 11 ++++++- .../com/pulseloop/ring/ColmiSyncEngine.kt | 13 ++++++-- .../pulseloop/service/RingSyncCoordinator.kt | 31 +++++++++++++++++++ .../java/com/pulseloop/ui/PulseLoopApp.kt | 8 +++-- .../java/com/pulseloop/ui/screens/Screens.kt | 25 ++++++++++----- .../com/pulseloop/ui/viewmodels/ViewModels.kt | 4 +++ 8 files changed, 113 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt b/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt index 44b1283..9ac81a7 100644 --- a/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt @@ -25,11 +25,19 @@ object ColmiDecoder { return when (v[0]) { ColmiCommandID.BATTERY -> listOf(RingDecodedEvent.Battery(percent = v[1].toInt())) ColmiCommandID.MANUAL_HEART_RATE -> { + // Real-time stream: [0x69, reading_type, error, value, …]. + // reading_type selects the metric (HR=1, SpO2=3); error!=0 ends the run. + val readingType = v[1] val errorCode = v[2].toInt() - val bpm = v[3].toInt() + val value = v[3].toInt() + if (readingType == ColmiCommandID.RT_SPO2) { + if (errorCode != 0 || value !in 70..100) return emptyList() // warm-up / noise + return listOf(RingDecodedEvent.Spo2Result(value = value, _timestamp = now)) + } + // Default: heart rate (reading_type == RT_HEART_RATE, or legacy 2-byte request). if (errorCode != 0) return listOf(RingDecodedEvent.HeartRateComplete(_timestamp = now)) - if (bpm !in 30..220) return emptyList() // warm-up (bpm 0) or noise - listOf(RingDecodedEvent.HeartRateSample(bpm = bpm, _timestamp = now)) + if (value !in 30..220) return emptyList() // warm-up (bpm 0) or noise + listOf(RingDecodedEvent.HeartRateSample(bpm = value, _timestamp = now)) } ColmiCommandID.REALTIME_HEART_RATE -> { val bpm = v[1].toInt() diff --git a/app/src/main/java/com/pulseloop/ring/ColmiEncoder.kt b/app/src/main/java/com/pulseloop/ring/ColmiEncoder.kt index ef2d6e0..1c0feaa 100644 --- a/app/src/main/java/com/pulseloop/ring/ColmiEncoder.kt +++ b/app/src/main/java/com/pulseloop/ring/ColmiEncoder.kt @@ -63,6 +63,26 @@ object ColmiEncoder { fun realtimeHeartRateContinue(): ByteArray = byteArrayOf(ColmiCommandID.REALTIME_HEART_RATE.toByte(), 0x03) + /** + * On-demand SpO₂ spot measurement via the real-time command family (0x69/0x6A). + * Start: [0x69, reading_type=SPO2(3), action=START(1)]; the ring streams + * [0x69, 3, error, value, …] frames until stopped. Stop: [0x6A, 3, 0, 0]. + * From colmi_r02_client real_time.py (CMD_START_REAL_TIME=105 / CMD_STOP_REAL_TIME=106). + */ + fun manualSpO2(enable: Boolean): ByteArray = if (enable) { + byteArrayOf( + ColmiCommandID.MANUAL_HEART_RATE.toByte(), + ColmiCommandID.RT_SPO2.toByte(), + ColmiCommandID.RT_ACTION_START.toByte(), + ) + } else { + byteArrayOf( + ColmiCommandID.REALTIME_STOP.toByte(), + ColmiCommandID.RT_SPO2.toByte(), + 0x00, 0x00, + ) + } + fun findDevice(): ByteArray = byteArrayOf(ColmiCommandID.FIND_DEVICE.toByte(), 0x55, 0xAA.toByte()) fun powerOff(): ByteArray = byteArrayOf(ColmiCommandID.POWER_OFF.toByte(), 0x01) fun factoryReset(): ByteArray = byteArrayOf(ColmiCommandID.FACTORY_RESET.toByte(), 0x66, 0x66) @@ -162,7 +182,8 @@ object ColmiCoordinator : WearableCoordinator { WearableCapability.SLEEP, WearableCapability.BATTERY, WearableCapability.REM_SLEEP, WearableCapability.STRESS, WearableCapability.HRV, WearableCapability.TEMPERATURE, - WearableCapability.MANUAL_HEART_RATE, WearableCapability.REALTIME_HEART_RATE, + WearableCapability.MANUAL_HEART_RATE, WearableCapability.MANUAL_SPO2, + WearableCapability.REALTIME_HEART_RATE, WearableCapability.REALTIME_STEPS, WearableCapability.FIND_DEVICE, WearableCapability.POWER_OFF, WearableCapability.FACTORY_RESET, // NOTE: Colmi rings do NOT support blood pressure or blood sugar. diff --git a/app/src/main/java/com/pulseloop/ring/ColmiProtocol.kt b/app/src/main/java/com/pulseloop/ring/ColmiProtocol.kt index cf1b828..a3ee5a4 100644 --- a/app/src/main/java/com/pulseloop/ring/ColmiProtocol.kt +++ b/app/src/main/java/com/pulseloop/ring/ColmiProtocol.kt @@ -36,7 +36,8 @@ object ColmiCommandID { const val BP_READ: UByte = 0x14u const val BP_CONFIRM: UByte = 0x0Eu const val FIND_DEVICE: UByte = 0x50u - const val MANUAL_HEART_RATE: UByte = 0x69u + const val MANUAL_HEART_RATE: UByte = 0x69u // CMD_START_REAL_TIME (105) + const val REALTIME_STOP: UByte = 0x6Au // CMD_STOP_REAL_TIME (106) const val NOTIFICATION: UByte = 0x73u const val BIG_DATA_V2: UByte = 0xBCu const val FACTORY_RESET: UByte = 0xFFu @@ -45,6 +46,14 @@ object ColmiCommandID { const val PREF_WRITE: UByte = 0x02u const val PREF_DELETE: UByte = 0x03u + // Real-time measurement reading types (0x69/0x6A payload byte 0). + // From the colmi_r02_client RealTimeReading enum. + const val RT_HEART_RATE: UByte = 0x01u + const val RT_SPO2: UByte = 0x03u + // Real-time actions (0x69/0x6A payload byte 1). + const val RT_ACTION_START: UByte = 0x01u + const val RT_ACTION_STOP: UByte = 0x02u + // 0x73 notification subtypes const val NOTIF_NEW_HR: UByte = 0x01u const val NOTIF_NEW_SPO2: UByte = 0x03u diff --git a/app/src/main/java/com/pulseloop/ring/ColmiSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/ColmiSyncEngine.kt index 612fed8..896b7c3 100644 --- a/app/src/main/java/com/pulseloop/ring/ColmiSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/ColmiSyncEngine.kt @@ -37,6 +37,7 @@ class ColmiSyncEngine( private var realtimeHRActive = false private var realtimeHRPacketCount = 0 private var manualHRActive = false + private var manualSpO2Active = false companion object { fun isHistoryOpcode(op: UByte): Boolean = @@ -235,10 +236,18 @@ class ColmiSyncEngine( } override fun startSpO2() { - writer?.enqueue(encoder.bigDataSpo2()) + // On-demand live SpO₂ via the real-time command (0x69/3). The ring streams + // [0x69, 3, error, value] frames decoded to Spo2Result. (Historical SpO₂ is a + // separate big-data path, requestSpo2(), used by the startup history sync.) + manualSpO2Active = true + writer?.enqueue(encoder.manualSpO2(enable = true)) } - override fun stopSpO2() {} + override fun stopSpO2() { + if (!manualSpO2Active) return + manualSpO2Active = false + writer?.enqueue(encoder.manualSpO2(enable = false)) + } override fun findDevice() { writer?.enqueue(encoder.findDevice()) diff --git a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt index 2be8e0e..ecc4ad4 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt @@ -73,6 +73,9 @@ class RingSyncCoordinator( companion object { /** Duration of a combined spot measurement (0x23→0x24); also drives the UI countdown. */ const val COMBINED_MEASURE_SECONDS = 45 + /** Upper-bound for a sequential HR+SpO₂ spot measurement; drives the UI countdown. + * Each leg returns early once it gets a reading, so it usually finishes well sooner. */ + const val SPOT_MEASURE_SECONDS = 75 } private val engine: RingSyncEngine? get() = client.syncEngine @@ -221,6 +224,34 @@ class RingSyncCoordinator( // MARK: - Spot measurements + /** + * Fire a one-shot spot measurement right after connect so fresh vitals appear + * immediately, without the user tapping "Measure". Capability-gated: HR for any + * ring that supports a manual reading, SpO₂ for rings that support a manual SpO₂. + * Delayed briefly so the startup/time-sync commands drain through the write queue + * first. Best-effort — failures just leave the previous values in place. + */ + fun autoMeasureOnConnect() { + if (!isConnected) return + scope.launch { + delay(2000) + measureSpot() + } + } + + /** + * Manual spot measurement for rings without the combined 0x23 packet (e.g. Colmi): + * live HR then live SpO₂, each capability-gated, run sequentially through the same + * paths the Today/Vitals views read. Each leg returns early once it gets a reading. + * Used by both [autoMeasureOnConnect] and the Vitals "Measure" button. + */ + suspend fun measureSpot() { + if (!isConnected) return + val caps = client.state.value.activeCapabilities + if (caps.contains(WearableCapability.MANUAL_HEART_RATE)) measureHR() + if (caps.contains(WearableCapability.MANUAL_SPO2)) measureSpO2() + } + suspend fun measureHR(): Int? { if (hrState == MeasureState.MEASURING) return null if (!isConnected) { hrState = MeasureState.FAILED; return null } diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index ec4aff8..88363ca 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -77,8 +77,12 @@ fun PulseLoopApp() { // ── Start services (one-shot on composition) ───────────────────── LaunchedEffect(Unit) { - // Wire onConnected → run startup sequence - bleClient.onConnected = { coordinator.runStartupSequence() } + // Wire onConnected → run startup sequence, then a one-shot spot measurement + // so fresh HR/SpO₂ appear right after pairing without a manual tap. + bleClient.onConnected = { + coordinator.runStartupSequence() + coordinator.autoMeasureOnConnect() + } // Wire firmware read → persist to DB bleClient.onFirmwareRead = { fw -> 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 736a0c5..c26bf66 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/Screens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/Screens.kt @@ -288,7 +288,16 @@ fun VitalsScreen( val scope = rememberCoroutineScope() var measuring by remember { mutableStateOf(false) } var remaining by remember { mutableStateOf(0) } - val measureSeconds = com.pulseloop.service.RingSyncCoordinator.COMBINED_MEASURE_SECONDS + // Two measurement flavours: + // • combined (56ff/Jring): one 0x23 packet → BP + SpO₂ + stress + fatigue + blood sugar + // • spot (Colmi): sequential live HR + SpO₂ via the real-time command (0x69) + // Colmi has no BP/glucose hardware, so it never qualifies for the combined flow. + val combinedMode = state.supportsBP || state.supportsGlucose + val spotMode = !combinedMode && (state.supportsManualHr || state.supportsManualSpo2) + val measureSeconds = if (combinedMode) + com.pulseloop.service.RingSyncCoordinator.COMBINED_MEASURE_SECONDS + else + com.pulseloop.service.RingSyncCoordinator.SPOT_MEASURE_SECONDS LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), @@ -300,10 +309,9 @@ fun VitalsScreen( Text("Vitals", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold) Text("Live measurements and trends", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } - // Combined spot measurement (0x23): one tap captures BP, SpO₂, stress, - // fatigue and blood sugar — the same flow the official app's "Measurement" button uses. - // Only shown for rings that support BP or blood sugar (56ff/Jring). - if (coordinator != null && (state.supportsBP || state.supportsGlucose)) { + // Measure button: combined (0x23) for 56ff/Jring, or sequential live + // HR + SpO₂ (0x69) for Colmi. Hidden for rings that support neither. + if (coordinator != null && (combinedMode || spotMode)) { Button( enabled = !measuring, onClick = { @@ -314,7 +322,7 @@ fun VitalsScreen( while (remaining > 0) { kotlinx.coroutines.delay(1000); remaining-- } } try { - coordinator.measureCombined() + if (combinedMode) coordinator.measureCombined() else coordinator.measureSpot() } finally { ticker.cancel() remaining = 0 @@ -338,7 +346,10 @@ fun VitalsScreen( modifier = Modifier.fillMaxWidth(), ) Text( - "Keep still — measuring blood pressure, SpO₂, stress, fatigue & blood sugar…", + if (combinedMode) + "Keep still — measuring blood pressure, SpO₂, stress, fatigue & blood sugar…" + else + "Keep still — measuring heart rate & SpO₂…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), 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 a47f2e2..c776e3a 100644 --- a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt @@ -230,6 +230,8 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore val supportsTemp: Boolean = false, val supportsBP: Boolean = false, val supportsGlucose: Boolean = false, + val supportsManualHr: Boolean = false, + val supportsManualSpo2: Boolean = false, ) private val _state = MutableStateFlow(VitalsState()) @@ -303,6 +305,8 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore supportsTemp = caps.contains(WearableCapability.TEMPERATURE), supportsBP = caps.contains(WearableCapability.BLOOD_PRESSURE), supportsGlucose = caps.contains(WearableCapability.BLOOD_SUGAR), + supportsManualHr = caps.contains(WearableCapability.MANUAL_HEART_RATE), + supportsManualSpo2 = caps.contains(WearableCapability.MANUAL_SPO2), ) } } From ee26b3e39644a10181aca3f43cedfe6e85dc96fd Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Mon, 29 Jun 2026 15:38:11 -0700 Subject: [PATCH 3/6] Colmi R10: self-update, PHI-safe diagnostics, ring-removal split, fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the R10 pairing fix + measurement feature with the support, observability, and correctness work needed to ship it. Self-update (release-only): - In-app updater polls the GitHub latest release, compares versionCode from the `v+` tag, downloads + installs the universal APK. On-launch (throttled) + a Settings "Check for updates" button. - Tag-driven GitHub Actions workflow builds, signs, and attaches the APKs. - Coexisting `.debug` build (applicationIdSuffix) so a debug install never wipes the release app's data; enable buildConfig; env-overridable signing. Diagnostics + privacy: - Harden the export: own-PID logcat capture + a crash handler (new Application) persisting stack traces, plus accurate build/version info. - PHI scrubbing on by default (health values, ring serial, MAC addresses removed; models/opcodes/control frames/errors kept) with an opt-out toggle for full unmasked BLE frames. Verified masked vs full on-device. Ring removal (restores the iOS two-action model): - "Forget" is now non-destructive (unbind + disconnect only) for both rings — no more power-off/factory-reset wiping a Colmi's history on a normal remove. - New Colmi-only "Factory Reset" syncs latest history first, then resets, behind a confirmation dialog. Fixes: - Persist + prettify the connected ring's name (was always showing the default "SMART_RING"; now "Colmi R10"); shared ringModelLabel. - Live-activity (0x73 0x12) decode read big-endian fields as little-endian, inflating steps to millions and locking a garbage daily total via max-merge. Decode big-endian + plausibility guard + self-heal of a stuck total. - Drop autoMeasureOnConnect: connecting no longer pins the optical sensor on. Matches iOS — connect runs the history sync only; vitals come from the ring's periodic monitoring + the manual Measure button. --- .github/workflows/release.yml | 77 +++++++++ app/build.gradle.kts | 32 +++- app/proguard-rules.pro | 30 ++++ app/src/main/AndroidManifest.xml | 5 + .../com/pulseloop/PulseLoopApplication.kt | 15 ++ .../com/pulseloop/diagnostics/CrashLogger.kt | 44 +++++ .../diagnostics/DiagnosticsExporter.kt | 56 +++++-- .../diagnostics/DiagnosticsRedactor.kt | 41 +++++ .../pulseloop/diagnostics/LogcatCapture.kt | 24 +++ .../java/com/pulseloop/ring/ColmiDecoder.kt | 14 +- .../java/com/pulseloop/ring/ColmiProtocol.kt | 2 + .../java/com/pulseloop/ring/PulseEventBus.kt | 2 +- .../java/com/pulseloop/ring/RingBLEClient.kt | 8 +- .../java/com/pulseloop/ring/RingDisplay.kt | 24 +++ .../service/EventPersistenceSubscriber.kt | 16 +- .../pulseloop/service/RingSyncCoordinator.kt | 65 ++++--- .../java/com/pulseloop/ui/PulseLoopApp.kt | 19 ++- .../com/pulseloop/ui/screens/DebugScreen.kt | 25 ++- .../com/pulseloop/ui/screens/PairingScreen.kt | 25 +-- .../pulseloop/ui/screens/SettingsScreen.kt | 73 +++++++- .../java/com/pulseloop/update/ApkInstaller.kt | 37 ++++ .../com/pulseloop/update/UpdateChecker.kt | 158 ++++++++++++++++++ .../java/com/pulseloop/update/UpdateUi.kt | 123 ++++++++++++++ 23 files changed, 827 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/java/com/pulseloop/PulseLoopApplication.kt create mode 100644 app/src/main/java/com/pulseloop/diagnostics/CrashLogger.kt create mode 100644 app/src/main/java/com/pulseloop/diagnostics/DiagnosticsRedactor.kt create mode 100644 app/src/main/java/com/pulseloop/diagnostics/LogcatCapture.kt create mode 100644 app/src/main/java/com/pulseloop/ring/RingDisplay.kt create mode 100644 app/src/main/java/com/pulseloop/update/ApkInstaller.kt create mode 100644 app/src/main/java/com/pulseloop/update/UpdateChecker.kt create mode 100644 app/src/main/java/com/pulseloop/update/UpdateUi.kt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b4ccc17 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +name: Release + +# Tag-driven: push a tag like `v1.0.0+5` (versionName `+` versionCode) and this builds, +# signs, and attaches the universal APKs to a GitHub release for that tag. +# - versionCode (the integer after `+`) is what the in-app updater compares. +# - versionName (before `+`) is display-only; change it freely. +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Parse version from tag + id: ver + run: | + TAG="${GITHUB_REF_NAME}" # e.g. v1.0.0+5 + NAME="${TAG#v}"; NAME="${NAME%%+*}" # 1.0.0 + CODE="${TAG##*+}" # 5 + if ! [[ "$CODE" =~ ^[0-9]+$ ]]; then + echo "::error::Tag must be v+, e.g. v1.0.0+5 (got '$TAG')" + exit 1 + fi + echo "name=$NAME" >> "$GITHUB_OUTPUT" + echo "code=$CODE" >> "$GITHUB_OUTPUT" + echo "Building versionName=$NAME versionCode=$CODE" + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Decode release keystore + env: + KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + run: echo "$KEYSTORE_BASE64" | base64 --decode > "${{ github.workspace }}/app/release-ci.keystore" + + - name: Build signed release + debug universal APKs + env: + RELEASE_STORE_FILE: ${{ github.workspace }}/app/release-ci.keystore + RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + run: | + ./gradlew --no-daemon \ + -PappVersionName=${{ steps.ver.outputs.name }} \ + -PappVersionCode=${{ steps.ver.outputs.code }} \ + :app:assembleRelease :app:assembleDebug + + - name: Stage APKs for upload + id: stage + run: | + V="${{ steps.ver.outputs.name }}+${{ steps.ver.outputs.code }}" + mkdir -p dist + # The universal release APK is the one the in-app updater installs (must keep + # "universal" in the name so the updater's asset selector picks it). + cp app/build/outputs/apk/release/app-universal-release.apk "dist/pulseloop-${V}-universal.apk" + # Debug universal APK: a coexisting (.debug) build for gathering diagnostics. + cp app/build/outputs/apk/debug/app-universal-debug.apk "dist/pulseloop-${V}-debug.apk" + ls -la dist + + - name: Create / update release and attach APKs + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: PulseLoop ${{ steps.ver.outputs.name }} (${{ steps.ver.outputs.code }}) + generate_release_notes: true + files: dist/*.apk diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6d06765..e69a258 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,9 +14,19 @@ android { applicationId = "com.pulseloop" minSdk = 26 targetSdk = 35 - versionCode = 4 - versionName = "1.0.0" + // versionCode/versionName are overridable from Gradle properties so the release CI + // can drive them straight from the git tag (e.g. -PappVersionCode=5 -PappVersionName=1.0.0). + // Local builds fall back to the literals below. + versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 4 + versionName = (project.findProperty("appVersionName") as String?) ?: "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // Repo the self-updater polls for new releases. + buildConfigField("String", "GITHUB_REPO", "\"foureight84/PulseLoopAndroid\"") + } + + buildFeatures { + buildConfig = true } splits { @@ -30,10 +40,13 @@ android { signingConfigs { create("release") { - storeFile = file("pulseloop-release.keystore") - storePassword = "pulseloop" - keyAlias = "pulseloop" - keyPassword = "pulseloop" + // Keystore + passwords are overridable from the environment so CI can supply a + // decoded keystore + secrets without committing them. Local builds fall back to + // the checked-out keystore and the existing literals. + storeFile = file(System.getenv("RELEASE_STORE_FILE") ?: "pulseloop-release.keystore") + storePassword = System.getenv("RELEASE_STORE_PASSWORD") ?: "pulseloop" + keyAlias = System.getenv("RELEASE_KEY_ALIAS") ?: "pulseloop" + keyPassword = System.getenv("RELEASE_KEY_PASSWORD") ?: "pulseloop" } } @@ -43,6 +56,13 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("release") } + debug { + // Distinct applicationId so a debug build installs ALONGSIDE the release app + // (com.pulseloop.debug) instead of replacing it — which would wipe the release + // app's data, since the two are signed with different keys. + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + } } lint { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..9abf680 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,30 @@ +# kotlinx.serialization — keep generated serializers so JSON parsing survives R8. +# (Modern kotlinx-serialization ships consumer rules, but these make it explicit.) +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault + +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# Self-update models parsed from the GitHub releases API. +-keep,includedescriptorclasses class com.pulseloop.update.**$$serializer { *; } +-keepclassmembers class com.pulseloop.update.** { + *** Companion; +} +-keepclasseswithmembers class com.pulseloop.update.** { + kotlinx.serialization.KSerializer serializer(...); +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 46e0a20..fc46413 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,7 +21,12 @@ + + + + + try { writeCrash(appContext, thread, throwable) } catch (_: Throwable) {} + previous?.uncaughtException(thread, throwable) + } + } + + private fun writeCrash(context: Context, thread: Thread, throwable: Throwable) { + val dir = File(context.filesDir, DIR).apply { mkdirs() } + val sw = StringWriter() + PrintWriter(sw).use { throwable.printStackTrace(it) } + val stamp = Instant.now().toString().replace(":", "-") + File(dir, "crash-$stamp.txt").writeText("at: ${Instant.now()}\nthread: ${thread.name}\n\n$sw") + // Keep only the most recent reports. + dir.listFiles()?.sortedByDescending { it.lastModified() }?.drop(MAX_FILES)?.forEach { it.delete() } + } + + /** Most-recent crash reports (newest first) as (filename, trace) pairs, for the export. */ + fun recentCrashes(context: Context, max: Int = 5): List> { + val dir = File(context.filesDir, DIR) + val files = dir.listFiles()?.sortedByDescending { it.lastModified() } ?: return emptyList() + return files.take(max).map { it.name to it.readText() } + } +} diff --git a/app/src/main/java/com/pulseloop/diagnostics/DiagnosticsExporter.kt b/app/src/main/java/com/pulseloop/diagnostics/DiagnosticsExporter.kt index ec46b99..383a654 100644 --- a/app/src/main/java/com/pulseloop/diagnostics/DiagnosticsExporter.kt +++ b/app/src/main/java/com/pulseloop/diagnostics/DiagnosticsExporter.kt @@ -18,15 +18,24 @@ object DiagnosticsExporter { /** * Serialize a diagnostics report to pretty-printed JSON. + * + * [mask] (default true) scrubs PHI/PII: physiological values, the ring serial suffix, and + * MAC addresses are removed while opcodes / decoded kinds / control frames / UUIDs / errors + * are kept (see [DiagnosticsRedactor]). Pass false only to capture full unmasked BLE frames + * for deep protocol debugging. */ - suspend fun exportJSON(db: PulseLoopDatabase, maxLogs: Int = 500): String { + suspend fun exportJSON(context: Context, db: PulseLoopDatabase, mask: Boolean = true, maxLogs: Int = 500): String { val device = db.deviceDao().current() val logs = db.wearableLogDao().recent(maxLogs) val root = buildJsonObject { put("generatedAt", Instant.now().toString()) + put("redacted", mask) putJsonObject("app") { - put("version", "1.0.0") + put("version", com.pulseloop.BuildConfig.VERSION_NAME) + put("versionCode", com.pulseloop.BuildConfig.VERSION_CODE) + put("buildType", if (com.pulseloop.BuildConfig.DEBUG) "debug" else "release") + put("applicationId", com.pulseloop.BuildConfig.APPLICATION_ID) put("platform", "Android") put("sdkVersion", Build.VERSION.SDK_INT) } @@ -37,7 +46,7 @@ object DiagnosticsExporter { put("osVersion", Build.VERSION.RELEASE) if (device != null) { put("wearableType", device.deviceTypeRaw) - put("wearableName", device.name) + put("wearableName", if (mask) DiagnosticsRedactor.maskRingName(device.name) else device.name) put("capabilities", device.capabilitiesRaw) put("firmware", device.firmwareVersion ?: "?") put("lastSyncAt", device.lastSyncAt?.let { Instant.ofEpochMilli(it).toString() } ?: "") @@ -50,39 +59,60 @@ object DiagnosticsExporter { put("at", Instant.ofEpochMilli(log.timestamp).toString()) put("category", log.categoryRaw) put("level", log.levelRaw) - put("message", log.message) - log.metadataJSON?.let { if (it != "null") put("metadata", it) } + put("message", if (mask) DiagnosticsRedactor.scrubText(log.message) else log.message) + log.metadataJSON?.let { + if (it != "null") put("metadata", if (mask) DiagnosticsRedactor.scrubText(it) else it) + } if (log.deviceTypeRaw.isNotEmpty()) put("deviceType", log.deviceTypeRaw) } } } - // Raw BLE packets for protocol debugging + // Raw BLE packets for protocol debugging. When masking, health-measurement frames + // are reduced to their opcode (the values live in the payload); control frames stay + // whole so connection/pairing flow is still fully visible. val packets = db.rawPacketDao().recent(200) putJsonArray("rawPackets") { packets.forEach { pkt -> + val kind = pkt.decodedKind ?: "" addJsonObject { put("at", Instant.ofEpochMilli(pkt.timestamp).toString()) put("direction", pkt.directionRaw) - put("hex", pkt.hexPayload) - put("decoded", pkt.decodedKind ?: "") + put("hex", if (mask) DiagnosticsRedactor.maskPacketHex(pkt.hexPayload, kind) else pkt.hexPayload) + put("decoded", kind) } } } + + // Recent crash stack traces (uncaught exceptions persisted by CrashLogger). + putJsonArray("crashes") { + CrashLogger.recentCrashes(context).forEach { (name, trace) -> + addJsonObject { + put("file", name) + put("trace", trace) + } + } + } + + // This process's own logcat — app logs + in-process BluetoothGatt callbacks. + // MAC addresses are scrubbed when masking. + val logcat = LogcatCapture.ownProcessLog() + put("logcat", if (mask) DiagnosticsRedactor.scrubText(logcat) else logcat) } return json.encodeToString(JsonObject.serializer(), root) } - suspend fun exportFile(context: Context, db: PulseLoopDatabase): File { - val report = exportJSON(db) + suspend fun exportFile(context: Context, db: PulseLoopDatabase, mask: Boolean = true): File { + val report = exportJSON(context, db, mask) val stamp = Instant.now().toString().replace(":", "-") - val file = File(context.cacheDir, "pulseloop-diagnostics-$stamp.json") + val suffix = if (mask) "" else "-full" + val file = File(context.cacheDir, "pulseloop-diagnostics-$stamp$suffix.json") file.writeText(report) return file } - suspend fun shareIntent(context: Context, db: PulseLoopDatabase): Intent { - val file = exportFile(context, db) + suspend fun shareIntent(context: Context, db: PulseLoopDatabase, mask: Boolean = true): Intent { + val file = exportFile(context, db, mask) val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", diff --git a/app/src/main/java/com/pulseloop/diagnostics/DiagnosticsRedactor.kt b/app/src/main/java/com/pulseloop/diagnostics/DiagnosticsRedactor.kt new file mode 100644 index 0000000..d0434a4 --- /dev/null +++ b/app/src/main/java/com/pulseloop/diagnostics/DiagnosticsRedactor.kt @@ -0,0 +1,41 @@ +package com.pulseloop.diagnostics + +/** + * Scrubs PHI/PII from a diagnostics report while keeping it useful for debugging. + * + * What's removed: physiological values (HR, SpO₂, BP, glucose, temperature, stress, HRV, + * sleep, activity), the ring's serial suffix, and BLE MAC addresses. + * What's kept: device + ring model, Android version, firmware, capabilities, command + * opcodes, decoded kinds, error/status frames, service UUIDs, timestamps — i.e. everything + * needed to follow the protocol flow and spot the error, just not the measured numbers. + * + * Health-measurement BLE frames carry the values in their payload, so those are reduced to + * the opcode byte. Control/protocol frames (acks, status, time-sync, battery, firmware, bind) + * carry no vitals and are kept whole — that's the data most connection/pairing bugs need. + */ +object DiagnosticsRedactor { + /** Decoded kinds whose BLE payload carries a physiological/health value → mask payload. */ + private val HEALTH_KINDS = setOf( + "activity", "activity_bucket", "hr_sample", "spo2_progress", "spo2_result", + "sleep_timeline", "history_measurement", "stress_sample", "hrv_sample", "temperature_sample", + ) + + private val MAC = Regex("\\b([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\\b") + private val RING_SERIAL_SUFFIX = Regex("_[0-9A-Fa-f]{3,}$") + + /** + * For a health-measurement frame, keep the opcode byte and mask the rest (the values live + * in the payload). Non-health frames are returned unchanged. [hex] is contiguous lowercase. + */ + fun maskPacketHex(hex: String, kind: String): String { + if (kind !in HEALTH_KINDS || hex.length <= 2) return hex + val byteCount = hex.length / 2 + return hex.substring(0, 2) + "··".repeat(byteCount - 1) + } + + /** Mask BLE MAC addresses anywhere in free text (logcat, log messages, metadata). */ + fun scrubText(text: String): String = MAC.replace(text, "··:··:··:··:··:··") + + /** Strip the ring's serial suffix, keeping the model (e.g. "COLMI R10_1203" → "COLMI R10"). */ + fun maskRingName(name: String): String = RING_SERIAL_SUFFIX.replace(name, "") +} diff --git a/app/src/main/java/com/pulseloop/diagnostics/LogcatCapture.kt b/app/src/main/java/com/pulseloop/diagnostics/LogcatCapture.kt new file mode 100644 index 0000000..69e510a --- /dev/null +++ b/app/src/main/java/com/pulseloop/diagnostics/LogcatCapture.kt @@ -0,0 +1,24 @@ +package com.pulseloop.diagnostics + +import android.os.Process + +/** + * Captures this process's own logcat. An app may read logcat entries for its OWN pid without + * the privileged READ_LOGS permission, which covers app logs (e.g. RingBLEClient) plus the + * in-process BluetoothGatt client callbacks — most of what BLE debugging needs. The separate + * system Bluetooth process's logs are not readable here (that needs adb / a debug build). + */ +object LogcatCapture { + fun ownProcessLog(maxLines: Int = 2000): String = try { + val pid = Process.myPid() + val proc = ProcessBuilder("logcat", "-d", "-v", "time", "--pid=$pid") + .redirectErrorStream(true) + .start() + val text = proc.inputStream.bufferedReader().use { it.readText() } + proc.waitFor() + val lines = text.lines() + if (lines.size > maxLines) lines.takeLast(maxLines).joinToString("\n") else text + } catch (e: Exception) { + "logcat capture failed: ${e.message}" + } +} diff --git a/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt b/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt index 9ac81a7..2a4a2e5 100644 --- a/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/ColmiDecoder.kt @@ -80,10 +80,16 @@ object ColmiDecoder { private fun decodeNotification(v: List, now: Instant): List = when (v[1]) { ColmiCommandID.NOTIF_BATTERY -> listOf(RingDecodedEvent.Battery(percent = v[2].toInt())) ColmiCommandID.NOTIF_LIVE_ACTIVITY -> { - val steps = ColmiBytes.u24(v[2], v[3], v[4]) - val calories = ColmiBytes.u24(v[5], v[6], v[7]).toInt() / 10 - val distance = ColmiBytes.u24(v[8], v[9], v[10]).toInt() - listOf(RingDecodedEvent.ActivityUpdate( + // Live activity (0x73 0x12) packs steps / calories / distance as BIG-endian u24 + // (verified against on-ring frames). Reading them little-endian inflated steps to + // millions and, via the daily max-merge, locked in a garbage Today total. + val steps = ColmiBytes.u24be(v[2], v[3], v[4]) + val calories = ColmiBytes.u24be(v[5], v[6], v[7]) / 1000 // field is in calories → kcal + val distance = ColmiBytes.u24be(v[8], v[9], v[10]) // meters + // Plausibility guard: drop any frame with an impossible step count so a single + // bad packet can never poison the day's total again. + if (steps !in 0..200_000) listOf(RingDecodedEvent.CommandAck(commandId = v[0])) + else listOf(RingDecodedEvent.ActivityUpdate( _timestamp = now, steps = steps, distanceMeters = distance, calories = calories )) } diff --git a/app/src/main/java/com/pulseloop/ring/ColmiProtocol.kt b/app/src/main/java/com/pulseloop/ring/ColmiProtocol.kt index a3ee5a4..793bf65 100644 --- a/app/src/main/java/com/pulseloop/ring/ColmiProtocol.kt +++ b/app/src/main/java/com/pulseloop/ring/ColmiProtocol.kt @@ -117,4 +117,6 @@ object ColmiBytes { a.toInt() or (b.toInt() shl 8) or (c.toInt() shl 16) or (d.toInt() shl 24) fun u24(a: UByte, b: UByte, c: UByte): Int = a.toInt() or (b.toInt() shl 8) or (c.toInt() shl 16) + fun u24be(a: UByte, b: UByte, c: UByte): Int = + (a.toInt() shl 16) or (b.toInt() shl 8) or c.toInt() } diff --git a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt index 28cdd10..00b4f92 100644 --- a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt +++ b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.asSharedFlow * Typed events published on the bus for subscribers to consume. */ sealed class PulseEvent { - data class DeviceStateChanged(val state: RingConnectionState, val address: String?, val firmware: String? = null) : PulseEvent() + data class DeviceStateChanged(val state: RingConnectionState, val address: String?, val firmware: String? = null, val name: String? = null) : PulseEvent() data class DeviceIdentified(val deviceType: RingDeviceType, val capabilities: Set) : PulseEvent() data class BatteryLevel(val percent: Int) : PulseEvent() data class RawPacket(val direction: PacketDirection, val data: ByteArray, val decoded: RingDecodedEvent) : PulseEvent() { diff --git a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt index 62c8d79..ba67af8 100644 --- a/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt +++ b/app/src/main/java/com/pulseloop/ring/RingBLEClient.kt @@ -75,6 +75,9 @@ class RingBLEClient(private val context: Context) { private var bluetoothGatt: BluetoothGatt? = null private var discoveredPeripherals: MutableMap = mutableMapOf() + // Advertised name of the device we're connecting to, captured at connect time so it can be + // persisted as the device's display name (the connect callbacks otherwise only know the MAC). + private var connectingName: String? = null // Characteristics private var writeChar: BluetoothGattCharacteristic? = null @@ -185,6 +188,7 @@ class RingBLEClient(private val context: Context) { return } val matchedType = _state.value.discovered.firstOrNull { it.id == id }?.deviceType + connectingName = _state.value.discovered.firstOrNull { it.id == id }?.name ?: target.name beginConnect(target, matchedType) } @@ -770,7 +774,9 @@ class RingBLEClient(private val context: Context) { .apply() PulseEventBus.publishBlocking( - PulseEvent.DeviceStateChanged(RingConnectionState.CONNECTED, device.address) + PulseEvent.DeviceStateChanged( + RingConnectionState.CONNECTED, device.address, name = device.name ?: connectingName + ) ) activeCoordinator?.let { coord -> PulseEventBus.publishBlocking( diff --git a/app/src/main/java/com/pulseloop/ring/RingDisplay.kt b/app/src/main/java/com/pulseloop/ring/RingDisplay.kt new file mode 100644 index 0000000..c7de64d --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/RingDisplay.kt @@ -0,0 +1,24 @@ +package com.pulseloop.ring + +/** + * Friendly model label for a ring. + * + * The whole Colmi/Yawell family (R02, R03, R06, R07, R09, R10, R11, R12, H59…) is served by a + * single driver whose [RingDeviceType] is [RingDeviceType.COLMI_R02], so `displayName` alone + * would mislabel e.g. an R10 as "Colmi R02". Derive the real model from the advertised BLE name + * when possible ("COLMI R10_1203" → "Colmi R10", "R02_AB12" → "Colmi R02"), falling back to the + * family display name when the name carries no recognizable model token. + */ +fun ringModelLabel(name: String?, deviceType: RingDeviceType?): String { + val type = deviceType ?: return name?.takeIf { it.isNotBlank() } ?: "Ring" + if (type != RingDeviceType.COLMI_R02) return type.displayName + val token = (name ?: "").trim() + .removePrefix("COLMI ").removePrefix("Colmi ") + .substringBefore('_') + .trim() + return if (token.matches(Regex("^[A-Za-z]{1,2}[0-9]{2,3}[A-Za-z]?$"))) { + "Colmi ${token.uppercase()}" + } else { + type.displayName + } +} diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index b76c743..b95216c 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -54,6 +54,11 @@ class EventPersistenceSubscriber( } db.deviceDao().upsert(device.copy( stateRaw = state, + // Capture the connected ring's advertised name so the UI reflects the actual + // device instead of the DeviceEntity default. Falls back to the existing name + // for events that don't carry one (e.g. disconnect). + name = event.name?.takeIf { it.isNotBlank() } ?: device.name, + advertisedName = event.name?.takeIf { it.isNotBlank() } ?: device.advertisedName, bleAddressHint = event.address ?: device.bleAddressHint, // 0x0C device-info already gives the complete "V" string. firmwareVersion = event.firmware ?: device.firmwareVersion, @@ -163,10 +168,15 @@ class EventPersistenceSubscriber( val dayStart = com.pulseloop.util.TimeUtil.startOfDayLocal(ts) val existing = db.activityDailyDao().byDay(dayStart) if (existing != null) { + // Normally keep the running daily max (steps only climb through the day). But if the + // stored total is implausibly high — e.g. a value locked in by the old little-endian + // live-activity decode before this fix — overwrite it so the day self-heals instead + // of staying stuck at a garbage number until midnight. + val stale = existing.steps > 200_000 db.activityDailyDao().upsert(existing.copy( - steps = maxOf(existing.steps, steps), - calories = maxOf(existing.calories, calories), - distanceMeters = maxOf(existing.distanceMeters, distanceM), + steps = if (stale) steps else maxOf(existing.steps, steps), + calories = if (stale) calories else maxOf(existing.calories, calories), + distanceMeters = if (stale) distanceM else maxOf(existing.distanceMeters, distanceM), updatedAt = System.currentTimeMillis(), )) } else { diff --git a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt index ecc4ad4..271f47a 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first /** * Ported from [RingSyncCoordinator] in RingSyncCoordinator.swift. @@ -76,6 +77,8 @@ class RingSyncCoordinator( /** Upper-bound for a sequential HR+SpO₂ spot measurement; drives the UI countdown. * Each leg returns early once it gets a reading, so it usually finishes well sooner. */ const val SPOT_MEASURE_SECONDS = 75 + /** Max time to wait for the pre-factory-reset history sync before resetting anyway. */ + const val SYNC_BEFORE_RESET_TIMEOUT_MS = 30_000L } private val engine: RingSyncEngine? get() = client.syncEngine @@ -193,17 +196,43 @@ class RingSyncCoordinator( /** Send ring-side unpair commands (power-off, factory reset if supported), * then disconnect and forget. */ + /** + * Non-destructive: unbind + disconnect + drop the ring from the app. The ring keeps all + * of its on-device data, stays powered on, and can be re-paired immediately. Does NOT + * power off or factory-reset — that would wipe a Colmi ring's unsynced history and leave + * it dark until charged. For a true wipe use [factoryResetRing]. + */ fun forgetRing(onCleared: () -> Unit) { - val caps = client.state.value.activeCapabilities - if (caps.contains(WearableCapability.POWER_OFF)) { - engine?.powerOff() - } - if (caps.contains(WearableCapability.FACTORY_RESET)) { - engine?.factoryReset() + // client.forget() already sends the protocol unbind, waits for the ack, removes any + // OS bond, and clears the stored peripheral. That is the whole forget. + scope.launch { + client.forget() + stop() + onCleared() } - // Give the ring a moment to process, then disconnect + forget + } + + /** + * Destructive: wipe the ring's on-device storage. Because a Colmi ring buffers days of + * unsynced history, we sync the latest data into the app FIRST, then send the factory + * reset, then forget. Gate this on the ring's FACTORY_RESET capability at the call site. + * [onProgress] receives a short status for the UI; [onCleared] fires when fully done. + */ + fun factoryResetRing(onProgress: (String) -> Unit = {}, onCleared: () -> Unit) { scope.launch { - kotlinx.coroutines.delay(500) + if (isConnected) { + onProgress("Syncing latest data…") + runStartupSequence() + // Wait for the history sync to drain (progress reaches 100 or clears), capped + // so a stale link can never hang the reset. + kotlinx.coroutines.withTimeoutOrNull(SYNC_BEFORE_RESET_TIMEOUT_MS) { + syncProgress.first { it == null || it >= 100 } + } + kotlinx.coroutines.delay(800) // let the final history writes flush + onProgress("Resetting ring…") + engine?.factoryReset() + kotlinx.coroutines.delay(600) // let the reset command reach the ring + } client.forget() stop() onCleared() @@ -224,26 +253,14 @@ class RingSyncCoordinator( // MARK: - Spot measurements - /** - * Fire a one-shot spot measurement right after connect so fresh vitals appear - * immediately, without the user tapping "Measure". Capability-gated: HR for any - * ring that supports a manual reading, SpO₂ for rings that support a manual SpO₂. - * Delayed briefly so the startup/time-sync commands drain through the write queue - * first. Best-effort — failures just leave the previous values in place. - */ - fun autoMeasureOnConnect() { - if (!isConnected) return - scope.launch { - delay(2000) - measureSpot() - } - } - /** * Manual spot measurement for rings without the combined 0x23 packet (e.g. Colmi): * live HR then live SpO₂, each capability-gated, run sequentially through the same * paths the Today/Vitals views read. Each leg returns early once it gets a reading. - * Used by both [autoMeasureOnConnect] and the Vitals "Measure" button. + * + * Triggered only by the Vitals "Measure" button. Matching iOS, connecting does NOT + * auto-measure — the ring does its own low-power periodic monitoring (pulled in via the + * history sync), so we never pin the optical sensor on just for connecting. */ suspend fun measureSpot() { if (!isConnected) return diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index 88363ca..4ecafbd 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -77,12 +77,10 @@ fun PulseLoopApp() { // ── Start services (one-shot on composition) ───────────────────── LaunchedEffect(Unit) { - // Wire onConnected → run startup sequence, then a one-shot spot measurement - // so fresh HR/SpO₂ appear right after pairing without a manual tap. - bleClient.onConnected = { - coordinator.runStartupSequence() - coordinator.autoMeasureOnConnect() - } + // Wire onConnected → run the startup/history sync only (matches iOS). Connecting + // does NOT force a measurement; the ring's own periodic monitoring is pulled in via + // history, and on-demand readings come from the Vitals "Measure" button. + bleClient.onConnected = { coordinator.runStartupSequence() } // Wire firmware read → persist to DB bleClient.onFirmwareRead = { fw -> @@ -155,6 +153,11 @@ fun PulseLoopApp() { Tab("coach", "Coach", Icons.Filled.AutoAwesome, Icons.Outlined.AutoAwesome), ) + // ── Self-update: release-only, throttled to once/day. Surfaces a dialog when a + // newer GitHub release is published; Settings also has a manual "Check for updates". + var pendingUpdate by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { pendingUpdate = com.pulseloop.update.UpdateChecker.check(context) } + Scaffold( bottomBar = { NavigationBar { @@ -239,6 +242,10 @@ fun PulseLoopApp() { } } } + + pendingUpdate?.let { info -> + com.pulseloop.update.UpdateDialog(info) { pendingUpdate = null } + } } } diff --git a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt index b3a94c2..5ebd294 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt @@ -173,19 +173,38 @@ fun DebugScreen( // ── Diagnostics Export ────────────────────────────────────── item { + // Default ON every time: the export is always privacy-safe unless the user + // explicitly opts out for this export. Never persists "off". + var maskSensitive by remember { mutableStateOf(true) } Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text("Diagnostics", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Spacer(Modifier.height(8.dp)) - Text("Export diagnostics report with app, device, and wearable log info as JSON.", + Text("Export app, device, and wearable logs as JSON.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Mask sensitive data", style = MaterialTheme.typography.bodyMedium) + Text( + if (maskSensitive) + "Removes health values, ring serial & MAC addresses. Keeps models, opcodes & errors." + else + "OFF — includes full unmasked BLE frames (for protocol debugging only).", + style = MaterialTheme.typography.bodySmall, + color = if (maskSensitive) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.error, + ) + } + Switch(checked = maskSensitive, onCheckedChange = { maskSensitive = it }) + } + Spacer(Modifier.height(12.dp)) Button( onClick = { scope.launch { val db = PulseLoopDatabase.getInstance(context) try { - val intent = DiagnosticsExporter.shareIntent(context, db) + val intent = DiagnosticsExporter.shareIntent(context, db, mask = maskSensitive) context.startActivity(intent) } catch (_: Exception) {} } @@ -194,7 +213,7 @@ fun DebugScreen( ) { Icon(Icons.Filled.Share, null) Spacer(Modifier.width(8.dp)) - Text("Export Diagnostics") + Text(if (maskSensitive) "Export Diagnostics" else "Export Full (Unmasked)") } } } diff --git a/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt index e7f061e..bf68d10 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/PairingScreen.kt @@ -13,32 +13,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.pulseloop.ring.RingBLEClient -import com.pulseloop.ring.RingDeviceType +import com.pulseloop.ring.ringModelLabel import kotlinx.coroutines.flow.collectLatest -/** - * Friendly model label for the pairing badge. - * - * The whole Colmi/Yawell family (R02, R03, R06, R07, R09, R10, R11, R12, H59…) is served by - * a single driver whose [RingDeviceType] is [RingDeviceType.COLMI_R02], so its `displayName` - * would mislabel e.g. an R10 as "Colmi R02". Derive the real model from the advertised BLE - * name instead ("COLMI R10_1203" → "Colmi R10", "R02_AB12" → "Colmi R02"), falling back to - * the family display name when the name carries no recognizable model token. - */ -private fun ringModelLabel(name: String, deviceType: RingDeviceType): String { - if (deviceType != RingDeviceType.COLMI_R02) return deviceType.displayName - val token = name.trim() - .removePrefix("COLMI ").removePrefix("Colmi ") - .substringBefore('_') - .trim() - // Colmi model tokens look like R02 / R10 / R11C / H59 — letter(s) + digits (+ optional letter). - return if (token.matches(Regex("^[A-Za-z]{1,2}[0-9]{2,3}[A-Za-z]?$"))) { - "Colmi ${token.uppercase()}" - } else { - deviceType.displayName - } -} - /** * Pairing screen — scan for nearby rings and connect. * Ported from PairingView.swift. diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt index 08121aa..7bc7fe6 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt @@ -594,6 +594,12 @@ fun SettingsScreen( // rather than a value frozen at screen-open. val device = db.deviceDao().currentFlow().collectAsState(initial = null) val isConnected = device.value?.stateRaw == "CONNECTED" + val bleState = bleClient?.state?.collectAsState() + val supportsFactoryReset = bleState?.value?.activeCapabilities + ?.contains(com.pulseloop.ring.WearableCapability.FACTORY_RESET) == true + var showFactoryReset by remember { mutableStateOf(false) } + var resetting by remember { mutableStateOf(false) } + var resetStatus by remember { mutableStateOf("") } Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Filled.Bluetooth, null, Modifier.size(18.dp), @@ -601,8 +607,8 @@ fun SettingsScreen( ) Spacer(Modifier.width(8.dp)) Text( - if (isConnected) "Connected — ${device.value?.name ?: "Ring"} · ${device.value?.batteryPercent ?: 0}%" - else device.value?.let { "Last seen: ${it.name}" } ?: "No ring paired", + if (isConnected) "Connected — ${com.pulseloop.ring.ringModelLabel(device.value?.name, device.value?.deviceType)} · ${device.value?.batteryPercent ?: 0}%" + else device.value?.let { "Last seen: ${com.pulseloop.ring.ringModelLabel(it.name, it.deviceType)}" } ?: "No ring paired", style = MaterialTheme.typography.bodyMedium, ) } @@ -651,6 +657,62 @@ fun SettingsScreen( Spacer(Modifier.width(4.dp)) Text("Forget Ring") } + + // Factory reset — destructive, ring-side wipe. Only for connected rings that + // support it (Colmi). Syncs latest history first so nothing is lost. + if (isConnected && supportsFactoryReset) { + Spacer(Modifier.height(8.dp)) + Text( + "Factory reset erases all data stored on the ring itself and resets " + + "it to factory state. Your synced data in the app is kept.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + OutlinedButton( + enabled = !resetting, + onClick = { showFactoryReset = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Icon(Icons.Filled.Refresh, null, Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(if (resetting) (resetStatus.ifEmpty { "Resetting…" }) else "Factory Reset Ring") + } + } + } + + if (showFactoryReset) { + AlertDialog( + onDismissRequest = { showFactoryReset = false }, + title = { Text("Factory reset ring?") }, + text = { + Text( + "This erases all data stored on the ring and resets it to factory " + + "state. We'll sync its latest data into the app first, but this " + + "can't be undone. The ring will need to be re-paired afterward." + ) + }, + confirmButton = { + TextButton(onClick = { + showFactoryReset = false + resetting = true + resetStatus = "Syncing latest data…" + RingSyncWorker.cancel(context) + coordinator?.factoryResetRing( + onProgress = { resetStatus = it }, + ) { + scope.launch { + db.deviceDao().clear() + resetting = false + } + } + }) { Text("Reset ring", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showFactoryReset = false }) { Text("Cancel") } + }, + ) } } } @@ -699,12 +761,17 @@ fun SettingsScreen( Column(Modifier.padding(16.dp)) { Text("About", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Spacer(Modifier.height(4.dp)) - Text("PulseLoop v1.0.0", style = MaterialTheme.typography.bodyMedium) + Text( + "PulseLoop v${com.pulseloop.BuildConfig.VERSION_NAME} (${com.pulseloop.BuildConfig.VERSION_CODE})", + style = MaterialTheme.typography.bodyMedium, + ) Text( "Open-source health tracker. Ported from iOS.\nCC BY 4.0 · github.com/foureight84/PulseLoop", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Spacer(Modifier.height(12.dp)) + com.pulseloop.update.CheckForUpdatesButton() } } diff --git a/app/src/main/java/com/pulseloop/update/ApkInstaller.kt b/app/src/main/java/com/pulseloop/update/ApkInstaller.kt new file mode 100644 index 0000000..160d88a --- /dev/null +++ b/app/src/main/java/com/pulseloop/update/ApkInstaller.kt @@ -0,0 +1,37 @@ +package com.pulseloop.update + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.core.content.FileProvider +import java.io.File + +/** + * Hands a downloaded APK to the system package installer. + * + * Uses an ACTION_VIEW install intent with a FileProvider content URI, which reliably shows + * the standard "install update?" confirmation. Requires REQUEST_INSTALL_PACKAGES plus the + * user having granted "install unknown apps" for PulseLoop — gate the call on [canInstall] + * and send the user to settings via [installPermissionIntent] when needed. + * + * The APK must be signed with the same key as the installed app, or the system rejects it + * with INSTALL_FAILED_UPDATE_INCOMPATIBLE (so this only works for the signed release build). + */ +object ApkInstaller { + fun canInstall(context: Context): Boolean = + context.packageManager.canRequestPackageInstalls() + + /** Intent to the per-app "install unknown apps" screen. */ + fun installPermissionIntent(context: Context): Intent = + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}")) + + fun install(context: Context, apk: File) { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } +} diff --git a/app/src/main/java/com/pulseloop/update/UpdateChecker.kt b/app/src/main/java/com/pulseloop/update/UpdateChecker.kt new file mode 100644 index 0000000..8f93d7a --- /dev/null +++ b/app/src/main/java/com/pulseloop/update/UpdateChecker.kt @@ -0,0 +1,158 @@ +package com.pulseloop.update + +import android.content.Context +import android.os.Build +import com.pulseloop.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File + +@Serializable +data class GithubRelease( + @SerialName("tag_name") val tagName: String, + val name: String? = null, + val body: String? = null, + @SerialName("html_url") val htmlUrl: String? = null, + val draft: Boolean = false, + val prerelease: Boolean = false, + val assets: List = emptyList(), +) + +@Serializable +data class GithubAsset( + val name: String, + @SerialName("browser_download_url") val browserDownloadUrl: String, + val size: Long = 0, +) + +/** A newer release than what's installed, resolved to an installable APK asset. */ +data class UpdateInfo( + val versionName: String, + val versionCode: Int, + val changelog: String, + val apkUrl: String, + val apkSize: Long, + val htmlUrl: String, +) + +/** + * Polls the GitHub "latest release" endpoint and compares it to the installed build. + * + * Versioning contract: releases are tagged `v{versionName}+{versionCode}` (e.g. `v1.0.0+5`). + * The integer after `+` is the Android versionCode — the only value Android uses to decide + * "is this newer" — so that's what we compare. versionName is display-only. + * + * Self-update only works for the signed release build under the real applicationId: a debug + * build (different signing key / `.debug` id) can't install the release APK over itself. + */ +object UpdateChecker { + private val json = Json { ignoreUnknownKeys = true } + private val client = OkHttpClient() + + private const val PREFS = "self_update" + private const val KEY_ETAG = "etag" + private const val KEY_LAST_CHECK = "lastCheckAt" + private const val AUTO_CHECK_INTERVAL_MS = 24 * 3600_000L + + fun isSupported(): Boolean = + !BuildConfig.DEBUG && BuildConfig.APPLICATION_ID == "com.pulseloop" + + fun versionCodeFromTag(tag: String): Int? = tag.substringAfter('+', "").toIntOrNull() + fun versionNameFromTag(tag: String): String = tag.removePrefix("v").substringBefore('+') + + /** + * Returns an [UpdateInfo] if a newer release exists, else null. [force] bypasses the + * once-a-day throttle and the ETag cache (used by the Settings "Check for updates" button). + */ + suspend fun check(context: Context, force: Boolean = false): UpdateInfo? = withContext(Dispatchers.IO) { + if (!isSupported()) return@withContext null + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + if (!force) { + val last = prefs.getLong(KEY_LAST_CHECK, 0L) + if (System.currentTimeMillis() - last < AUTO_CHECK_INTERVAL_MS) return@withContext null + } + + val request = Request.Builder() + .url("https://api.github.com/repos/${BuildConfig.GITHUB_REPO}/releases/latest") + .header("Accept", "application/vnd.github+json") + .apply { if (!force) prefs.getString(KEY_ETAG, null)?.let { header("If-None-Match", it) } } + .build() + + val response = try { + client.newCall(request).execute() + } catch (_: Exception) { + return@withContext null + } + + response.use { resp -> + prefs.edit().putLong(KEY_LAST_CHECK, System.currentTimeMillis()).apply() + if (resp.code == 304) return@withContext null // Not Modified (ETag) + if (!resp.isSuccessful) return@withContext null + resp.header("ETag")?.let { prefs.edit().putString(KEY_ETAG, it).apply() } + + val bodyText = resp.body?.string() ?: return@withContext null + val release = try { + json.decodeFromString(GithubRelease.serializer(), bodyText) + } catch (_: Exception) { + return@withContext null + } + if (release.draft || release.prerelease) return@withContext null + + val remoteCode = versionCodeFromTag(release.tagName) ?: return@withContext null + if (remoteCode <= BuildConfig.VERSION_CODE) return@withContext null + + val apks = release.assets.filter { it.name.endsWith(".apk", ignoreCase = true) } + val apk = apks.firstOrNull { it.name.contains("universal", ignoreCase = true) } + ?: apks.firstOrNull { a -> Build.SUPPORTED_ABIS.any { a.name.contains(it, ignoreCase = true) } } + ?: apks.firstOrNull() + ?: return@withContext null + + UpdateInfo( + versionName = versionNameFromTag(release.tagName), + versionCode = remoteCode, + changelog = release.body?.takeIf { it.isNotBlank() } ?: (release.name ?: "A new version is available."), + apkUrl = apk.browserDownloadUrl, + apkSize = apk.size, + htmlUrl = release.htmlUrl ?: "", + ) + } + } + + /** Download the APK to cacheDir/updates, reporting progress in 0f..1f. Returns the file or null. */ + suspend fun download( + context: Context, + info: UpdateInfo, + onProgress: (Float) -> Unit, + ): File? = withContext(Dispatchers.IO) { + try { + client.newCall(Request.Builder().url(info.apkUrl).build()).execute().use { resp -> + if (!resp.isSuccessful) return@withContext null + val stream = resp.body?.byteStream() ?: return@withContext null + val total = resp.body?.contentLength()?.takeIf { it > 0 } ?: info.apkSize + + val dir = File(context.cacheDir, "updates").apply { mkdirs() } + dir.listFiles()?.forEach { it.delete() } // drop any stale download + val out = File(dir, "pulseloop-${info.versionCode}.apk") + + out.outputStream().use { fos -> + val buf = ByteArray(64 * 1024) + var downloaded = 0L + var read: Int + while (stream.read(buf).also { read = it } != -1) { + fos.write(buf, 0, read) + downloaded += read + if (total > 0) onProgress((downloaded.toFloat() / total).coerceIn(0f, 1f)) + } + } + out + } + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/java/com/pulseloop/update/UpdateUi.kt b/app/src/main/java/com/pulseloop/update/UpdateUi.kt new file mode 100644 index 0000000..e443459 --- /dev/null +++ b/app/src/main/java/com/pulseloop/update/UpdateUi.kt @@ -0,0 +1,123 @@ +package com.pulseloop.update + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.pulseloop.BuildConfig +import kotlinx.coroutines.launch + +/** + * "Update available" dialog: shows the changelog, then downloads the APK with a progress bar + * and hands it to the system installer. Reused by the on-launch check and the Settings button. + */ +@Composable +fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var downloading by remember { mutableStateOf(false) } + var progress by remember { mutableFloatStateOf(0f) } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = { if (!downloading) onDismiss() }, + title = { Text("Update available — ${info.versionName}") }, + text = { + Column { + if (downloading) { + Text("Downloading… ${(progress * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth()) + } else { + Text(info.changelog, style = MaterialTheme.typography.bodySmall) + error?.let { + Spacer(Modifier.height(8.dp)) + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + } + }, + confirmButton = { + TextButton( + enabled = !downloading, + onClick = { + // Require the per-app "install unknown apps" grant first. + if (!ApkInstaller.canInstall(context)) { + context.startActivity(ApkInstaller.installPermissionIntent(context)) + return@TextButton + } + downloading = true + error = null + scope.launch { + val file = UpdateChecker.download(context, info) { progress = it } + downloading = false + if (file != null) { + ApkInstaller.install(context, file) + onDismiss() + } else { + error = "Download failed. Please try again." + } + } + }, + ) { Text(if (downloading) "Downloading…" else "Update") } + }, + dismissButton = { + TextButton(enabled = !downloading, onClick = onDismiss) { Text("Later") } + }, + ) +} + +/** + * Self-contained "Check for updates" control for the Settings screen: a button that runs a + * forced check and shows the result (up-to-date / error / the update dialog). On a debug or + * non-release build, self-update isn't possible, so it shows a short explanation instead. + */ +@Composable +fun CheckForUpdatesButton(modifier: Modifier = Modifier) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var checking by remember { mutableStateOf(false) } + var status by remember { mutableStateOf(null) } + var update by remember { mutableStateOf(null) } + + Column(modifier) { + if (!UpdateChecker.isSupported()) { + Text( + "Self-update is available in the release build only " + + "(current: ${BuildConfig.VERSION_NAME} / ${BuildConfig.APPLICATION_ID}).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return@Column + } + OutlinedButton( + enabled = !checking, + onClick = { + checking = true + status = null + scope.launch { + val result = UpdateChecker.check(context, force = true) + checking = false + if (result != null) update = result else status = "You're on the latest version (${BuildConfig.VERSION_NAME})." + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (checking) "Checking…" else "Check for updates") } + status?.let { + Spacer(Modifier.height(8.dp)) + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + update?.let { UpdateDialog(it) { update = null } } +} From 472be8669d348ad2bbc5bb08368379338e044daf Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Mon, 29 Jun 2026 15:50:02 -0700 Subject: [PATCH 4/6] CI: publish only the release APK, not the debug build End users get diagnostics from the in-app export, so there's no need to distribute a debug APK. The debug variant stays for local dev/repro (adb, run-as, system-Bluetooth logs); it's just no longer a release asset. --- .github/workflows/release.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4ccc17..e1887ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,18 +54,17 @@ jobs: ./gradlew --no-daemon \ -PappVersionName=${{ steps.ver.outputs.name }} \ -PappVersionCode=${{ steps.ver.outputs.code }} \ - :app:assembleRelease :app:assembleDebug + :app:assembleRelease - - name: Stage APKs for upload + - name: Stage APK for upload id: stage run: | V="${{ steps.ver.outputs.name }}+${{ steps.ver.outputs.code }}" mkdir -p dist - # The universal release APK is the one the in-app updater installs (must keep - # "universal" in the name so the updater's asset selector picks it). + # Only the signed universal release APK is published — it's what the in-app updater + # installs (must keep "universal" in the name so the updater's asset selector picks it). + # End users get diagnostics from the in-app export, so no debug APK is distributed. cp app/build/outputs/apk/release/app-universal-release.apk "dist/pulseloop-${V}-universal.apk" - # Debug universal APK: a coexisting (.debug) build for gathering diagnostics. - cp app/build/outputs/apk/debug/app-universal-debug.apk "dist/pulseloop-${V}-debug.apk" ls -la dist - name: Create / update release and attach APKs From b7cba9346db470f76ec0f1ceaebd16f09d83cae0 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Mon, 29 Jun 2026 15:58:48 -0700 Subject: [PATCH 5/6] Bump versionCode to 5 for the next release --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e69a258..db87a5b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,7 +17,7 @@ android { // versionCode/versionName are overridable from Gradle properties so the release CI // can drive them straight from the git tag (e.g. -PappVersionCode=5 -PappVersionName=1.0.0). // Local builds fall back to the literals below. - versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 4 + versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 5 versionName = (project.findProperty("appVersionName") as String?) ?: "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 0a289e12820d1b1ee6031cca8fca9458c45c5fc1 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Tue, 30 Jun 2026 04:50:41 -0700 Subject: [PATCH 6/6] Ring: stop the optical sensor reliably after a spot measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reasons the Colmi green LED kept pulsing after a measurement: - The HR stop frame was wrong: manualHeartRate(false) sent [0x69, 0x02], but 0x69 is CMD_START_REAL_TIME — so the 'stop' actually started another real-time reading and the sensor never switched off (the ring only timed out on its own). Send CMD_STOP_REAL_TIME (0x6A) instead, mirroring SpO2. - measureHR/measureSpO2/measureCombined sent the stop after the wait loop, not in a finally. The Measure button runs in the screen's coroutine scope, so navigating away mid-measurement cancelled it before the stop ran. Wrap each in try/finally so the sensor is always switched off. --- .../java/com/pulseloop/ring/ColmiEncoder.kt | 14 ++++++- .../pulseloop/service/RingSyncCoordinator.kt | 42 ++++++++++++------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/ColmiEncoder.kt b/app/src/main/java/com/pulseloop/ring/ColmiEncoder.kt index 1c0feaa..82da247 100644 --- a/app/src/main/java/com/pulseloop/ring/ColmiEncoder.kt +++ b/app/src/main/java/com/pulseloop/ring/ColmiEncoder.kt @@ -54,8 +54,18 @@ object ColmiEncoder { fun readGoals(): ByteArray = byteArrayOf(ColmiCommandID.GOALS.toByte(), ColmiCommandID.PREF_READ.toByte()) - fun manualHeartRate(enable: Boolean = true): ByteArray = - byteArrayOf(ColmiCommandID.MANUAL_HEART_RATE.toByte(), if (enable) 0x01 else 0x02) + fun manualHeartRate(enable: Boolean = true): ByteArray = if (enable) { + byteArrayOf(ColmiCommandID.MANUAL_HEART_RATE.toByte(), ColmiCommandID.RT_HEART_RATE.toByte()) + } else { + // Stop MUST use CMD_STOP_REAL_TIME (0x6A). The old [0x69, 0x02] was another + // CMD_START_REAL_TIME (reading type 2), so the optical sensor never switched off + // and the ring kept pulsing until it timed out. Mirror the SpO₂ stop frame. + byteArrayOf( + ColmiCommandID.REALTIME_STOP.toByte(), + ColmiCommandID.RT_HEART_RATE.toByte(), + 0x00, 0x00, + ) + } fun realtimeHeartRate(enable: Boolean): ByteArray = byteArrayOf(ColmiCommandID.REALTIME_HEART_RATE.toByte(), if (enable) 0x01 else 0x02) diff --git a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt index 271f47a..b2ced3b 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncCoordinator.kt @@ -277,17 +277,22 @@ class RingSyncCoordinator( measurementReceivedReading = false engine?.measureHeartRateSpot() - pollForValue(hrMeasureSeconds, { if (measurementReceivedReading) latestHRValue else null }, { hrNoReadingReported }) - - var result = if (measurementReceivedReading) latestHRValue else null - if (result != null) { - repeat(hrSettleSeconds * 2) { // 0.5s granularity - delay(500) - latestHRValue?.let { result = it } + var result: Int? = null + try { + pollForValue(hrMeasureSeconds, { if (measurementReceivedReading) latestHRValue else null }, { hrNoReadingReported }) + result = if (measurementReceivedReading) latestHRValue else null + if (result != null) { + repeat(hrSettleSeconds * 2) { // 0.5s granularity + delay(500) + latestHRValue?.let { result = it } + } } + } finally { + // Always switch the optical sensor off — even if the caller's coroutine is + // cancelled (e.g. the user navigates away mid-measurement) — or the ring keeps pulsing. + engine?.stopHeartRate() + hrState = if (result != null) MeasureState.DONE else MeasureState.FAILED } - engine?.stopHeartRate() - hrState = if (result != null) MeasureState.DONE else MeasureState.FAILED return result } @@ -297,9 +302,13 @@ class RingSyncCoordinator( spo2State = MeasureState.MEASURING latestSpO2Value = null engine?.startSpO2() - val result = pollForValue(spo2MeasureSeconds, { latestSpO2Value }, { false }) - engine?.stopSpO2() - spo2State = if (result != null) MeasureState.DONE else MeasureState.FAILED + var result: Int? = null + try { + result = pollForValue(spo2MeasureSeconds, { latestSpO2Value }, { false }) + } finally { + engine?.stopSpO2() // stop the sensor even on cancellation (see measureHR) + spo2State = if (result != null) MeasureState.DONE else MeasureState.FAILED + } return result } @@ -314,9 +323,12 @@ class RingSyncCoordinator( if (!isConnected) { combinedState = MeasureState.FAILED; return } combinedState = MeasureState.MEASURING engine?.startCombinedMeasurement() - repeat(combinedMeasureSeconds.toInt()) { delay(1000) } - engine?.stopCombinedMeasurement() - combinedState = MeasureState.DONE + try { + repeat(combinedMeasureSeconds.toInt()) { delay(1000) } + } finally { + engine?.stopCombinedMeasurement() // stop even on cancellation (see measureHR) + combinedState = MeasureState.DONE + } } private suspend fun pollForValue(