Compare commits

...
7 Commits
Author SHA1 Message Date
darken 3662904d75 fix(debug): Make the recorder the only writer of the debug flag
The module's flag collector was pure redundancy: State.recorder is only
ever written right after a Recorder.start()/stop() that already published
the same value. Its one added behaviour was a hazard - a late-delivered
committed stop could overwrite the flag a newer session's start() had just
set, blanking diagnostics for up to the five seconds the header read is
bounded at.

Recorder.start()/stop() stay the single writers, next to the file logger
they install.

The collector's test goes with it: it hard-coded a two-collector queue
depth that can no longer be reached. In its place, a test asserting the
flag against the recorder itself, and one that forces the late-stop
delivery through a hand-stepped app-scope dispatcher.

Fixes review findings F2, F3, F4
2026-09-01 10:11:01 +02:00
darken cec36268e0 fix(debug): Keep the flag collector off uncommitted recorder states
The collector mirrored isRecording from every state emission, including the
start and stop requests, whose value the recorder has already moved past. On a
loaded device that write can land after Recorder.start()'s, leaving isDebug
false for the whole header-read window (5s) while the file logger is live.

distinctUntilChangedBy collapses the initial state and the start request into
one emission, drop(1) removes it, so only committed transitions are published.
The operator order matters: drop(1) first would let the start request through.

Fixes review finding F1.
2026-09-01 10:11:01 +02:00
darken 5895ccbc27 fix(tests): Match only power snapshot lines in the log filter 2026-09-01 10:11:01 +02:00
darken 798ac32ec9 fix(tests): Pin the new Robolectric tests to SDK 33 2026-09-01 10:11:01 +02:00
darken 6ed0bf8257 feat(debug): Log the device power state while recording
A reception blackout in a capture is as easily the display going off as
a broken scan, and nothing in a capture currently says which. The full
snapshot is written on every screen, doze and power-save broadcast, plus
once when the recording starts, so a single line format answers both
"what is it now" and "what just changed".

The receiver is registered before the first snapshot is taken:
ACTION_SCREEN_OFF is not sticky, so a screen-off in between would appear
as neither a transition nor a corrected state.

The catch sits on the inner receiver flow rather than after
flatMapLatest. Flow.catch completes the flow it is applied to, so a
top-level catch would end the recording-flag collection on the first
failure and every later recording in the process would carry no power
state at all.

Nothing here may throw: onReceive is an Android callback outside any
flow, so a vendor PowerManager that throws would take the process down.
Each field is guarded individually so a bad read costs one value rather
than the line.
2026-09-01 10:11:01 +02:00
darken 39a6c3c89f feat(device): Log the effective BLE scan configuration
A capture that shows no scan results cannot currently answer whether the
scan was filtered. The configuration is decided once when the scan
starts, which is usually long before the recording that is meant to
diagnose it, so the line is re-emitted when a recording begins while the
scan is already running.

The re-emission drops against the value captured at the first emission
instead of drop(1): launchIn subscribes asynchronously, so a StateFlow
replays whatever is current at subscription time. A recording started in
that gap would be swallowed as if it were the initial value, which is
exactly the case the line exists for.

filterPolicy is a parameter rather than something derived from the
filter set, because the unfiltered mode is implemented as a single
match-all filter. A count-based summary would report it as a filtered
scan, inverting the answer.

"Requested" and not "filtering"/"batching": adapter capability plus our
own setting is what we asked the platform for, not proof that the
controller offloaded anything.
2026-09-01 10:11:01 +02:00
darken 2ba90e1d18 feat(debug): Flip the debug flag when the recorder starts writing
The flag was only written from committed recorder module state, which
publishes after the recording header has been read. That read is bounded
at 5s, so for up to five seconds the file logger is live while the flag
still says false, and diagnostics keyed off it are missing from exactly
the window a reporter uses to reproduce a screen-off issue.

The module-state writer stays: it covers a resumed session and any path
that reaches isRecording=false without going through Recorder.stop().
A rolled-back start self-corrects, because the rollback stops the
recorder before publishing the failure.

