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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,36 @@ internal class BleReconnectBackoff(
companion object {
/**
* Firmware status is exactly four LE uint32s: used, unread, free, RTC valid.
* Only a nonzero used + unread + free total proves initialized storage;
* RTC validity alone cannot license backoff. Null includes failed reads.
* A nonzero used + unread + free total proves initialized storage, and the
* RTC-valid field must also be set: the firmware's offline writer drops every
* request while its clock is unsynced (sd_card.c `process_write_data_req`),
* so a mounted-but-timeless device discards the audio a longer reconnect gap
* would strand. RTC validity alone is still not evidence of usable storage.
* Null includes failed reads.
*/
fun retentionProbeSucceeds(payload: ByteArray?): Boolean {
if (payload == null || payload.size != 16) return false
val status = java.nio.ByteBuffer.wrap(payload).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val used = status.int.toLong() and 0xffff_ffffL
val unread = status.int.toLong() and 0xffff_ffffL
val free = status.int.toLong() and 0xffff_ffffL
status.int // RTC validity is not evidence of usable storage.
return used + unread + free > 0L
val rtcValid = status.int != 0
return rtcValid && used + unread + free > 0L
Comment thread
Git-on-my-level marked this conversation as resolved.
}

/**
* True when the status payload shows mounted storage (nonzero
* used+unread+free) whose RTC-valid field is still zero — the one
* failure shape a post-clock-sync re-probe can legitimately flip.
*/
fun storageStatusHasDataWithoutClock(payload: ByteArray?): Boolean {
if (payload == null || payload.size != 16) return false
val buffer = java.nio.ByteBuffer.wrap(payload).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val used = buffer.int.toLong() and 0xffff_ffffL
val unread = buffer.int.toLong() and 0xffff_ffffL
val free = buffer.int.toLong() and 0xffff_ffffL
val rtcValid = buffer.int != 0
return !rtcValid && used + unread + free > 0L
}

val DEFAULT_STEPS_MS = longArrayOf(3_000L, 6_000L, 12_000L, 30_000L, 60_000L)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ class OmiBleForegroundService : Service() {
private const val MTU_SIZE = 512
private const val STABILITY_TIMER_MS = 60_000L
private const val RETENTION_PROBE_TIMEOUT_MS = 10_000L

/** Dart performs performSyncTime after onDeviceReady; re-probes after
* this window cover a factory-reset pendant whose first probe failed
* only on RTC validity. Bounded chain per link, reset on new GATT. */
private const val RETENTION_REPROBE_DELAY_MS = 20_000L
private const val RETENTION_MAX_REPROBES = 3
// Base delay for CCCD-timeout recovery. Ordinary out-of-range retries use
// reconnectBackoff so a fixed 3s loop cannot spin the radio forever.
private const val RECONNECT_DELAY_MS = 3_000L
Expand Down Expand Up @@ -172,7 +178,10 @@ class OmiBleForegroundService : Service() {
* callback reports CONNECTED. Used to distinguish fail-to-connect from
* an established link dropping. Independent of hasEverConnected which
* tracks the full device lifetime. */
var currentAttemptEstablished: Boolean = false
var currentAttemptEstablished: Boolean = false,
/** Post-clock-sync re-probes already run on the current link; one per
* connection, reset in onGattConnected. */
var retentionReprobes: Int = 0
) {
internal val reconnectDiagnostics = BleReconnectDiagnostics()
internal val retentionProbe = RetentionProbeLifecycle()
Expand Down Expand Up @@ -225,6 +234,16 @@ class OmiBleForegroundService : Service() {
backfillTimeToReconnect(addr, managed)
managed.retryCount = 0
reconnectBackoff.reset(addr)
// Any retention proof describes the previous GATT link, not this one:
// the device may have rebooted into a storage failure while it was
// away. Reset before anything on this connection can consume the
// stale proof; fireDeviceReady re-proves storage for this link and
// re-licenses the escalated schedule only on a fresh successful read.
synchronized(managed) {
managed.hasVerifiedRetention = false
managed.retentionProbe.resetVerification()
managed.retentionReprobes = 0
}
managed.hasEverConnected = true
managed.currentAttemptEstablished = true
managed.pendingReconnect?.let { handler.removeCallbacks(it) }
Expand Down Expand Up @@ -349,33 +368,53 @@ class OmiBleForegroundService : Service() {

private fun fireDeviceReady(address: String, services: List<BleService>) {
val addr = address.uppercase()
// Audio subscription goes first, always. Firmware drops dequeued frames
// while it is connected but not subscribed (transport.c), so anything
// this function puts on the GATT command queue before the CCCD write
// widens the connected-but-deaf window, and a wedged head command would
// block capture for the whole link. The retention probe is an optional
// battery optimization; capture is the product.
val nativeSubscribed = ensureBackgroundAudioSubscription(addr, services)
recordVerifiedRetention(addr, services)
probeRetentionRuntime(addr)
ensureBackgroundAudioSubscription(addr, services)
limitlessDrainEngine.onDeviceReady(addr)
if (OmiBleManager.isFlutterAlive) {
bleManager.mainHandler.post {
bleManager.flutterApi?.onDeviceReady(addr, services) {}
}
}
// The probe read must never reach the serial GATT queue ahead of an
// audio subscription. With a native target the subscription is already
// queued above; without one, Dart queues its own subscription from the
// onDeviceReady post above, so posting the probe after that post keeps
// it behind. With neither (no native target, Flutter gone) no
// subscription can ever arrive on this link, so the probe cannot delay
// one. probeRetentionRuntime still defers its enqueue through the same
// looper as subscribeCharacteristic.
when {
nativeSubscribed -> probeRetentionRuntime(addr)
OmiBleManager.isFlutterAlive -> bleManager.mainHandler.post { probeRetentionRuntime(addr) }
Comment thread
Git-on-my-level marked this conversation as resolved.
else -> probeRetentionRuntime(addr)
}
}

private fun ensureBackgroundAudioSubscription(address: String, services: List<BleService>) {
/** Returns true when this call queued an audio CCCD write for the device. */
private fun ensureBackgroundAudioSubscription(address: String, services: List<BleService>): Boolean {
// Subscribe for background streaming OR batch capture (whichever is configured)
// so native keeps receiving audio after the Flutter engine is gone. Never deferred
// to Dart: enabling notifications is idempotent, and waiting on an engine that is
// gone left the characteristic subscribed by nobody (issue #10847).
val target = backgroundAudioStreamer.configuredAudioTargetFor(address)
?: batchAudioWriter.configuredAudioTargetFor(address)
?: return
?: return false
val hasTarget = services.any { service ->
service.uuid.equals(target.first, ignoreCase = true) &&
service.characteristicUuids.any { it.equals(target.second, ignoreCase = true) }
}
if (!hasTarget) return
if (!hasTarget) return false

Log.i(TAG, "Ensuring BLE audio subscription for background transcription on $address")
bleManager.subscribeCharacteristic(address, target.first, target.second)
return true
}

/** INV-CAP-1 requires both advertisement and a successful runtime storage probe. */
Expand Down Expand Up @@ -422,26 +461,69 @@ class OmiBleForegroundService : Service() {
if (managed.retentionRuntimeVerified) {
Log.i(TAG, "Verified runtime offline retention for $address (storage status nonzero)")
} else {
val payload = result.getOrNull()
val attempt = synchronized(managed) { managed.retentionReprobes + 1 }
if (payload != null && BleReconnectBackoff.storageStatusHasDataWithoutClock(payload) &&
attempt <= RETENTION_MAX_REPROBES
) {
// Mounted storage with an unsynced clock: Dart performs
// performSyncTime after onDeviceReady, so the same link
// can legitimately gain a valid RTC. Bounded chain of
// re-probes on THIS link covers a factory-reset pendant
// whose connect-time work outruns the first window,
// without weakening the RTC gate.
synchronized(managed) { managed.retentionReprobes = attempt }
scheduleRetentionReprobe(address, gatt, attempt)
Comment thread
Git-on-my-level marked this conversation as resolved.
}
Log.w(TAG, "Runtime offline retention unverified for $address; keeping flat retry cadence: " +
(result.exceptionOrNull()?.message ?: "invalid or all-zero storage status"))
}
}
}
try {
bleManager.readCharacteristic(address, STORAGE_DRAIN_SERVICE_UUID, STORAGE_DRAIN_READ_CHAR_UUID, completeProbe)
} catch (e: Exception) {
completeProbe(Result.failure(e))
// subscribeCharacteristic defers its enqueue through mainHandler, so the
// probe read must defer the same way: enqueueing it directly from
// fireDeviceReady would win the race and take the serial queue ahead of
// the audio CCCD write. A post into the same looper keeps this behind
// the subscription unconditionally. If the link dropped before the post
// runs, readCharacteristic fails the completion and the probe retires.
bleManager.mainHandler.post {
try {
bleManager.readCharacteristic(address, STORAGE_DRAIN_SERVICE_UUID, STORAGE_DRAIN_READ_CHAR_UUID, completeProbe)
} catch (e: Exception) {
completeProbe(Result.failure(e))
}
}
}

private fun retireRetentionProbe(managed: ManagedDevice) {
synchronized(managed) {
managed.retentionProbeTimeout?.let { handler.removeCallbacks(it) }
managed.retentionProbeTimeout = null
managed.retentionProbe.interrupt()
// Cancel the in-flight probe only. A completed verification survives
// this link's disconnection: retry selection runs after the link is
// gone and still needs the proof to keep the escalated schedule.
managed.retentionProbe.cancel()
Comment thread
Git-on-my-level marked this conversation as resolved.
}
}

/** Re-probe on the link that scheduled it. Replaced links are rejected
* (a stale callback must not capture a successor GATT or clear its fresh
* proof), and the chain self-limits: each cycle arms only when the probe
* again sees the mounted-storage-without-clock shape, up to
* RETENTION_MAX_REPROBES per link, reset on each new GATT connection. */
private fun scheduleRetentionReprobe(address: String, gatt: android.bluetooth.BluetoothGatt, attempt: Int) {
if (attempt > RETENTION_MAX_REPROBES) return
handler.postDelayed({
val addr = address.uppercase()
val managed = managedDevices[addr] ?: return@postDelayed
if (bleManager.connectedGatts[addr] !== gatt) return@postDelayed
synchronized(managed) {
if (managed.retentionReprobes < attempt) return@postDelayed
}
probeRetentionRuntime(address)
}, RETENTION_REPROBE_DELAY_MS)
Comment thread
Git-on-my-level marked this conversation as resolved.
}

private fun mapGattServices(services: List<BluetoothGattService>): List<BleService> =
services.map { service ->
BleService(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
package com.friend.ios.ble

/** Per-device probe ownership. Callers serialize transitions with their device lock. */
/**
* Per-device retention probe ownership and its completed proof. Callers
* serialize transitions with their device lock.
*
* Proof lifecycle (INV-CAP-1): a successful read verifies the GATT link it ran
* on, and that proof must survive the disconnected retry attempts that follow —
* a proof erased at every disconnect makes the escalated schedule unreachable
* because retry selection always runs after the link is already gone. Each NEW
* connection resets the proof before any retry logic on the new link can
* consume it, and reads retired by a replacement connection can never verify
* their successor.
*/
internal class RetentionProbeLifecycle {
class Probe internal constructor(internal val connection: Any)

Expand All @@ -9,6 +20,8 @@ internal class RetentionProbeLifecycle {
@Volatile var runtimeVerified: Boolean = false
private set

/** Clears the completed proof. Called when a new GATT connects and when a
* fresh ready event must re-prove storage for its own link. */
fun resetVerification() {
runtimeVerified = false
}
Expand All @@ -21,13 +34,14 @@ internal class RetentionProbeLifecycle {

fun timeout(probe: Probe): Boolean {
if (activeProbe !== probe) return false
interrupt()
cancel()
return true
}

fun interrupt() {
/** Retires an in-flight probe only. A completed verification survives:
* it is reset when the next GATT connects, not when this one drops. */
fun cancel() {
activeProbe = null
resetVerification()
}

/** Retired attempts and reads from a replaced connection cannot affect a fresh probe. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,28 @@ class BleReconnectBackoffTest {
assertFlatCadenceWithProbe(storageStatus(rtc = 1))
}

@Test
fun `mounted but timeless storage keeps flat cadence even with data on the ring`() {
// INV-CAP-1: the firmware's offline writer drops every request while its
// RTC is unsynced (sd_card.c process_write_data_req), so nonzero storage
// without a valid clock cannot license longer reconnect gaps.
assertFalse(BleReconnectBackoff.retentionProbeSucceeds(storageStatus(free = 4096)))
assertFalse(BleReconnectBackoff.retentionProbeSucceeds(storageStatus(used = 5, unread = 5, free = 100)))
val backoff = BleReconnectBackoff()
repeat(5) {
assertEquals(3_000L, backoff.delayForFailure("A", false, null, BleReconnectBackoff.retentionProbeSucceeds(storageStatus(used = 5, free = 100))))
}
// The one failure shape a post-clock-sync re-probe can flip is detected:
// mounted storage whose RTC-valid field is still zero.
assertTrue(BleReconnectBackoff.storageStatusHasDataWithoutClock(storageStatus(used = 5, unread = 5, free = 100)))
assertFalse(BleReconnectBackoff.storageStatusHasDataWithoutClock(storageStatus(used = 5, free = 100, rtc = 1)))
assertFalse(BleReconnectBackoff.storageStatusHasDataWithoutClock(ByteArray(16)))
assertFalse(BleReconnectBackoff.storageStatusHasDataWithoutClock(null))
}

@Test
fun `nonzero free bytes license escalation only with advertisement`() {
val runtimeVerified = BleReconnectBackoff.retentionProbeSucceeds(storageStatus(free = 4096))
val runtimeVerified = BleReconnectBackoff.retentionProbeSucceeds(storageStatus(free = 4096, rtc = 1))
assertTrue(runtimeVerified)
val backoff = BleReconnectBackoff()
val advertised = true
Expand All @@ -152,11 +171,11 @@ class BleReconnectBackoffTest {

@Test
fun `used or unread status proves storage and uint32 totals do not overflow`() {
assertTrue(BleReconnectBackoff.retentionProbeSucceeds(storageStatus(used = 1)))
assertTrue(BleReconnectBackoff.retentionProbeSucceeds(storageStatus(unread = 1)))
assertTrue(BleReconnectBackoff.retentionProbeSucceeds(storageStatus(free = Int.MIN_VALUE)))
assertTrue(BleReconnectBackoff.retentionProbeSucceeds(storageStatus(used = 1, rtc = 1)))
assertTrue(BleReconnectBackoff.retentionProbeSucceeds(storageStatus(unread = 1, rtc = 1)))
assertTrue(BleReconnectBackoff.retentionProbeSucceeds(storageStatus(free = Int.MIN_VALUE, rtc = 1)))
// Unsigned sum is 2^32, which would wrap to zero in Int arithmetic.
assertTrue(BleReconnectBackoff.retentionProbeSucceeds(storageStatus(used = -1, free = 1)))
assertTrue(BleReconnectBackoff.retentionProbeSucceeds(storageStatus(used = -1, free = 1, rtc = 1)))
}

private fun assertFlatCadenceWithProbe(payload: ByteArray?) {
Expand Down
Loading
Loading