Skip to content
Merged
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
66 changes: 66 additions & 0 deletions app/src/main/java/com/pulseloop/ring/CRPReconnect.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.pulseloop.ring

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

/**
* Da Rings retries three seconds after a disconnect (`o3/l.o` in the Android decompile).
* Keep this separate from the 15-second liveness watchdog: a five-second Android connection
* timeout otherwise leaves the user waiting another ten seconds before recovery even starts.
*
* GATT callbacks and lifecycle actions may arrive on different threads. The epoch and monitor
* make cancellation invalidate even a timer that has finished its delay and is waiting to run.
* Callbacks run under that monitor, so cancellation cannot return ahead of a stale retry.
*/
internal class CRPReconnect(
private val scope: CoroutineScope,
private val canRetry: () -> Boolean,
private val onPending: () -> Unit,
private val onRetry: () -> Unit,
private val onAbandoned: () -> Unit,
) {
private val lock = Any()
private var epoch = 0L
private var timer: Job? = null
private var recovering = false

val isPending: Boolean get() = synchronized(lock) { timer != null }
/** Remains true during a recovery scan, until success, cancellation, or exhausted retries. */
val isRecovering: Boolean get() = synchronized(lock) { recovering }

fun schedule(): Boolean = synchronized(lock) {
if (timer != null || !canRetry()) return false
recovering = true
val generation = ++epoch
val job = scope.launch(start = CoroutineStart.LAZY) {
delay(3_000L)
synchronized(lock) {
if (generation != epoch) return@synchronized
timer = null
if (canRetry()) onRetry() else {
recovering = false
onAbandoned()
}
}
}
timer = job
onPending()
job.start()
true
}

/** A new attempt supersedes the timer but still belongs to the current recovery. */
fun cancelPending() = synchronized(lock) {
epoch++
timer?.cancel()
timer = null
}

fun cancel() = synchronized(lock) {
cancelPending()
recovering = false
}
}
90 changes: 90 additions & 0 deletions app/src/main/java/com/pulseloop/ring/CRPTeardownGate.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.pulseloop.ring

import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull

/** Physical teardown outlives both the GATT disconnected callback and a worker-owned client. */
internal class CRPTeardownGate(
private val nowNanos: () -> Long,
private val timeoutMs: Long = 5_000L,
) {
internal class Ticket(val address: String) {
val ready = CompletableDeferred<Boolean>()
var closedAtNanos: Long? = null
var disconnected = false
var scanAfterNanos: Long? = null
}

private val lock = Any()
private val pending = mutableMapOf<String, Ticket>()

fun begin(address: String): Ticket = synchronized(lock) {
// An older close completion must never release a newer teardown for the same ring.
pending.remove(address)?.ready?.complete(false)
Ticket(address).also { pending[address] = it }
}

fun isPending(address: String): Boolean = synchronized(lock) { address in pending }

fun gattClosed(ticket: Ticket) = synchronized(lock) {
if (pending[ticket.address] !== ticket) return@synchronized
ticket.closedAtNanos = nowNanos()
finishIfReady(ticket)
}

fun aclDisconnected(address: String) = synchronized(lock) {
pending[address]?.let {
it.disconnected = true
finishIfReady(it)
}
}

/**
* The OS already reported no physical link when this teardown began (a watchdog or hung
* CONNECTING reconnect), so no ACL_DISCONNECTED broadcast will follow. Only the GATT close
* remains to wait for. A stale ticket cannot release a newer teardown.
*/
fun linkAlreadyDown(ticket: Ticket) = synchronized(lock) {
if (pending[ticket.address] !== ticket) return@synchronized
ticket.disconnected = true
finishIfReady(ticket)
}

fun bluetoothOff() = synchronized(lock) {
pending.values.toList().forEach {
it.disconnected = true
finishIfReady(it)
}
}

/** False means require a fresh scan, never blindly reuse the old link on timeout. */
suspend fun await(address: String): Boolean {
val ticket = synchronized(lock) { pending[address] } ?: return true
if (synchronized(lock) { ticket.scanAfterNanos != null }) return false
val ready = withTimeoutOrNull(timeoutMs) { ticket.ready.await() }
if (ready != null) return ready
return synchronized(lock) {
if (pending[address] !== ticket) return@synchronized !pending.containsKey(address)
ticket.scanAfterNanos = nowNanos()
false
}
}

/** Only a post-timeout, freshly observed advertisement can substitute for a missed broadcast. */
fun freshAdvertisement(address: String, timestampNanos: Long): Boolean = synchronized(lock) {
val ticket = pending[address] ?: return true
val after = ticket.scanAfterNanos ?: return false
val closed = ticket.closedAtNanos ?: return false
if (timestampNanos <= maxOf(after, closed)) return false
pending.remove(address)
ticket.ready.complete(true)
true
}

private fun finishIfReady(ticket: Ticket) {
if (ticket.closedAtNanos != null && ticket.disconnected) {
pending.remove(ticket.address)
ticket.ready.complete(true)
}
}
}
106 changes: 106 additions & 0 deletions app/src/main/java/com/pulseloop/ring/CRPTeardownMonitor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.pulseloop.ring

