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"