mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
7
Commits
92dac661c4
...
3662904d75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3662904d75 | ||
|
|
cec36268e0 | ||
|
|
5895ccbc27 | ||
|
|
798ac32ec9 | ||
|
|
6ed0bf8257 | ||
|
|
39a6c3c89f | ||
|
|
2ba90e1d18 |
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+240
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user