import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.SystemClock
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

/**
* One monitor per process, using only the application context. A worker can finish while its
* physical link is still disconnecting; a new foreground client must observe the same gate.
* The receiver intentionally lives for the process, including while no client is connecting.
*/
@SuppressLint("MissingPermission")
internal class CRPTeardownMonitor private constructor(context: Context) {
val gate = CRPTeardownGate(SystemClock::elapsedRealtimeNanos)
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private val bluetoothManager: BluetoothManager? =
context.applicationContext.getSystemService(BluetoothManager::class.java)

init {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
// Older Android releases may omit EXTRA_TRANSPORT. CRP rings are LE-only.
if (intent.getIntExtra(BluetoothDevice.EXTRA_TRANSPORT,
BluetoothDevice.TRANSPORT_LE) != BluetoothDevice.TRANSPORT_LE) return
// IntentCompat gates the typed overload on API 34: it is buggy on 33.
val device = IntentCompat.getParcelableExtra(
intent, BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java,
) ?: return
if (gate.isPending(device.address)) {
Log.i("RingBLEClient", "CRP physical disconnect confirmed")
gate.aclDisconnected(device.address)
}
}
BluetoothAdapter.ACTION_STATE_CHANGED -> {
if (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1) == BluetoothAdapter.STATE_OFF) {
gate.bluetoothOff()
}
}
}
}
}
// Bluetooth broadcasts originate in a privileged process outside the system UID.
// These are protected system actions; NOT_EXPORTED can silently miss their delivery.
try {
ContextCompat.registerReceiver(
context.applicationContext, receiver,
IntentFilter(BluetoothDevice.ACTION_ACL_DISCONNECTED).apply {
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
},
ContextCompat.RECEIVER_EXPORTED,
)
} catch (e: Exception) {
Log.w("RingBLEClient", "CRP ACL receiver unavailable; will require fresh advertisement", e)
}
}

/** Register the wait before disconnecting, and close even if the owning client's scope dies. */
fun disconnect(gatt: BluetoothGatt, close: () -> Unit) {
// Open the ticket before probing so a broadcast racing the probe is still recorded.
val ticket = gate.begin(gatt.device.address)
if (!linkPhysicallyUp(gatt.device)) {
Log.i("RingBLEClient", "CRP link already down; waiting only for GATT close")
gate.linkAlreadyDown(ticket)
}
try { gatt.disconnect() } catch (_: Exception) {}
scope.launch {
delay(500L)
try { close() } finally { gate.gattClosed(ticket) }
}
}

/** Unknown (no manager, missing permission) counts as up: wait for the broadcast. */
private fun linkPhysicallyUp(device: BluetoothDevice): Boolean = try {
bluetoothManager?.getConnectionState(device, BluetoothProfile.GATT)
?.let { it == BluetoothProfile.STATE_CONNECTED || it == BluetoothProfile.STATE_DISCONNECTING }
?: true
} catch (_: Exception) {
true
}

companion object {
@Volatile private var instance: CRPTeardownMonitor? = null
fun current(): CRPTeardownMonitor? = instance
fun get(context: Context): CRPTeardownMonitor = instance ?: synchronized(this) {
instance ?: CRPTeardownMonitor(context.applicationContext).also { instance = it }
}
}
}
Loading
Loading