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"