mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user