mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
6
Commits
b66c756741
...
92dac661c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92dac661c4 | ||
|
|
7712b18388 | ||
|
|
d7988189a0 | ||
|
|
ff5d0cedfa | ||
|
|
f82adeba42 | ||
|
|
cc99ad59ab |
@@ -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)
|
||||||
@@ -311,12 +331,10 @@ class BluetoothManager2 @Inject constructor(
|
|||||||
address = device.address,
|
address = device.address,
|
||||||
name = device.name,
|
name = device.name,
|
||||||
internal = device,
|
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 {
|
seenFirstAt = seenDevicesLock.withLock {
|
||||||
seenDevicesCache[device.address] ?: run {
|
seenDevicesCache[device.address] ?: timeSource.now()
|
||||||
val now = timeSource.now()
|
|
||||||
seenDevicesCache[device.address] = now
|
|
||||||
now
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package eu.darken.capod.monitor.core.aap
|
package eu.darken.capod.monitor.core.aap
|
||||||
|
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||||
@@ -33,15 +34,22 @@ class AapKeyPersister @Inject constructor(
|
|||||||
return@onEach
|
return@onEach
|
||||||
}
|
}
|
||||||
|
|
||||||
val updated = profile.copy(
|
// The profile's key fields are ByteArrays, so the generated equals() compares them by
|
||||||
identityKey = keys.irk ?: profile.identityKey,
|
// reference — content comparison is what decides whether anything actually changed.
|
||||||
encryptionKey = keys.encKey ?: profile.encryptionKey,
|
val irkChanged = keys.irk != null && !keys.irk.contentEquals(profile.identityKey)
|
||||||
)
|
val encChanged = keys.encKey != null && !keys.encKey.contentEquals(profile.encryptionKey)
|
||||||
|
|
||||||
if (updated != profile) {
|
if (!irkChanged && !encChanged) {
|
||||||
profilesRepo.updateProfile(updated)
|
log(TAG, VERBOSE) { "Keys for $address are unchanged, skipping key persistence" }
|
||||||
log(TAG) { "Persisted keys for $address (IRK=${keys.irk != null}, ENC=${keys.encKey != null})" }
|
return@onEach
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val updated = profile.copy(
|
||||||
|
identityKey = if (irkChanged) keys.irk else profile.identityKey,
|
||||||
|
encryptionKey = if (encChanged) keys.encKey else profile.encryptionKey,
|
||||||
|
)
|
||||||
|
profilesRepo.updateProfile(updated)
|
||||||
|
log(TAG) { "Persisted keys for $address (IRK changed=$irkChanged, ENC changed=$encChanged)" }
|
||||||
}
|
}
|
||||||
.map { }
|
.map { }
|
||||||
.setupCommonEventHandlers(TAG) { "keyPersister" }
|
.setupCommonEventHandlers(TAG) { "keyPersister" }
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.cancelChildren
|
import kotlinx.coroutines.cancelChildren
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -95,6 +98,7 @@ class MonitorService : Service() {
|
|||||||
|
|
||||||
private val monitorScope = MonitorCoroutineScope()
|
private val monitorScope = MonitorCoroutineScope()
|
||||||
private var monitoringJob: Job? = null
|
private var monitoringJob: Job? = null
|
||||||
|
private val startSignal = MutableStateFlow(0L)
|
||||||
@Volatile private var monitorGeneration = 0
|
@Volatile private var monitorGeneration = 0
|
||||||
private var foregroundStartFailed = false
|
private var foregroundStartFailed = false
|
||||||
private var injectionComplete = false
|
private var injectionComplete = false
|
||||||
@@ -212,6 +216,8 @@ class MonitorService : Service() {
|
|||||||
|
|
||||||
if (monitoringJob?.isActive == true && !forceStart) {
|
if (monitoringJob?.isActive == true && !forceStart) {
|
||||||
log(TAG) { "Already monitoring and forceStart=false, keeping current session." }
|
log(TAG) { "Already monitoring and forceStart=false, keeping current session." }
|
||||||
|
// Fresh evidence of activity: re-arm any pending teardown countdown.
|
||||||
|
startSignal.value++
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +320,7 @@ class MonitorService : Service() {
|
|||||||
}
|
}
|
||||||
.launchIn(monitorScope)
|
.launchIn(monitorScope)
|
||||||
|
|
||||||
permissionTool.missingScanPermissions
|
val modeStates = permissionTool.missingScanPermissions
|
||||||
.flatMapLatest { missingPermsFlow ->
|
.flatMapLatest { missingPermsFlow ->
|
||||||
if (missingPermsFlow.isNotEmpty()) {
|
if (missingPermsFlow.isNotEmpty()) {
|
||||||
log(TAG, WARN) { "Aborting, scan permissions are missing: $missingPermsFlow" }
|
log(TAG, WARN) { "Aborting, scan permissions are missing: $missingPermsFlow" }
|
||||||
@@ -333,38 +339,13 @@ class MonitorService : Service() {
|
|||||||
}
|
}
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.setupCommonEventHandlers(TAG) { "MonitorMode" }
|
.setupCommonEventHandlers(TAG) { "MonitorMode" }
|
||||||
.flatMapLatest { state ->
|
|
||||||
log(TAG) { "Monitor mode: ${state.mode}" }
|
|
||||||
log(TAG) { "connectedAddresses: ${state.connectedAddresses}" }
|
|
||||||
log(TAG) { "knownAddresses: ${state.knownAddresses}" }
|
|
||||||
|
|
||||||
when (state.mode) {
|
monitorModeFlow(
|
||||||
MonitorMode.MANUAL -> flow<Unit> {
|
tag = TAG,
|
||||||
monitorScope.coroutineContext.cancelChildren()
|
modeStates = modeStates,
|
||||||
}
|
startSignal = startSignal,
|
||||||
|
onTeardown = { monitorScope.coroutineContext.cancelChildren() },
|
||||||
MonitorMode.ALWAYS -> emptyFlow()
|
)
|
||||||
MonitorMode.AUTOMATIC -> flow {
|
|
||||||
when {
|
|
||||||
!state.hasProfiles && state.connectedAddresses.isNotEmpty() -> {
|
|
||||||
log(TAG, WARN) { "Main device address not set, staying alive while any is connected" }
|
|
||||||
}
|
|
||||||
|
|
||||||
state.knownAddresses.any { it in state.connectedAddresses } || state.hasAapSession -> {
|
|
||||||
log(TAG) { "A device is connected, aborting any timeout." }
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
log(TAG) { "No known Pods are connected, stopping service soon." }
|
|
||||||
delay(15 * 1000)
|
|
||||||
log(TAG) { "Stopping service now, still no Pods connected." }
|
|
||||||
|
|
||||||
monitorScope.coroutineContext.cancelChildren()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.catch {
|
.catch {
|
||||||
log(TAG, WARN) { "MonitorMode Flow failed:\n${it.asLog()}" }
|
log(TAG, WARN) { "MonitorMode Flow failed:\n${it.asLog()}" }
|
||||||
}
|
}
|
||||||
@@ -492,6 +473,60 @@ internal data class MonitorModeState(
|
|||||||
val hasAapSession: Boolean,
|
val hasAapSession: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether a monitor session may keep running. In [MonitorMode.AUTOMATIC] with nothing
|
||||||
|
* connected the teardown runs [timeoutMillis] after the state was seen.
|
||||||
|
*
|
||||||
|
* [startSignal] is bumped by a start request that found a live session and short-circuited. The
|
||||||
|
* bump restarts this flow, arming a fresh window, and the pre-teardown re-check covers the case
|
||||||
|
* where the bump lands while the expired countdown is already unwinding.
|
||||||
|
*/
|
||||||
|
internal fun monitorModeFlow(
|
||||||
|
tag: String,
|
||||||
|
modeStates: Flow<MonitorModeState>,
|
||||||
|
startSignal: StateFlow<Long>,
|
||||||
|
timeoutMillis: Long = 15 * 1000,
|
||||||
|
onTeardown: () -> Unit,
|
||||||
|
): Flow<Unit> = combine(modeStates, startSignal) { state, signal ->
|
||||||
|
state to signal
|
||||||
|
}.flatMapLatest { (state, armedSignal) ->
|
||||||
|
log(tag) { "Monitor mode: ${state.mode}" }
|
||||||
|
log(tag) { "connectedAddresses: ${state.connectedAddresses}" }
|
||||||
|
log(tag) { "knownAddresses: ${state.knownAddresses}" }
|
||||||
|
|
||||||
|
when (state.mode) {
|
||||||
|
MonitorMode.MANUAL -> flow<Unit> {
|
||||||
|
onTeardown()
|
||||||
|
}
|
||||||
|
|
||||||
|
MonitorMode.ALWAYS -> emptyFlow()
|
||||||
|
MonitorMode.AUTOMATIC -> flow {
|
||||||
|
when {
|
||||||
|
!state.hasProfiles && state.connectedAddresses.isNotEmpty() -> {
|
||||||
|
log(tag, WARN) { "Main device address not set, staying alive while any is connected" }
|
||||||
|
}
|
||||||
|
|
||||||
|
state.knownAddresses.any { it in state.connectedAddresses } || state.hasAapSession -> {
|
||||||
|
log(tag) { "A device is connected, aborting any timeout." }
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
log(tag) { "No known Pods are connected, stopping service soon." }
|
||||||
|
delay(timeoutMillis)
|
||||||
|
|
||||||
|
if (startSignal.value != armedSignal) {
|
||||||
|
log(tag) { "A start request arrived during the timeout, staying alive." }
|
||||||
|
return@flow
|
||||||
|
}
|
||||||
|
log(tag) { "Stopping service now, still no Pods connected." }
|
||||||
|
|
||||||
|
onTeardown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun buildMonitorModeState(
|
internal fun buildMonitorModeState(
|
||||||
mode: MonitorMode,
|
mode: MonitorMode,
|
||||||
profiles: List<DeviceProfile>,
|
profiles: List<DeviceProfile>,
|
||||||
|
|||||||
@@ -70,9 +70,12 @@ class AppleFactory @Inject constructor(
|
|||||||
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) } ?: unknownAppleFactory
|
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) } ?: unknownAppleFactory
|
||||||
|
|
||||||
val profiles = profilesRepo.currentProfiles().filterIsInstance<AppleDeviceProfile>()
|
val profiles = profilesRepo.currentProfiles().filterIsInstance<AppleDeviceProfile>()
|
||||||
var profile = profiles.firstOrNull {
|
val irkMatch = profiles.firstNotNullOfOrNull { candidate ->
|
||||||
it.identityKey != null && rpaChecker.verify(scanResult.address, it.identityKey)
|
val identityKey = candidate.identityKey ?: return@firstNotNullOfOrNull null
|
||||||
|
rpaChecker.resolve(scanResult.address, identityKey)?.let { candidate to it }
|
||||||
}
|
}
|
||||||
|
var profile = irkMatch?.first
|
||||||
|
val irkOrder = irkMatch?.second
|
||||||
|
|
||||||
val isIrkMatch = profile != null
|
val isIrkMatch = profile != null
|
||||||
if (isIrkMatch) {
|
if (isIrkMatch) {
|
||||||
@@ -115,7 +118,10 @@ class AppleFactory @Inject constructor(
|
|||||||
.filter { it.identityKey != null && (it.model == PodModel.UNKNOWN || it.model == tempDevice.model) }
|
.filter { it.identityKey != null && (it.model == PodModel.UNKNOWN || it.model == tempDevice.model) }
|
||||||
.firstOrNull { it.minimumSignalQuality <= tempDevice.signalQuality }
|
.firstOrNull { it.minimumSignalQuality <= tempDevice.signalQuality }
|
||||||
if (legacyCandidate != null) {
|
if (legacyCandidate != null) {
|
||||||
log(TAG, WARN) { "Keyed profile ${legacyCandidate.id} would match via old fallback (IRK failed) — stale key?" }
|
log(TAG, WARN) {
|
||||||
|
"Keyed profile ${legacyCandidate.id} would match via old fallback, " +
|
||||||
|
"its key resolved neither address order"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +142,8 @@ class AppleFactory @Inject constructor(
|
|||||||
val publicHex = payload.public.data.joinToString(" ") { "%02X".format(it.toInt()) }
|
val publicHex = payload.public.data.joinToString(" ") { "%02X".format(it.toInt()) }
|
||||||
val privateHex = payload.private?.data?.joinToString(" ") { "%02X".format(it.toInt()) } ?: "-"
|
val privateHex = payload.private?.data?.joinToString(" ") { "%02X".format(it.toInt()) } ?: "-"
|
||||||
"Apple decoded: model=${device.model}, addr=${scanResult.address.redactedForLogs()}, " +
|
"Apple decoded: model=${device.model}, addr=${scanResult.address.redactedForLogs()}, " +
|
||||||
"irkMatch=$isIrkMatch, raw=[$rawHex], public=[$publicHex], private=[$privateHex]"
|
"irkMatch=$isIrkMatch, irkOrder=${irkOrder?.name ?: "-"}, " +
|
||||||
|
"raw=[$rawHex], public=[$publicHex], private=[$privateHex]"
|
||||||
}
|
}
|
||||||
|
|
||||||
device
|
device
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ class PodHistoryRepo @Inject constructor(
|
|||||||
|
|
||||||
if (profile != null) {
|
if (profile != null) {
|
||||||
recognizedDevice = knownDevices.values
|
recognizedDevice = knownDevices.values
|
||||||
|
.filter { it.boundProfileId == null || it.boundProfileId == profile.id }
|
||||||
.firstOrNull { rpaChecker.verify(it.lastAddress, profile.identityKey!!) }
|
.firstOrNull { rpaChecker.verify(it.lastAddress, profile.identityKey!!) }
|
||||||
.also { log(TAG, VERBOSE) { "search1: Recovered via IRK: ${it?.logSummary()}" } }
|
.also { log(TAG, VERBOSE) { "search1: Recovered via IRK: ${it?.logSummary()}" } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,30 +2,73 @@ package eu.darken.capod.pods.core.apple.ble.protocol
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
|
import eu.darken.capod.common.bluetooth.redactedForLogs
|
||||||
import eu.darken.capod.common.debug.logging.Logging
|
import eu.darken.capod.common.debug.logging.Logging
|
||||||
import eu.darken.capod.common.debug.logging.asLog
|
import eu.darken.capod.common.debug.logging.asLog
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import okio.ByteString.Companion.toByteString
|
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class RPAChecker @Inject constructor() {
|
class RPAChecker @Inject constructor() {
|
||||||
|
|
||||||
|
enum class AddressOrder {
|
||||||
|
STANDARD,
|
||||||
|
REVERSED,
|
||||||
|
}
|
||||||
|
|
||||||
// Resolvable-Private-Address
|
// Resolvable-Private-Address
|
||||||
fun verify(address: BluetoothAddress, irk: IdentityResolvingKey): Boolean = try {
|
fun verify(address: BluetoothAddress, irk: IdentityResolvingKey): Boolean = resolve(address, irk) != null
|
||||||
val rpa = address.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
|
|
||||||
val prand = rpa.copyOfRange(3, 6)
|
/**
|
||||||
val hash = rpa.copyOfRange(0, 3)
|
* Returns the octet order the [address] resolved in, or null if it resolves in neither.
|
||||||
val computedHash = ah(irk, prand)
|
*
|
||||||
hash.contentEquals(computedHash)
|
* The reversed attempt is gated on the address being RPA-shaped in that order, the standard
|
||||||
|
* attempt is not: a device that resolves today must keep resolving byte-for-byte the same way.
|
||||||
|
*/
|
||||||
|
fun resolve(address: BluetoothAddress, irk: IdentityResolvingKey): AddressOrder? = try {
|
||||||
|
val octets = address.parseOctets()
|
||||||
|
when {
|
||||||
|
octets == null -> {
|
||||||
|
log(TAG, Logging.Priority.WARN) {
|
||||||
|
"Failed to resolve RPA, malformed address: ${address.redactedForLogs()}"
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
matchesHash(octets, irk) -> AddressOrder.STANDARD
|
||||||
|
else -> octets.reversedArray()
|
||||||
|
.takeIf { it.isRpaShaped() && matchesHash(it, irk) }
|
||||||
|
?.let { AddressOrder.REVERSED }
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log(
|
log(
|
||||||
TAG,
|
TAG,
|
||||||
Logging.Priority.ERROR
|
Logging.Priority.ERROR
|
||||||
) { "Failed to verify RPA\naddress=${address}\nIRK=${irk.toByteString()}\n${e.asLog()}" }
|
) { "Failed to resolve RPA\naddress=${address.redactedForLogs()}\nIRK=${irk.size}B\n${e.asLog()}" }
|
||||||
false
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Six octets, most-significant first, as printed. Null if [this] isn't a well-formed address. */
|
||||||
|
private fun BluetoothAddress.parseOctets(): ByteArray? {
|
||||||
|
val parts = split(":")
|
||||||
|
if (parts.size != ADDRESS_OCTETS) return null
|
||||||
|
val octets = ByteArray(ADDRESS_OCTETS)
|
||||||
|
parts.forEachIndexed { index, part ->
|
||||||
|
val value = part.toIntOrNull(16) ?: return null
|
||||||
|
if (value !in 0..255) return null
|
||||||
|
octets[index] = value.toByte()
|
||||||
|
}
|
||||||
|
return octets
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.isRpaShaped(): Boolean = (this[0].toInt() and 0xC0) == 0x40
|
||||||
|
|
||||||
|
private fun matchesHash(octets: ByteArray, irk: IdentityResolvingKey): Boolean {
|
||||||
|
val rpa = octets.reversedArray()
|
||||||
|
val prand = rpa.copyOfRange(3, 6)
|
||||||
|
val hash = rpa.copyOfRange(0, 3)
|
||||||
|
return hash.contentEquals(ah(irk, prand))
|
||||||
}
|
}
|
||||||
|
|
||||||
// E function (Encryption function):
|
// E function (Encryption function):
|
||||||
@@ -47,6 +90,7 @@ class RPAChecker @Inject constructor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val ADDRESS_OCTETS = 6
|
||||||
private val TAG = logTag("Monitor", "BlePodMonitor", "RPAChecker")
|
private val TAG = logTag("Monitor", "BlePodMonitor", "RPAChecker")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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.CoroutineScope
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
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
|
||||||
|
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()
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
package eu.darken.capod.monitor.core
|
package eu.darken.capod.monitor.core
|
||||||
|
|
||||||
|
import eu.darken.capod.common.debug.logging.Logging
|
||||||
import eu.darken.capod.common.fromHex
|
import eu.darken.capod.common.fromHex
|
||||||
|
import eu.darken.capod.common.toHex
|
||||||
import eu.darken.capod.pods.core.apple.ble.protocol.RPAChecker
|
import eu.darken.capod.pods.core.apple.ble.protocol.RPAChecker
|
||||||
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import testhelpers.BaseTest
|
import testhelpers.BaseTest
|
||||||
|
import testhelpers.logging.JUnitLogger
|
||||||
|
|
||||||
class RPACheckerTest : BaseTest() {
|
class RPACheckerTest : BaseTest() {
|
||||||
|
|
||||||
|
private val irkHex = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79"
|
||||||
|
private val resolvingAddress = "5A:16:2B:91:D1:CD"
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test check`() {
|
fun `test check`() {
|
||||||
val checker = RPAChecker()
|
val checker = RPAChecker()
|
||||||
@@ -37,4 +44,150 @@ class RPACheckerTest : BaseTest() {
|
|||||||
irk = "".fromHex(),
|
irk = "".fromHex(),
|
||||||
) shouldBe false
|
) shouldBe false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve reports the standard octet order`() {
|
||||||
|
val checker = RPAChecker()
|
||||||
|
checker.resolve(
|
||||||
|
address = resolvingAddress,
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
) shouldBe RPAChecker.AddressOrder.STANDARD
|
||||||
|
checker.verify(
|
||||||
|
address = resolvingAddress,
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
) shouldBe true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolve reports the reversed octet order`() {
|
||||||
|
val checker = RPAChecker()
|
||||||
|
// The resolving address with its octets reversed, as a vendor stack delivering them backwards.
|
||||||
|
checker.resolve(
|
||||||
|
address = "CD:D1:91:2B:16:5A",
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
) shouldBe RPAChecker.AddressOrder.REVERSED
|
||||||
|
checker.verify(
|
||||||
|
address = "CD:D1:91:2B:16:5A",
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
) shouldBe true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a reversed candidate still has to pass the hash comparison`() {
|
||||||
|
val checker = RPAChecker()
|
||||||
|
// Reversed form 5B:16:2B:91:D1:CD is RPA-shaped, so the marker gate passes and only the
|
||||||
|
// hash comparison can reject it.
|
||||||
|
checker.resolve(
|
||||||
|
address = "CD:D1:91:2B:16:5B",
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
).shouldBeNull()
|
||||||
|
checker.verify(
|
||||||
|
address = "CD:D1:91:2B:16:5B",
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
) shouldBe false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a reversed candidate is gated on the RPA type marker`() {
|
||||||
|
val checker = RPAChecker()
|
||||||
|
// Reversed form 1A:16:2B:85:33:DC resolves cryptographically, but its type marker is 00,
|
||||||
|
// so only the gate rejects it.
|
||||||
|
checker.resolve(
|
||||||
|
address = "DC:33:85:2B:16:1A",
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
).shouldBeNull()
|
||||||
|
checker.verify(
|
||||||
|
address = "DC:33:85:2B:16:1A",
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
) shouldBe false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `malformed addresses are rejected instead of mis-sliced`() {
|
||||||
|
val checker = RPAChecker()
|
||||||
|
// Seven components whose tail is the resolving address — sliced rather than rejected, this
|
||||||
|
// resolves.
|
||||||
|
checker.resolve(
|
||||||
|
address = "00:$resolvingAddress",
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
).shouldBeNull()
|
||||||
|
checker.verify(
|
||||||
|
address = "00:$resolvingAddress",
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
) shouldBe false
|
||||||
|
// 0x15A truncates to the resolving octet 0x5A.
|
||||||
|
checker.resolve(
|
||||||
|
address = "15A:16:2B:91:D1:CD",
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
).shouldBeNull()
|
||||||
|
checker.verify(
|
||||||
|
address = "15A:16:2B:91:D1:CD",
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
) shouldBe false
|
||||||
|
checker.resolve(
|
||||||
|
address = "5A:16:2B:91:D1",
|
||||||
|
irk = irkHex.fromHex(),
|
||||||
|
).shouldBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the failure log carries neither the identity key nor the full address`() {
|
||||||
|
val malformedIrk = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE".fromHex()
|
||||||
|
val captured = mutableListOf<Pair<Logging.Priority, String>>()
|
||||||
|
val capturingLogger = object : Logging.Logger {
|
||||||
|
override fun log(
|
||||||
|
priority: Logging.Priority,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
metaData: Map<String, Any>?,
|
||||||
|
) {
|
||||||
|
captured.add(priority to message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logging.clearAll()
|
||||||
|
Logging.install(capturingLogger)
|
||||||
|
try {
|
||||||
|
RPAChecker().verify(address = resolvingAddress, irk = malformedIrk) shouldBe false
|
||||||
|
} finally {
|
||||||
|
Logging.clearAll()
|
||||||
|
Logging.install(JUnitLogger())
|
||||||
|
}
|
||||||
|
|
||||||
|
captured.any { (priority, message) ->
|
||||||
|
priority == Logging.Priority.ERROR && message.contains("Failed to resolve RPA")
|
||||||
|
} shouldBe true
|
||||||
|
|
||||||
|
val keyHex = malformedIrk.toHex(separator = "")
|
||||||
|
captured.any { (_, message) -> message.contains(keyHex, ignoreCase = true) } shouldBe false
|
||||||
|
captured.any { (_, message) -> message.contains(resolvingAddress) } shouldBe false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a malformed address is logged as a warning`() {
|
||||||
|
val captured = mutableListOf<Pair<Logging.Priority, String>>()
|
||||||
|
val capturingLogger = object : Logging.Logger {
|
||||||
|
override fun log(
|
||||||
|
priority: Logging.Priority,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
metaData: Map<String, Any>?,
|
||||||
|
) {
|
||||||
|
captured.add(priority to message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logging.clearAll()
|
||||||
|
Logging.install(capturingLogger)
|
||||||
|
try {
|
||||||
|
RPAChecker().verify(address = "123", irk = irkHex.fromHex()) shouldBe false
|
||||||
|
} finally {
|
||||||
|
Logging.clearAll()
|
||||||
|
Logging.install(JUnitLogger())
|
||||||
|
}
|
||||||
|
|
||||||
|
captured.any { (priority, message) ->
|
||||||
|
priority == Logging.Priority.WARN && message.contains("malformed")
|
||||||
|
} shouldBe true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package eu.darken.capod.monitor.core.aap
|
||||||
|
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
|
import eu.darken.capod.common.fromHex
|
||||||
|
import eu.darken.capod.pods.core.apple.PodModel
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||||
|
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||||
|
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||||
|
import eu.darken.capod.profiles.core.DeviceProfile
|
||||||
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import testhelpers.BaseTest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key fields on [AppleDeviceProfile] are ByteArrays, so a wrong null-handling or
|
||||||
|
* reference-comparison here either writes the profile on every connect or stops persisting keys
|
||||||
|
* entirely.
|
||||||
|
*/
|
||||||
|
class AapKeyPersisterTest : BaseTest() {
|
||||||
|
|
||||||
|
private val address: BluetoothAddress = "AA:BB:CC:DD:EE:FF"
|
||||||
|
private val irkHex = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79"
|
||||||
|
private val encHex = "11-22-33-44-55-66-77-88-99-AA-BB-CC-DD-EE-FF-00"
|
||||||
|
|
||||||
|
private val aapManager = mockk<AapConnectionManager>()
|
||||||
|
private val profilesRepo = mockk<DeviceProfilesRepo>()
|
||||||
|
|
||||||
|
private suspend fun runPersister(profile: AppleDeviceProfile, keys: KeyExchangeResult) {
|
||||||
|
val keysReceived = MutableSharedFlow<Pair<BluetoothAddress, KeyExchangeResult>>(replay = 1)
|
||||||
|
keysReceived.tryEmit(address to keys)
|
||||||
|
|
||||||
|
every { aapManager.keysReceived } returns keysReceived
|
||||||
|
every { profilesRepo.profiles } returns flowOf(listOf(profile))
|
||||||
|
coEvery { profilesRepo.updateProfile(any()) } returns Unit
|
||||||
|
|
||||||
|
AapKeyPersister(aapManager, profilesRepo).monitor().first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun profile(
|
||||||
|
identityKey: ByteArray? = null,
|
||||||
|
encryptionKey: ByteArray? = null,
|
||||||
|
) = AppleDeviceProfile(
|
||||||
|
label = "Mine",
|
||||||
|
model = PodModel.AIRPODS_PRO2_USBC,
|
||||||
|
identityKey = identityKey,
|
||||||
|
encryptionKey = encryptionKey,
|
||||||
|
address = address,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun capturedProfile(): AppleDeviceProfile {
|
||||||
|
val captured = slot<DeviceProfile>()
|
||||||
|
coVerify(exactly = 1) { profilesRepo.updateProfile(capture(captured)) }
|
||||||
|
return captured.captured as AppleDeviceProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a first key is persisted`() = runTest {
|
||||||
|
runPersister(
|
||||||
|
profile = profile(),
|
||||||
|
keys = KeyExchangeResult(irk = irkHex.fromHex(), encKey = null),
|
||||||
|
)
|
||||||
|
|
||||||
|
capturedProfile().identityKey.contentEquals(irkHex.fromHex()) shouldBe true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a key equal by content is not persisted again`() = runTest {
|
||||||
|
runPersister(
|
||||||
|
profile = profile(identityKey = irkHex.fromHex(), encryptionKey = encHex.fromHex()),
|
||||||
|
keys = KeyExchangeResult(irk = irkHex.fromHex(), encKey = encHex.fromHex()),
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { profilesRepo.updateProfile(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a missing incoming key leaves the stored one alone`() = runTest {
|
||||||
|
runPersister(
|
||||||
|
profile = profile(identityKey = irkHex.fromHex(), encryptionKey = encHex.fromHex()),
|
||||||
|
keys = KeyExchangeResult(irk = null, encKey = encHex.fromHex()),
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { profilesRepo.updateProfile(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a changed encryption key is persisted without touching the identity key`() = runTest {
|
||||||
|
val newEnc = "00-11-22-33-44-55-66-77-88-99-AA-BB-CC-DD-EE-FF".fromHex()
|
||||||
|
runPersister(
|
||||||
|
profile = profile(identityKey = irkHex.fromHex(), encryptionKey = encHex.fromHex()),
|
||||||
|
keys = KeyExchangeResult(irk = irkHex.fromHex(), encKey = newEnc),
|
||||||
|
)
|
||||||
|
|
||||||
|
val written = capturedProfile()
|
||||||
|
written.encryptionKey.contentEquals(newEnc) shouldBe true
|
||||||
|
written.identityKey.contentEquals(irkHex.fromHex()) shouldBe true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package eu.darken.capod.monitor.core.worker
|
||||||
|
|
||||||
|
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||||
|
import eu.darken.capod.main.core.MonitorMode
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import testhelpers.BaseTest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The AUTOMATIC teardown is only cancelled by a new state emission, but a start request that finds
|
||||||
|
* a live session short-circuits without producing one — the connection state that would abort the
|
||||||
|
* countdown lags the Bluetooth event by roughly a second. A request landing in the tail of the
|
||||||
|
* window used to be acknowledged and the session torn down anyway.
|
||||||
|
*/
|
||||||
|
class MonitorModeFlowTest : BaseTest() {
|
||||||
|
|
||||||
|
private fun state(
|
||||||
|
mode: MonitorMode = MonitorMode.AUTOMATIC,
|
||||||
|
hasProfiles: Boolean = true,
|
||||||
|
knownAddresses: Set<BluetoothAddress> = setOf(KNOWN_ADDRESS),
|
||||||
|
connectedAddresses: Set<BluetoothAddress> = emptySet(),
|
||||||
|
hasAapSession: Boolean = false,
|
||||||
|
) = MonitorModeState(
|
||||||
|
mode = mode,
|
||||||
|
hasProfiles = hasProfiles,
|
||||||
|
knownAddresses = knownAddresses,
|
||||||
|
connectedAddresses = connectedAddresses,
|
||||||
|
hasAapSession = hasAapSession,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing connected tears the session down after the timeout`() = runTest {
|
||||||
|
var teardowns = 0
|
||||||
|
val job = monitorModeFlow(
|
||||||
|
tag = TAG,
|
||||||
|
modeStates = flowOf(state()),
|
||||||
|
startSignal = MutableStateFlow(0L),
|
||||||
|
onTeardown = { teardowns++ },
|
||||||
|
).launchIn(this)
|
||||||
|
|
||||||
|
advanceTimeBy(TIMEOUT)
|
||||||
|
teardowns shouldBe 0
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
teardowns shouldBe 1
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a start request during the window re-arms the countdown`() = runTest {
|
||||||
|
var teardowns = 0
|
||||||
|
val startSignal = MutableStateFlow(0L)
|
||||||
|
val job = monitorModeFlow(
|
||||||
|
tag = TAG,
|
||||||
|
modeStates = flowOf(state()),
|
||||||
|
startSignal = startSignal,
|
||||||
|
onTeardown = { teardowns++ },
|
||||||
|
).launchIn(this)
|
||||||
|
|
||||||
|
val bumpedAt = 14_750L
|
||||||
|
advanceTimeBy(bumpedAt)
|
||||||
|
runCurrent()
|
||||||
|
startSignal.value++
|
||||||
|
|
||||||
|
// The original window would have expired here.
|
||||||
|
advanceTimeBy(TIMEOUT - bumpedAt)
|
||||||
|
runCurrent()
|
||||||
|
teardowns shouldBe 0
|
||||||
|
|
||||||
|
// The re-armed window runs from the start request, not from the state emission.
|
||||||
|
advanceTimeBy(bumpedAt)
|
||||||
|
teardowns shouldBe 0
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
teardowns shouldBe 1
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a connected device aborts the countdown`() = runTest {
|
||||||
|
var teardowns = 0
|
||||||
|
val job = monitorModeFlow(
|
||||||
|
tag = TAG,
|
||||||
|
modeStates = flowOf(state(connectedAddresses = setOf(KNOWN_ADDRESS))),
|
||||||
|
startSignal = MutableStateFlow(0L),
|
||||||
|
onTeardown = { teardowns++ },
|
||||||
|
).launchIn(this)
|
||||||
|
|
||||||
|
advanceTimeBy(10 * TIMEOUT)
|
||||||
|
runCurrent()
|
||||||
|
teardowns shouldBe 0
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual mode tears the session down immediately`() = runTest {
|
||||||
|
var teardowns = 0
|
||||||
|
val job = monitorModeFlow(
|
||||||
|
tag = TAG,
|
||||||
|
modeStates = flowOf(state(mode = MonitorMode.MANUAL)),
|
||||||
|
startSignal = MutableStateFlow(0L),
|
||||||
|
onTeardown = { teardowns++ },
|
||||||
|
).launchIn(this)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
teardowns shouldBe 1
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MonitorModeFlowTest"
|
||||||
|
private const val TIMEOUT = 15 * 1000L
|
||||||
|
private const val KNOWN_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import io.kotest.matchers.types.shouldNotBeSameInstanceAs
|
|||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.Robolectric
|
import org.robolectric.Robolectric
|
||||||
@@ -42,6 +43,8 @@ class MonitorServiceTest {
|
|||||||
private fun MonitorService.getField(name: String): Any? =
|
private fun MonitorService.getField(name: String): Any? =
|
||||||
MonitorService::class.java.getDeclaredField(name).apply { isAccessible = true }.get(this)
|
MonitorService::class.java.getDeclaredField(name).apply { isAccessible = true }.get(this)
|
||||||
|
|
||||||
|
private fun MonitorService.startSignal(): Long = (getField("startSignal") as StateFlow<*>).value as Long
|
||||||
|
|
||||||
private fun notification(title: String): Notification =
|
private fun notification(title: String): Notification =
|
||||||
NotificationCompat.Builder(context, MonitorNotifications.NOTIFICATION_CHANNEL_ID)
|
NotificationCompat.Builder(context, MonitorNotifications.NOTIFICATION_CHANNEL_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
@@ -204,6 +207,22 @@ class MonitorServiceTest {
|
|||||||
service.getField("lastNotification").shouldBeNull()
|
service.getField("lastNotification").shouldBeNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A start request that finds a live session is acknowledged without touching it — including a
|
||||||
|
* teardown countdown that is already running. Bumping the signal is what re-arms that window.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a short-circuited start bumps the start signal`() {
|
||||||
|
val service = createService()
|
||||||
|
service.readyForMonitoring()
|
||||||
|
|
||||||
|
val before = service.startSignal()
|
||||||
|
|
||||||
|
service.onStartCommand(MonitorService.intent(context), 0, 1) shouldBe Service.START_STICKY
|
||||||
|
|
||||||
|
service.startSignal() shouldBe before + 1
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `onDestroy skips notification cleanup when injection never completed`() {
|
fun `onDestroy skips notification cleanup when injection never completed`() {
|
||||||
val service = createService()
|
val service = createService()
|
||||||
|
|||||||
+83
-1
@@ -27,8 +27,11 @@ class PodHistoryFuzzyCollisionTest : BaseBlePodsTest() {
|
|||||||
// IRK + a resolvable RPA pair lifted from RPACheckerTest.
|
// IRK + a resolvable RPA pair lifted from RPACheckerTest.
|
||||||
private val irkHex = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79"
|
private val irkHex = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79"
|
||||||
private val myRpa = "5A:16:2B:91:D1:CD" // resolves against irkHex
|
private val myRpa = "5A:16:2B:91:D1:CD" // resolves against irkHex
|
||||||
|
private val myRotatedRpa = "45:23:51:E3:40:6E" // also resolves against irkHex (computed)
|
||||||
private val foreignAddress = "77:49:4C:D8:25:0C" // does NOT resolve against irkHex
|
private val foreignAddress = "77:49:4C:D8:25:0C" // does NOT resolve against irkHex
|
||||||
|
|
||||||
|
private fun String.reversedOctets(): String = split(":").reversed().joinToString(":")
|
||||||
|
|
||||||
// A valid AirPods Pro 2 (USB-C) proximity advertisement (model 0x2420), reused for both frames.
|
// A valid AirPods Pro 2 (USB-C) proximity advertisement (model 0x2420), reused for both frames.
|
||||||
private val payload = "07 19 01 24 20 0B 99 8F 11 00 04 BD A7 3B FF 2D 8A 3C AF 9B 1A 7C 74 B7 A9 D1 C3"
|
private val payload = "07 19 01 24 20 0B 99 8F 11 00 04 BD A7 3B FF 2D 8A 3C AF 9B 1A 7C 74 B7 A9 D1 C3"
|
||||||
|
|
||||||
@@ -75,7 +78,6 @@ class PodHistoryFuzzyCollisionTest : BaseBlePodsTest() {
|
|||||||
address = "AA:BB:CC:DD:EE:FF",
|
address = "AA:BB:CC:DD:EE:FF",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val myRotatedRpa = "45:23:51:E3:40:6E" // also resolves against irkHex (computed)
|
|
||||||
|
|
||||||
lateinit var first: AirPodsPro2Usbc
|
lateinit var first: AirPodsPro2Usbc
|
||||||
create<AirPodsPro2Usbc>(payload, address = myRpa) { first = this }
|
create<AirPodsPro2Usbc>(payload, address = myRpa) { first = this }
|
||||||
@@ -87,4 +89,84 @@ class PodHistoryFuzzyCollisionTest : BaseBlePodsTest() {
|
|||||||
// Same physical device across rotation -> same stable identity (recovered via IRK).
|
// Same physical device across rotation -> same stable identity (recovered via IRK).
|
||||||
second.identifier shouldBe first.identifier
|
second.identifier shouldBe first.identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a device whose address arrives octet-reversed keeps one identity across a rotation`() = runTest {
|
||||||
|
profileList.add(
|
||||||
|
AppleDeviceProfile(
|
||||||
|
label = "Mine",
|
||||||
|
model = PodModel.AIRPODS_PRO2_USBC,
|
||||||
|
identityKey = irkHex.fromHex(),
|
||||||
|
address = "AA:BB:CC:DD:EE:FF",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
lateinit var first: AirPodsPro2Usbc
|
||||||
|
create<AirPodsPro2Usbc>(payload, address = myRpa.reversedOctets()) { first = this }
|
||||||
|
first.meta.isIRKMatch shouldBe true
|
||||||
|
first.meta.profile shouldNotBe null
|
||||||
|
|
||||||
|
lateinit var second: AirPodsPro2Usbc
|
||||||
|
create<AirPodsPro2Usbc>(payload, address = myRotatedRpa.reversedOctets()) { second = this }
|
||||||
|
second.meta.isIRKMatch shouldBe true
|
||||||
|
second.identifier shouldBe first.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a reversed foreign address must not inherit a keyed device's identity`() = runTest {
|
||||||
|
profileList.add(
|
||||||
|
AppleDeviceProfile(
|
||||||
|
label = "Mine",
|
||||||
|
model = PodModel.AIRPODS_PRO2_USBC,
|
||||||
|
identityKey = irkHex.fromHex(),
|
||||||
|
address = "AA:BB:CC:DD:EE:FF",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
lateinit var mine: AirPodsPro2Usbc
|
||||||
|
create<AirPodsPro2Usbc>(payload, address = myRpa) { mine = this }
|
||||||
|
mine.meta.isIRKMatch shouldBe true
|
||||||
|
|
||||||
|
// The reversed foreign address is RPA-shaped in its reversed form, so the alternate-order
|
||||||
|
// path runs its hash comparison on it — and must still refuse to attribute it.
|
||||||
|
lateinit var foreign: AirPodsPro2Usbc
|
||||||
|
create<AirPodsPro2Usbc>(payload, address = foreignAddress.reversedOctets()) { foreign = this }
|
||||||
|
foreign.meta.isIRKMatch shouldBe false
|
||||||
|
foreign.meta.profile shouldBe null
|
||||||
|
foreign.identifier shouldNotBe mine.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a history bound to another profile is not claimed by identity recovery`() = runTest {
|
||||||
|
// Both profiles carry the SAME key: with two independent keys the candidate would already
|
||||||
|
// fail the key check and the binding check would never be reached.
|
||||||
|
val profileOne = AppleDeviceProfile(
|
||||||
|
label = "First",
|
||||||
|
model = PodModel.AIRPODS_PRO2_USBC,
|
||||||
|
identityKey = irkHex.fromHex(),
|
||||||
|
address = "AA:BB:CC:DD:EE:F1",
|
||||||
|
)
|
||||||
|
val profileTwo = AppleDeviceProfile(
|
||||||
|
label = "Second",
|
||||||
|
model = PodModel.AIRPODS_PRO2_USBC,
|
||||||
|
identityKey = irkHex.fromHex(),
|
||||||
|
address = "AA:BB:CC:DD:EE:F2",
|
||||||
|
)
|
||||||
|
profileList.add(profileOne)
|
||||||
|
profileList.add(profileTwo)
|
||||||
|
|
||||||
|
// The first resolving profile wins, so this frame binds the history to profileOne.
|
||||||
|
lateinit var first: AirPodsPro2Usbc
|
||||||
|
create<AirPodsPro2Usbc>(payload, address = myRpa) { first = this }
|
||||||
|
first.meta.profile?.id shouldBe profileOne.id
|
||||||
|
|
||||||
|
// profileOne is gone, so the next frame resolves to profileTwo — which must not be handed
|
||||||
|
// the history that is already bound to profileOne.
|
||||||
|
profileList.remove(profileOne)
|
||||||
|
|
||||||
|
lateinit var second: AirPodsPro2Usbc
|
||||||
|
create<AirPodsPro2Usbc>(payload, address = myRotatedRpa) { second = this }
|
||||||
|
second.meta.profile?.id shouldBe profileTwo.id
|
||||||
|
second.identifier shouldNotBe first.identifier
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user