fix(reaction): Stop bonded-device queries from poisoning connect times

seenFirstAt on a connected device is meant to be the connect time, and the
connected-devices flow maintains that by pruning its cache to the currently
connected addresses on every emission. bondedDevices() wrote into the same
cache for every bonded device, connected or not, and never pruned.

AapAutoConnect queries bonded devices on every connected-devices emission,
the disconnect one included, so an entry pruned at disconnect was re-stamped
milliseconds later at disconnect time. The next reconnect then inherited the
previous disconnect as its connect time, and the popup reaction rejects a
connection older than 30 seconds — so any reconnect more than half a minute
after the previous disconnect silently lost its popup.

bondedDevices() is now a cache reader: a connected bonded device still
reports its true connect time, a non-connected one gets the current time,
which no caller reads. The connected-devices path becomes the cache's only
writer, which is the invariant its prune-and-stamp logic already assumed.
This commit is contained in:
darken
2026-08-31 15:54:51 +02:00
committed by Matthias Urhahn
parent f82adeba42
commit ff5d0cedfa
2 changed files with 71 additions and 5 deletions
@@ -311,12 +311,10 @@ class BluetoothManager2 @Inject constructor(
address = device.address,
name = device.name,
internal = device,
// Read-only: a bonded device is not necessarily connected, and writing here would
// re-stamp entries that [connectedDevices] just pruned.
seenFirstAt = seenDevicesLock.withLock {
seenDevicesCache[device.address] ?: run {
val now = timeSource.now()
seenDevicesCache[device.address] = now
now
}
seenDevicesCache[device.address] ?: timeSource.now()
}
)
@@ -0,0 +1,68 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.content.Context
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
import testhelpers.coroutine.TestDispatcherProvider
import java.time.Duration
class BluetoothManager2Test : BaseTest() {
private val deviceA = mockk<BluetoothDevice>().apply {
every { address } returns ADDRESS_A
every { name } returns "Pods A"
}
private val deviceB = mockk<BluetoothDevice>().apply {
every { address } returns ADDRESS_B
every { name } returns "Pods B"
}
private val btAdapter = mockk<BluetoothAdapter>().apply {
every { bondedDevices } returns setOf(deviceA, deviceB)
}
private val btManager = mockk<BluetoothManager>().apply {
every { adapter } returns btAdapter
}
private val timeSource = TestTimeSource()
private fun TestScope.create() = BluetoothManager2(
appScope = backgroundScope,
dispatcherProvider = TestDispatcherProvider(),
context = mockk<Context>(),
manager = btManager,
timeSource = timeSource,
)
private suspend fun BluetoothManager2.bonded(address: BluetoothAddress) =
bondedDevices().first().single { it.address == address }
/**
* A bonded device is not necessarily a connected one, so querying bonded devices must not put a
* timestamp into the cache that [BluetoothManager2.connectedDevices] later reads as a connect
* time.
*/
@Test
fun `querying bonded devices does not cache a timestamp`() = runTest {
val manager = create()
manager.bonded(ADDRESS_A).seenFirstAt shouldBe timeSource.now()
timeSource.advanceBy(Duration.ofSeconds(60))
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"
}
}