BaseTest resets the flag per test instance because it is JVM-global. It
goes into init rather than the companion teardown, which uses JUnit 5's
@AfterAll and never fires under the JUnit 4 Robolectric runner.
2026-09-01 10:11:01 +02:00
13 changed files with 931 additions and 19 deletions
+3
View File
@@ -6,6 +6,7 @@ import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import eu.darken.capod.common.BuildConfigWrap import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.PowerStateLogger
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.LogCatLogger import eu.darken.capod.common.debug.logging.LogCatLogger
import eu.darken.capod.common.debug.logging.Logging import eu.darken.capod.common.debug.logging.Logging
@@ -41,6 +42,7 @@ open class App : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var autoReporting: AutomaticBugReporter @Inject lateinit var autoReporting: AutomaticBugReporter
@Inject lateinit var powerStateLogger: PowerStateLogger
@Inject lateinit var deviceMonitor: DeviceMonitor @Inject lateinit var deviceMonitor: DeviceMonitor
@Inject lateinit var widgetManager: WidgetManager @Inject lateinit var widgetManager: WidgetManager
@Inject lateinit var upgradeRepo: UpgradeRepo @Inject lateinit var upgradeRepo: UpgradeRepo
@@ -69,6 +71,7 @@ open class App : Application(), Configuration.Provider {
) )
autoReporting.setup(this) autoReporting.setup(this)
powerStateLogger.setup()
log(TAG) { "onCreate() done! ${Exception().asLog()}" } log(TAG) { "onCreate() done! ${Exception().asLog()}" }
@@ -10,7 +10,9 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
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
@@ -21,6 +23,8 @@ import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -39,6 +43,7 @@ class BleScanner @Inject constructor(
@SuppressLint("MissingPermission") fun scan( @SuppressLint("MissingPermission") fun scan(
filters: Set<ScanFilter>, filters: Set<ScanFilter>,
filterPolicy: ScanFilterPolicy,
scannerMode: ScannerMode = ScannerMode.BALANCED, scannerMode: ScannerMode = ScannerMode.BALANCED,
disableOffloadFiltering: Boolean = false, disableOffloadFiltering: Boolean = false,
disableOffloadBatching: Boolean = false, disableOffloadBatching: Boolean = false,
@@ -50,14 +55,16 @@ class BleScanner @Inject constructor(
val adapter = bluetoothManager.adapter ?: throw IllegalStateException("Bluetooth adapter unavailable") val adapter = bluetoothManager.adapter ?: throw IllegalStateException("Bluetooth adapter unavailable")
val useOffloadedFiltering = adapter.isOffloadedFilteringSupported.also { val offloadFilteringSupported = adapter.isOffloadedFilteringSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" } log(TAG, if (it) DEBUG else WARN) { "isOffloadedFilteringSupported=$it" }
} && !disableOffloadFiltering }
val useOffloadedFiltering = offloadFilteringSupported && !disableOffloadFiltering
if (disableOffloadFiltering) log(TAG, WARN) { "Offloaded filtering is disabled!" } if (disableOffloadFiltering) log(TAG, WARN) { "Offloaded filtering is disabled!" }
val useOffloadedBatching = adapter.isOffloadedScanBatchingSupported.also { val offloadBatchingSupported = adapter.isOffloadedScanBatchingSupported.also {
log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" } log(TAG, if (it) DEBUG else WARN) { "isOffloadedScanBatchingSupported=$it" }
} && !disableOffloadBatching }
val useOffloadedBatching = offloadBatchingSupported && !disableOffloadBatching
if (disableOffloadBatching) log(TAG, WARN) { "Offloaded scan-batching is disabled!" } if (disableOffloadBatching) log(TAG, WARN) { "Offloaded scan-batching is disabled!" }
if (disableDirectScanCallback) log(TAG, WARN) { "Direct scan callback is disabled!" } if (disableDirectScanCallback) log(TAG, WARN) { "Direct scan callback is disabled!" }
@@ -128,6 +135,16 @@ class BleScanner @Inject constructor(
else -> emptyList() else -> emptyList()
} }
val reportDelayMs = if (useOffloadedBatching) {
when (scannerMode) {
ScannerMode.LOW_POWER -> 2000L
ScannerMode.BALANCED -> 1000L
ScannerMode.LOW_LATENCY -> 500L
}
} else {
0L // Anything > 0 enables batching
}
val scanSettings = ScanSettings.Builder().apply { val scanSettings = ScanSettings.Builder().apply {
setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
when (scannerMode) { when (scannerMode) {
@@ -148,18 +165,35 @@ class BleScanner @Inject constructor(
} }
} }
val delay = if (useOffloadedBatching) { setReportDelay(reportDelayMs)
when (scannerMode) {
ScannerMode.LOW_POWER -> 2000L
ScannerMode.BALANCED -> 1000L
ScannerMode.LOW_LATENCY -> 500L
}
} else {
0L // Anything > 0 enables batching
}
setReportDelay(delay)
}.build() }.build()
val config = ScanConfig(
scannerMode = scannerMode,
filterPolicy = filterPolicy,
platformFilterCount = filterList.size,
requestedFilterCount = filters.size,
offloadFilteringSupported = offloadFilteringSupported,
offloadFilteringDisabledBySetting = disableOffloadFiltering,
offloadBatchingSupported = offloadBatchingSupported,
offloadBatchingDisabledBySetting = disableOffloadBatching,
reportDelayMs = reportDelayMs,
directCallback = !disableDirectScanCallback,
)
log(TAG, INFO) { config.summary() }
// A recording usually starts while a scan is already running, i.e. after the line above went
// nowhere. Re-emit so the capture contains the configuration it is meant to diagnose.
val debugAtStart = Bugs.isDebug.value
Bugs.isDebug
// Not drop(1): launchIn subscribes asynchronously, so the replayed value is whatever is
// current at subscription time. A recording started in that gap would be discarded as
// though it were the initial value.
.dropWhile { it == debugAtStart }
.filter { it }
.onEach { log(TAG, INFO) { "${config.summary()} (recording started)" } }
.launchIn(this)
try { try {
if (disableDirectScanCallback) { if (disableDirectScanCallback) {
val callbackIntent = createStartIntent() val callbackIntent = createStartIntent()
@@ -239,3 +273,30 @@ class BleScanner @Inject constructor(
private val TAG = logTag("Bluetooth", "BleScanner") private val TAG = logTag("Bluetooth", "BleScanner")
} }
} }
internal data class ScanConfig(
val scannerMode: ScannerMode,
val filterPolicy: ScanFilterPolicy,
val platformFilterCount: Int,
val requestedFilterCount: Int,
val offloadFilteringSupported: Boolean,
val offloadFilteringDisabledBySetting: Boolean,
val offloadBatchingSupported: Boolean,
val offloadBatchingDisabledBySetting: Boolean,
val reportDelayMs: Long,
val directCallback: Boolean,
) {
// "Requested" rather than "filtering"/"batching": adapter capability plus our own setting is
// what we asked the platform for, not proof that the controller offloaded anything.
fun summary(): String {
val offloadFilteringRequested = offloadFilteringSupported && !offloadFilteringDisabledBySetting
val offloadBatchingRequested = offloadBatchingSupported && !offloadBatchingDisabledBySetting
return "Scan config: mode=$scannerMode, filterPolicy=$filterPolicy, " +
"platformFilters=$platformFilterCount/$requestedFilterCount, " +
"offloadFilteringRequested=$offloadFilteringRequested " +
"(supported=$offloadFilteringSupported, disabledBySetting=$offloadFilteringDisabledBySetting), " +
"offloadBatchingRequested=$offloadBatchingRequested " +
"(supported=$offloadBatchingSupported, disabledBySetting=$offloadBatchingDisabledBySetting), " +
"reportDelay=${reportDelayMs}ms, callback=${if (directCallback) "direct" else "intent"}"
}
}
@@ -0,0 +1,3 @@
package eu.darken.capod.common.bluetooth
enum class ScanFilterPolicy { PROXIMITY_PAIRING, MATCH_ALL }
@@ -0,0 +1,117 @@
package eu.darken.capod.common.debug
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.PowerManager
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import javax.inject.Inject
import javax.inject.Singleton
/**
* A BLE reception blackout can just as well be the display going off as the scan being wrong, and a
* capture currently carries no evidence either way. Only active while a debug recording runs.
*/
@Singleton
class PowerStateLogger @Inject constructor(
@ApplicationContext private val context: Context,
@AppScope private val appScope: CoroutineScope,
) {
fun setup() {
Bugs.isDebug
.flatMapLatest { isRecording ->
if (isRecording) {
// The catch belongs to the episode, not to the flag: Flow.catch completes the
// flow it is applied to, so catching after flatMapLatest would end the isDebug
// collection on the first failure and every later recording in this process
// would carry no power state at all.
powerStateEvents().catch { log(TAG, ERROR) { "Power state logging failed: ${it.asLog()}" } }
} else {
emptyFlow()
}
}
.launchIn(appScope)
}
private fun powerStateEvents(): Flow<Unit> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Android callback, outside any flow: a throw here takes the process with it.
try {
logPowerState(intent.action ?: "unknown")
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to log power state: ${e.asLog()}" }
}
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
}
// Registered before the first snapshot is taken: ACTION_SCREEN_OFF is not sticky, so a
// screen-off in between would show up as neither a transition nor a corrected state.
context.registerReceiver(receiver, filter)
logPowerState("recording started")
awaitClose {
try {
context.unregisterReceiver(receiver)
} catch (e: Exception) {
log(TAG, WARN) { "Failed to unregister receiver: ${e.asLog()}" }
}
}
}
private fun logPowerState(trigger: String) {
val powerManager = try {
context.getSystemService(PowerManager::class.java)
} catch (e: Exception) {
null
}
val interactive = powerManager.read { isInteractive }
val deviceIdle = powerManager.read { isDeviceIdleMode }
val powerSave = powerManager.read { isPowerSaveMode }
val ignoringBatteryOptimizations = powerManager.read { isIgnoringBatteryOptimizations(context.packageName) }
log(TAG, INFO) {
"Power state ($trigger): interactive=$interactive, deviceIdle=$deviceIdle, " +
"powerSave=$powerSave, ignoringBatteryOptimizations=$ignoringBatteryOptimizations"
}
}
// Per field, so an OEM PowerManager that throws costs one value instead of the whole line.
private fun PowerManager?.read(value: PowerManager.() -> Any?): String {
val powerManager = this ?: return UNAVAILABLE
return try {
powerManager.value()?.toString() ?: UNAVAILABLE
} catch (e: Exception) {
UNAVAILABLE
}
}
companion object {
private const val UNAVAILABLE = "unavailable"
private val TAG = logTag("Debug", "PowerStateLogger")
}
}
@@ -1,6 +1,7 @@
package eu.darken.capod.common.debug.recording.core package eu.darken.capod.common.debug.recording.core
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.FileLogger import eu.darken.capod.common.debug.logging.FileLogger
import eu.darken.capod.common.debug.logging.Logging import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
@@ -33,6 +34,10 @@ class Recorder @Inject constructor(
it.start() it.start()
Logging.install(it) Logging.install(it)
log(TAG, INFO) { "Now logging to file!" } log(TAG, INFO) { "Now logging to file!" }
// Flipped here rather than only from the committed module state: that publishes after
// the recording header has been read, and everything written in that window would miss
// the debug-only diagnostics that key off this flag.
Bugs.isDebug.value = true
} }
} }
@@ -52,6 +57,7 @@ class Recorder @Inject constructor(
} finally { } finally {
fileLogger = null fileLogger = null
this@Recorder.path = null this@Recorder.path = null
Bugs.isDebug.value = false
} }
} }
} }
@@ -11,7 +11,6 @@ import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
@@ -206,10 +205,6 @@ class RecorderModule @Inject constructor(
} }
} }
.launchIn(appScope) .launchIn(appScope)
internalState.flow
.onEach { Bugs.isDebug.value = it.isRecording }
.launchIn(appScope)
} }
// Header lines written into a freshly started recording. Runs AFTER the recorder is live, so // Header lines written into a freshly started recording. Runs AFTER the recorder is live, so
@@ -5,6 +5,7 @@ import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BleScanner import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScanFilterPolicy
import eu.darken.capod.common.bluetooth.ScannerMode import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.bluetooth.onlyNewAndUnique import eu.darken.capod.common.bluetooth.onlyNewAndUnique
import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.AppScope
@@ -215,6 +216,10 @@ class BlePodMonitor @Inject constructor(
emitAll( emitAll(
bleScanner.scan( bleScanner.scan(
filters = filters, filters = filters,
filterPolicy = when {
options.showUnfiltered -> ScanFilterPolicy.MATCH_ALL
else -> ScanFilterPolicy.PROXIMITY_PAIRING
},
scannerMode = options.scannerMode, scannerMode = options.scannerMode,
disableOffloadFiltering = options.offloadedFilteringDisabled, disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled, disableOffloadBatching = options.offloadedBatchingDisabled,
@@ -0,0 +1,184 @@
package eu.darken.capod.common.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.le.BluetoothLeScanner
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import testhelpers.BaseTest
import testhelpers.TestApplication
import testhelpers.TestTimeSource
import java.util.concurrent.CopyOnWriteArrayList
/**
* A capture that shows no BLE results has to answer whether the scan was filtered at all. The
* configuration is decided once when the scan starts, which is usually before the recording the
* investigator is reading.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = TestApplication::class)
class BleScannerConfigLogTest : BaseTest() {
private val logLines = CopyOnWriteArrayList<String>()
private val logCapture = object : Logging.Logger {
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
logLines.add(message)
}
}
@Before
fun setup() {
Bugs.isDebug.value = false
Logging.install(logCapture)
}
@After
fun teardown() {
Logging.remove(logCapture)
Bugs.isDebug.value = false
}
private fun configLines() = logLines.filter { it.startsWith("Scan config:") }
private fun createScanner(
offloadFilteringSupported: Boolean = true,
offloadBatchingSupported: Boolean = true,
): BleScanner {
val adapter = mockk<BluetoothAdapter>(relaxed = true).apply {
every { isOffloadedFilteringSupported } returns offloadFilteringSupported
every { isOffloadedScanBatchingSupported } returns offloadBatchingSupported
}
val leScanner = mockk<BluetoothLeScanner>(relaxed = true)
val bluetoothManager = mockk<BluetoothManager2>().apply {
every { this@apply.adapter } returns adapter
every { scanner } returns leScanner
}
return BleScanner(
context = ApplicationProvider.getApplicationContext(),
bluetoothManager = bluetoothManager,
scanResultForwarder = BleScanResultForwarder(),
timeSource = TestTimeSource(),
)
}
private fun createConfig(
filterPolicy: ScanFilterPolicy = ScanFilterPolicy.PROXIMITY_PAIRING,
platformFilterCount: Int = 1,
requestedFilterCount: Int = 1,
offloadFilteringSupported: Boolean = true,
offloadFilteringDisabledBySetting: Boolean = false,
offloadBatchingSupported: Boolean = true,
offloadBatchingDisabledBySetting: Boolean = false,
) = ScanConfig(
scannerMode = ScannerMode.BALANCED,
filterPolicy = filterPolicy,
platformFilterCount = platformFilterCount,
requestedFilterCount = requestedFilterCount,
offloadFilteringSupported = offloadFilteringSupported,
offloadFilteringDisabledBySetting = offloadFilteringDisabledBySetting,
offloadBatchingSupported = offloadBatchingSupported,
offloadBatchingDisabledBySetting = offloadBatchingDisabledBySetting,
reportDelayMs = 1000L,
directCallback = true,
)
/**
* Both render `offloadFilteringRequested=false`, and the reader has to be able to tell a device
* that cannot offload from a user who turned offloading off.
*/
@Test
fun `an unsupported adapter reads differently from a disabled setting`() {
val unsupported = createConfig(
offloadFilteringSupported = false,
offloadBatchingSupported = false,
).summary()
unsupported shouldContain "offloadFilteringRequested=false (supported=false, disabledBySetting=false)"
unsupported shouldContain "offloadBatchingRequested=false (supported=false, disabledBySetting=false)"
val disabled = createConfig(
offloadFilteringDisabledBySetting = true,
offloadBatchingDisabledBySetting = true,
).summary()
disabled shouldContain "offloadFilteringRequested=false (supported=true, disabledBySetting=true)"
disabled shouldContain "offloadBatchingRequested=false (supported=true, disabledBySetting=true)"
}
@Test
fun `the filters that reached the platform are reported against the requested ones`() {
createConfig(
platformFilterCount = 0,
requestedFilterCount = 1,
offloadFilteringDisabledBySetting = true,
).summary() shouldContain "platformFilters=0/1"
createConfig(platformFilterCount = 1, requestedFilterCount = 1).summary() shouldContain "platformFilters=1/1"
}
/**
* The unfiltered scan mode is implemented as a single match-all filter, so the filter count
* alone reports it as a filtered scan. Only the policy separates the two.
*/
@Test
fun `a match-all scan is distinguishable from a filtered one at the same filter count`() {
createConfig(filterPolicy = ScanFilterPolicy.MATCH_ALL).summary() shouldContain
"filterPolicy=MATCH_ALL, platformFilters=1/1"
createConfig(filterPolicy = ScanFilterPolicy.PROXIMITY_PAIRING).summary() shouldContain
"filterPolicy=PROXIMITY_PAIRING, platformFilters=1/1"
}
@Test
fun `the scan config is logged when the scan starts`() = runTest {
createScanner()
.scan(filters = emptySet(), filterPolicy = ScanFilterPolicy.PROXIMITY_PAIRING)
.launchIn(backgroundScope)
runCurrent()
configLines() shouldHaveSize 1
configLines().single() shouldContain "filterPolicy=PROXIMITY_PAIRING"
}
@Test
fun `a recording started while the scan runs re-logs the config`() = runTest {
createScanner()
.scan(filters = emptySet(), filterPolicy = ScanFilterPolicy.MATCH_ALL)
.launchIn(backgroundScope)
runCurrent()
Bugs.isDebug.value = true
runCurrent()
configLines() shouldHaveSize 2
configLines().last().endsWith("(recording started)") shouldBe true
}
/**
* The re-emission suppresses the state it was already logged for, otherwise a scan starting
* inside a running recording writes the same line twice.
*/
@Test
fun `a scan started during a recording logs the config once`() = runTest {
Bugs.isDebug.value = true
createScanner()
.scan(filters = emptySet(), filterPolicy = ScanFilterPolicy.PROXIMITY_PAIRING)
.launchIn(backgroundScope)
runCurrent()
configLines() shouldHaveSize 1
}
}
@@ -0,0 +1,185 @@
package eu.darken.capod.common.debug
import android.content.BroadcastReceiver
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.os.Looper
import android.os.PowerManager
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.common.debug.logging.Logging
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import testhelpers.BaseTest
import testhelpers.TestApplication
import java.util.concurrent.CopyOnWriteArrayList
/**
* Screen-off is one of the two explanations for a BLE reception blackout, and a debug capture
* carries no evidence for it unless the state is written into the log while the recording runs.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = TestApplication::class)
class PowerStateLoggerTest : BaseTest() {
private val logLines = CopyOnWriteArrayList<String>()
private val logCapture = object : Logging.Logger {
override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map<String, Any>?) {
logLines.add(message)
}
}
private val context: Context get() = ApplicationProvider.getApplicationContext()
private val powerManager: PowerManager get() = context.getSystemService(PowerManager::class.java)
@Before
fun setup() {
Bugs.isDebug.value = false
Logging.install(logCapture)
}
@After
fun teardown() {
Logging.remove(logCapture)
Bugs.isDebug.value = false
}
private fun powerLines() = logLines.filter { it.startsWith("Power state (") }
private fun broadcast(action: String) {
context.sendBroadcast(Intent(action))
shadowOf(Looper.getMainLooper()).idle()
}
@Test
fun `a starting recording logs the current power state`() = runTest {
shadowOf(powerManager).setIsInteractive(true)
shadowOf(powerManager).setIgnoringBatteryOptimizations(context.packageName, true)
PowerStateLogger(context, backgroundScope).setup()
runCurrent()
Bugs.isDebug.value = true
runCurrent()
powerLines() shouldHaveSize 1
powerLines().single() shouldBe "Power state (recording started): interactive=true, deviceIdle=false, " +
"powerSave=false, ignoringBatteryOptimizations=true"
}
@Test
fun `a screen-off is logged with the state that came with it`() = runTest {
PowerStateLogger(context, backgroundScope).setup()
runCurrent()
Bugs.isDebug.value = true
runCurrent()
shadowOf(powerManager).setIsInteractive(false)
broadcast(Intent.ACTION_SCREEN_OFF)
powerLines() shouldHaveSize 2
powerLines().last() shouldContain "Power state (${Intent.ACTION_SCREEN_OFF})"
powerLines().last() shouldContain "interactive=false"
}
@Test
fun `a stopped recording stops the logging`() = runTest {
PowerStateLogger(context, backgroundScope).setup()
runCurrent()
Bugs.isDebug.value = true
runCurrent()
Bugs.isDebug.value = false
runCurrent()
broadcast(Intent.ACTION_SCREEN_OFF)
powerLines() shouldHaveSize 1
}
/**
* onReceive runs as an Android callback outside any flow, so a PowerManager that throws would
* take the process down instead of costing a log line.
*/
@Test
fun `an unreadable power manager degrades the line instead of the process`() = runTest {
val throwing = mockk<PowerManager>().apply {
every { isInteractive } throws RuntimeException("vendor power manager")
every { isDeviceIdleMode } throws RuntimeException("vendor power manager")
every { isPowerSaveMode } throws RuntimeException("vendor power manager")
every { isIgnoringBatteryOptimizations(any()) } throws RuntimeException("vendor power manager")
}
PowerStateLogger(ServiceOverrideContext(context, throwing), backgroundScope).setup()
runCurrent()
Bugs.isDebug.value = true
runCurrent()
broadcast(Intent.ACTION_SCREEN_OFF)
powerLines() shouldHaveSize 2
powerLines().last() shouldBe "Power state (${Intent.ACTION_SCREEN_OFF}): interactive=unavailable, " +
"deviceIdle=unavailable, powerSave=unavailable, ignoringBatteryOptimizations=unavailable"
}
/**
* A failure inside one recording must not end the collection of the recording flag itself,
* otherwise every later recording in the process silently carries no power state at all.
*/
@Test
fun `a failed episode does not stop later recordings from logging`() = runTest {
val flaky = FailFirstRegistrationContext(context)
PowerStateLogger(flaky, backgroundScope).setup()
runCurrent()
Bugs.isDebug.value = true
runCurrent()
powerLines() shouldHaveSize 0
logLines.any { it.startsWith("Power state logging failed") } shouldBe true
Bugs.isDebug.value = false
runCurrent()
Bugs.isDebug.value = true
runCurrent()
powerLines() shouldHaveSize 1
powerLines().single() shouldContain "Power state (recording started)"
}
private class ServiceOverrideContext(
base: Context,
private val powerManager: PowerManager,
) : ContextWrapper(base) {
override fun getSystemService(name: String): Any? = when (name) {
Context.POWER_SERVICE -> powerManager
else -> super.getSystemService(name)
}
}
private class FailFirstRegistrationContext(base: Context) : ContextWrapper(base) {
private var failNext = true
override fun registerReceiver(receiver: BroadcastReceiver?, filter: IntentFilter?): Intent? {
if (failNext) {
failNext = false
throw SecurityException("receiver registration denied")
}
return super.registerReceiver(receiver, filter)
}
}
}
@@ -0,0 +1,105 @@
package eu.darken.capod.common.debug.recording.core
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.FileLogger
import eu.darken.capod.common.debug.logging.Logging
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.assertions.withClue
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import testhelpers.BaseTest
import testhelpers.TestApplication
import testhelpers.TestTimeSource
import java.io.File
import java.io.IOException
/**
* [Recorder] is the writer of [Bugs.isDebug] that sits next to the file logger it installs, so it
* is the only one that can flip the flag in the same breath as the recording it describes. Asserted
* against the recorder itself and nothing else: a test that goes through [RecorderModule] is
* satisfied by the module's own flag collector, and would keep passing if the recorder stopped
* writing the flag entirely.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = TestApplication::class)
class RecorderDebugFlagTest : BaseTest() {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
private val logDir: File
get() = File(context.cacheDir, "recorder-flag-test")
private lateinit var recorder: Recorder
@Before
fun setup() {
logDir.deleteRecursively()
logDir.mkdirs()
recorder = Recorder(TestTimeSource())
}
@After
fun teardown() {
unmockkObject(Logging)
runBlocking { runCatching { recorder.stop() } }
Logging.loggers.filterIsInstance<FileLogger>().forEach { Logging.remove(it) }
logDir.deleteRecursively()
Bugs.isDebug.value = false
}
@Test
fun `start publishes the debug flag before it returns`() = runBlocking {
Bugs.isDebug.value shouldBe false
recorder.start(File(logDir, "core.log"))
withClue("a recording is live the moment start() returns, so the flag has to read true") {
Bugs.isDebug.value shouldBe true
}
}
@Test
fun `stop clears the debug flag`() = runBlocking {
recorder.start(File(logDir, "core.log"))
withClue("the flag has to be set before this test can say anything about clearing it") {
Bugs.isDebug.value shouldBe true
}
recorder.stop()
withClue("no recording is live any more, so the flag has to read false") {
Bugs.isDebug.value shouldBe false
}
}
@Test
fun `stop clears the debug flag even when the teardown fails`() = runBlocking {
recorder.start(File(logDir, "core.log"))
withClue("the flag has to be set before this test can say anything about clearing it") {
Bugs.isDebug.value shouldBe true
}
// Uninstalling the logger is the first thing stop() does and the only part of it that can
// throw: FileLogger.stop() swallows its own IO failures.
mockkObject(Logging)
every { Logging.remove(any()) } throws IOException("Uninstall failed")
shouldThrow<IOException> { recorder.stop() }
withClue("the recorder is no longer recording, failed teardown or not") {
Bugs.isDebug.value shouldBe false
}
}
}
@@ -0,0 +1,240 @@
package eu.darken.capod.common.debug.recording.core
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import eu.darken.capod.common.InstallId
import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.FileLogger
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.upgrade.UpgradeDiagnostics
import io.kotest.assertions.withClue
import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import testhelpers.BaseTest
import testhelpers.TestApplication
import testhelpers.coroutine.TestDispatcherProvider
import java.io.File
import java.util.ArrayDeque
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.coroutines.CoroutineContext
/**
* The flag collector in [RecorderModule]'s init mirrors `isRecording` from the module state, and a
* committed stop is a value it legitimately publishes. Delivery of that value is not tied to the
* recording it describes: the collector runs on the app scope while the recorder work runs on the
* producer's IO context, so the `false` can land after the next session's [Recorder.start] has
* already written `true`. `distinctUntilChangedBy`/`drop(1)` do not filter it — a genuine stop and
* a genuine start are distinct values, and both pass.
*
* The window that follows is not short: the state that would repair the flag only commits after
* `logRecordingHeader()`, which is bounded at five seconds.
*
* Forced rather than waited for: the module's app scope gets a dispatcher whose queue this test
* steps through by hand, which is what a saturated Dispatchers.Default does to the flag collector's
* continuation while the module's own producer proceeds on IO.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = TestApplication::class)
class RecorderModuleStaleStopFlagTest : BaseTest() {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
private val triggerFile: File
get() = File(context.getExternalFilesDir(null), "capod_force_debug_run")
private val externalLogsDir: File
get() = File(context.getExternalFilesDir(null), "debug/logs")
private val secondSessionDir: File
get() = File(context.cacheDir, "stale-stop-second-session")
@Before
fun cleanRecorderFiles() {
triggerFile.delete()
externalLogsDir.deleteRecursively()
File(context.cacheDir, "debug/logs").deleteRecursively()
secondSessionDir.deleteRecursively()
secondSessionDir.mkdirs()
}
@After
fun resetDebugFlag() {
Logging.loggers.filterIsInstance<FileLogger>().forEach { Logging.remove(it) }
secondSessionDir.deleteRecursively()
Bugs.isDebug.value = false
}
/**
* A single-threaded dispatcher whose queue can be held and then stepped through one task at a
* time. [releaseOne] returns once that task has run to its next suspension point, so a step is
* a decision point and not a sleep.
*/
private class SteppingDispatcher : CoroutineDispatcher() {
private val executor = Executors.newSingleThreadExecutor { r -> Thread(r, "stepping-dispatcher") }
private val held = ArrayDeque<Runnable>()
private var open = true
override fun dispatch(context: CoroutineContext, block: Runnable) {
synchronized(held) {
if (open) executor.execute(block) else held.addLast(block)
}
}
fun hold() = synchronized(held) { open = false }
fun release() = synchronized(held) {
open = true
while (held.isNotEmpty()) executor.execute(held.removeFirst())
}
fun heldCount(): Int = synchronized(held) { held.size }
fun releaseOne(timeoutMs: Long): Boolean {
val block = synchronized(held) { held.pollFirst() } ?: return false
val done = CountDownLatch(1)
executor.execute {
try {
block.run()
} finally {
done.countDown()
}
}
return done.await(timeoutMs, TimeUnit.MILLISECONDS)
}
/** Runs after everything already queued: a barrier for "the collectors have settled". */
fun barrier(timeoutMs: Long): Boolean {
val done = CountDownLatch(1)
executor.execute { done.countDown() }
return done.await(timeoutMs, TimeUnit.MILLISECONDS)
}
fun shutdown() {
executor.shutdownNow()
}
}
@Test
fun `a late committed stop does not clear the flag of the session that replaced it`() {
val moduleDispatcher = SteppingDispatcher()
val moduleScope = CoroutineScope(moduleDispatcher + SupervisorJob())
val callerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val module = RecorderModule(
context = context,
appScope = moduleScope,
dispatcherProvider = TestDispatcherProvider(Dispatchers.IO),
installId = mockk<InstallId>(relaxed = true),
timeSource = SystemTimeSource,
upgradeDiagnostics = mockk<UpgradeDiagnostics>().apply { coEvery { debugInfo() } returns null },
)
// Stands in for the recorder of the session that follows. In production this is the module's
// own next recorder: its start() runs on the producer's IO context, not on the app scope
// whose queue is held below, so it can write the flag while the collector still owes a value.
val nextSession = Recorder(SystemTimeSource)
try {
runBlocking { withTimeout(AWAIT_TIMEOUT_MS) { withContext(Dispatchers.IO) { module.startRecorder() } } }
settleAndHold(moduleDispatcher)
withClue("the first recording is live, so the flag is true before anything is held") {
Bugs.isDebug.value shouldBe true
}
val stop = callerScope.async { module.stopRecorder() }
// The stop request has been published and the state machine collector is queued on it.
runBlocking {
withTimeout(AWAIT_TIMEOUT_MS) {
while (moduleDispatcher.heldCount() < 1) delay(POLL_MS)
}
}
// Step 1: the state machine collector. It hands the stop to the module's own producer,
// which stops the recorder on IO and publishes the committed stop.
withClue("the state machine collector's turn on the stop request must be pending") {
moduleDispatcher.releaseOne(AWAIT_TIMEOUT_MS) shouldBe true
}
runBlocking { withTimeout(AWAIT_TIMEOUT_MS) { stop.await() } }
withClue("the recorder stopped itself, so nothing is recording at this point") {
Bugs.isDebug.value shouldBe false
}
// The committed stop is queued for the flag collector and has not been delivered yet.
withClue("the collector must still owe a delivery, or this test proves nothing") {
moduleDispatcher.heldCount() shouldBeGreaterThanOrEqual 1
}
runBlocking { nextSession.start(File(secondSessionDir, "core.log")) }
withClue("the new session's recorder wrote the flag next to the logger it installed") {
Bugs.isDebug.value shouldBe true
}
// Step 2: everything the collector still owes, the committed stop included.
moduleDispatcher.release()
moduleDispatcher.barrier(AWAIT_TIMEOUT_MS) shouldBe true
withClue("a live recorder is writing to the log file, so isDebug must not read false") {
Bugs.isDebug.value shouldBe true
}
} finally {
moduleDispatcher.release()
runBlocking {
runCatching { withTimeout(AWAIT_TIMEOUT_MS) { nextSession.stop() } }
runCatching {
withTimeout(AWAIT_TIMEOUT_MS) { withContext(Dispatchers.IO) { module.stopRecorder() } }
}
}
callerScope.cancel()
moduleScope.cancel()
moduleDispatcher.shutdown()
}
}
/**
* Holds the dispatcher at a point where nothing is queued on it, so the first task released
* afterwards is the first reaction to whatever the test does next.
*/
private fun settleAndHold(dispatcher: SteppingDispatcher) = runBlocking {
withTimeout(AWAIT_TIMEOUT_MS) {
while (true) {
dispatcher.barrier(AWAIT_TIMEOUT_MS) shouldBe true
dispatcher.hold()
dispatcher.barrier(AWAIT_TIMEOUT_MS) shouldBe true
delay(SETTLE_MS)
if (dispatcher.heldCount() == 0) return@withTimeout
dispatcher.release()
delay(SETTLE_MS)
}
}
}
companion object {
private const val AWAIT_TIMEOUT_MS = 5_000L
private const val POLL_MS = 10L
private const val SETTLE_MS = 100L
}
}
@@ -45,6 +45,7 @@ class BlePodMonitorTest : BaseTest() {
verify(exactly = 1) { verify(exactly = 1) {
fixture.bleScanner.scan( fixture.bleScanner.scan(
filters = any(), filters = any(),
filterPolicy = any(),
scannerMode = any(), scannerMode = any(),
disableOffloadFiltering = any(), disableOffloadFiltering = any(),
disableOffloadBatching = any(), disableOffloadBatching = any(),
@@ -72,6 +73,7 @@ class BlePodMonitorTest : BaseTest() {
verify(exactly = 2) { verify(exactly = 2) {
fixture.bleScanner.scan( fixture.bleScanner.scan(
filters = any(), filters = any(),
filterPolicy = any(),
scannerMode = any(), scannerMode = any(),
disableOffloadFiltering = any(), disableOffloadFiltering = any(),
disableOffloadBatching = any(), disableOffloadBatching = any(),
@@ -90,6 +92,7 @@ class BlePodMonitorTest : BaseTest() {
every { every {
scan( scan(
filters = any(), filters = any(),
filterPolicy = any(),
scannerMode = any(), scannerMode = any(),
disableOffloadFiltering = any(), disableOffloadFiltering = any(),
disableOffloadBatching = any(), disableOffloadBatching = any(),
@@ -1,5 +1,6 @@
package testhelpers package testhelpers
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE 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
@@ -12,6 +13,10 @@ open class BaseTest {
init { init {
Logging.clearAll() Logging.clearAll()
Logging.install(JUnitLogger()) Logging.install(JUnitLogger())
// JVM-global and written by anything that starts a debug recording. Reset per test instance
// and not in a companion teardown: the JUnit 5 @AfterAll below never fires under the JUnit 4
// Robolectric runner that the recorder tests use.
Bugs.isDebug.value = false
testClassName = this.javaClass.simpleName testClassName = this.javaClass.simpleName
} }