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"> android:label="Service trigger">
<intent-filter> <intent-filter>
<action android:name="android.bluetooth.device.action.ACL_CONNECTED" /> <action android:name="android.bluetooth.device.action.ACL_CONNECTED" />
<action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
@@ -237,6 +237,26 @@ class BluetoothManager2 @Inject constructor(
private val seenDevicesLock = Mutex() private val seenDevicesLock = Mutex()
private val seenDevicesCache = mutableMapOf<String, Instant>() 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 val connectedDevices: Flow<List<BluetoothDevice2>> = isBluetoothEnabled
.flatMapLatest { enabled -> .flatMapLatest { enabled ->
if (enabled) monitorProfile(BluetoothProfile.HEADSET) if (enabled) monitorProfile(BluetoothProfile.HEADSET)
@@ -7,6 +7,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.hasFeature import eu.darken.capod.common.bluetooth.hasFeature
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
@@ -19,6 +20,7 @@ import javax.inject.Inject
class BluetoothEventReceiver : BroadcastReceiver() { class BluetoothEventReceiver : BroadcastReceiver() {
@Inject lateinit var monitorControl: MonitorControl @Inject lateinit var monitorControl: MonitorControl
@Inject lateinit var bluetoothManager: BluetoothManager2
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
log(TAG) { "onReceive($context, $intent)" } log(TAG) { "onReceive($context, $intent)" }
@@ -48,6 +50,15 @@ class BluetoothEventReceiver : BroadcastReceiver() {
log { "Device has the following we features we support $supportedFeatures" } 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" } log(TAG) { "Starting monitor" }
monitorControl.startMonitor(forceStart = false) monitorControl.startMonitor(forceStart = false)
} }
@@ -7,9 +7,12 @@ import android.content.Context
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import testhelpers.BaseTest import testhelpers.BaseTest
import testhelpers.TestTimeSource import testhelpers.TestTimeSource
@@ -34,8 +37,16 @@ class BluetoothManager2Test : BaseTest() {
} }
private val timeSource = TestTimeSource() private val timeSource = TestTimeSource()
private fun TestScope.create() = BluetoothManager2( // Unconfined, so the appScope.launch bodies of the mark* methods complete in place.
appScope = backgroundScope, private val appScope = CoroutineScope(UnconfinedTestDispatcher())
@AfterEach
fun teardown() {
appScope.cancel()
}
private fun create() = BluetoothManager2(
appScope = appScope,
dispatcherProvider = TestDispatcherProvider(), dispatcherProvider = TestDispatcherProvider(),
context = mockk<Context>(), context = mockk<Context>(),
manager = btManager, manager = btManager,
@@ -61,6 +72,47 @@ class BluetoothManager2Test : BaseTest() {
manager.bonded(ADDRESS_A).seenFirstAt shouldBe timeSource.now() 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 { companion object {
private const val ADDRESS_A = "AA:BB:CC:DD:EE:F1" private const val ADDRESS_A = "AA:BB:CC:DD:EE:F1"
private const val ADDRESS_B = "AA:BB:CC:DD:EE:F2" private const val ADDRESS_B = "AA:BB:CC:DD:EE:F2"