fix(reaction): Stamp connect times from the ACL broadcast

The connection timestamp cache was only maintained while the connected-devices
flow had a subscriber. If the process died while a device stayed connected and
that device then disconnected and reconnected with nothing running, the
restarted collection found the old entry still keyed by a currently-connected
address, kept it through the prune, and reported the original connect time —
so the popup age check rejected an arbitrarily old connection.

The ACL broadcasts arrive at a manifest-registered receiver that wakes the
process regardless of any flow subscription, so the stamp is taken there
instead: connect stamps (keeping an existing one, which is the earlier and
therefore truer time), disconnect drops the entry. The flow keeps its own
stamp-on-first-sight and prune as a backstop for the force-stopped state and
missed broadcasts.

ACL_DISCONNECTED was already in the receiver's expected actions but was never
registered in the manifest. It does not start the monitor: a disconnect is not
a reason to start monitoring, and the start triggers here are deliberately
conservative.
This commit is contained in:
darken
2026-08-31 15:54:51 +02:00
committed by Matthias Urhahn
parent ff5d0cedfa
commit d7988189a0
4 changed files with 87 additions and 3 deletions
+1
View File
@@ -86,6 +86,7 @@
android:label="Service trigger">
<intent-filter>
<action android:name="android.bluetooth.device.action.ACL_CONNECTED" />
<action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />
</intent-filter>
</receiver>
@@ -237,6 +237,26 @@ class BluetoothManager2 @Inject constructor(
private val seenDevicesLock = Mutex()
private val seenDevicesCache = mutableMapOf<String, Instant>()
/**
* Stamps the connect time from the ACL broadcast, which reaches a manifest-registered receiver
* whether or not [connectedDevices] is being collected. An existing stamp wins: the earlier one
* is the real connect time.
*/
fun markDeviceConnected(address: BluetoothAddress) {
appScope.launch {
seenDevicesLock.withLock {
if (seenDevicesCache.containsKey(address)) return@withLock
seenDevicesCache[address] = timeSource.now()
}
}
}
fun markDeviceDisconnected(address: BluetoothAddress) {
appScope.launch {
seenDevicesLock.withLock { seenDevicesCache.remove(address) }
}
}
val connectedDevices: Flow<List<BluetoothDevice2>> = isBluetoothEnabled
.flatMapLatest { enabled ->
if (enabled) monitorProfile(BluetoothProfile.HEADSET)
@@ -7,6 +7,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.hasFeature
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
@@ -19,6 +20,7 @@ import javax.inject.Inject
class BluetoothEventReceiver : BroadcastReceiver() {
@Inject lateinit var monitorControl: MonitorControl
@Inject lateinit var bluetoothManager: BluetoothManager2
override fun onReceive(context: Context, intent: Intent) {
log(TAG) { "onReceive($context, $intent)" }
@@ -48,6 +50,15 @@ class BluetoothEventReceiver : BroadcastReceiver() {
log { "Device has the following we features we support $supportedFeatures" }
}
when (intent.action) {
BluetoothDevice.ACTION_ACL_CONNECTED -> bluetoothManager.markDeviceConnected(bluetoothDevice.address)
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
bluetoothManager.markDeviceDisconnected(bluetoothDevice.address)
// A disconnect is not a reason to start monitoring.
return
}
}
log(TAG) { "Starting monitor" }
monitorControl.startMonitor(forceStart = false)
}
@@ -7,9 +7,12 @@ import android.content.Context
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
@@ -34,8 +37,16 @@ class BluetoothManager2Test : BaseTest() {
}
private val timeSource = TestTimeSource()
private fun TestScope.create() = BluetoothManager2(
appScope = backgroundScope,
// Unconfined, so the appScope.launch bodies of the mark* methods complete in place.
private val appScope = CoroutineScope(UnconfinedTestDispatcher())
@AfterEach
fun teardown() {
appScope.cancel()
}
private fun create() = BluetoothManager2(
appScope = appScope,
dispatcherProvider = TestDispatcherProvider(),
context = mockk<Context>(),
manager = btManager,
@@ -61,6 +72,47 @@ class BluetoothManager2Test : BaseTest() {
manager.bonded(ADDRESS_A).seenFirstAt shouldBe timeSource.now()
}
/**
* The ACL broadcast arrives whether or not anything is collecting the connected-devices flow,
* so the stamp it leaves has to survive until the device disconnects.
*/
@Test
fun `an ACL connect stamp survives the passage of time`() = runTest {
val manager = create()
val connectedAt = timeSource.now()
manager.markDeviceConnected(ADDRESS_A)
timeSource.advanceBy(Duration.ofSeconds(60))
manager.bonded(ADDRESS_A).seenFirstAt shouldBe connectedAt
}
@Test
fun `a repeated ACL connect keeps the first stamp`() = runTest {
val manager = create()
val connectedAt = timeSource.now()
manager.markDeviceConnected(ADDRESS_A)
timeSource.advanceBy(Duration.ofSeconds(60))
manager.markDeviceConnected(ADDRESS_A)
manager.bonded(ADDRESS_A).seenFirstAt shouldBe connectedAt
}
@Test
fun `an ACL disconnect drops the stamp`() = runTest {
val manager = create()
manager.markDeviceConnected(ADDRESS_A)
timeSource.advanceBy(Duration.ofSeconds(60))
manager.markDeviceDisconnected(ADDRESS_A)
manager.bonded(ADDRESS_A).seenFirstAt shouldBe timeSource.now()
}
companion object {
private const val ADDRESS_A = "AA:BB:CC:DD:EE:F1"
private const val ADDRESS_B = "AA:BB:CC:DD:EE:F2"