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