From d7988189a05ab9ef07e93a056e96bf54045795b1 Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 31 Aug 2026 12:21:48 +0200 Subject: [PATCH] fix(reaction): Stamp connect times from the ACL broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/src/main/AndroidManifest.xml | 1 + .../common/bluetooth/BluetoothManager2.kt | 20 +++++++ .../core/receiver/BluetoothEventReceiver.kt | 11 ++++ .../common/bluetooth/BluetoothManager2Test.kt | 58 ++++++++++++++++++- 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9ae1a694..3feab0be 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -86,6 +86,7 @@ android:label="Service trigger"> + diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt index 0a58c12b..f97a1d9f 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt @@ -237,6 +237,26 @@ class BluetoothManager2 @Inject constructor( private val seenDevicesLock = Mutex() private val seenDevicesCache = mutableMapOf() + /** + * 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> = isBluetoothEnabled .flatMapLatest { enabled -> if (enabled) monitorProfile(BluetoothProfile.HEADSET) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/receiver/BluetoothEventReceiver.kt b/app/src/main/java/eu/darken/capod/monitor/core/receiver/BluetoothEventReceiver.kt index 69c7046a..0c908797 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/receiver/BluetoothEventReceiver.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/receiver/BluetoothEventReceiver.kt @@ -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) } diff --git a/app/src/test/java/eu/darken/capod/common/bluetooth/BluetoothManager2Test.kt b/app/src/test/java/eu/darken/capod/common/bluetooth/BluetoothManager2Test.kt index 6241cdfa..8b951dae 100644 --- a/app/src/test/java/eu/darken/capod/common/bluetooth/BluetoothManager2Test.kt +++ b/app/src/test/java/eu/darken/capod/common/bluetooth/BluetoothManager2Test.kt @@ -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(), 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"