Compare commits

...
12 Commits
Author SHA1 Message Date
d4rken-org-releaser[bot] d541de8c38 Release: 5.1.4-rc0 2026-05-03 17:32:42 +00:00
darken 2821da434d fix(settings): Hide Microphone & Press controls when AAP isn't ready 2026-05-03 19:27:52 +02:00
darken 07c72dc86c fix(aap): Cancel hung BluetoothSocket connect on timeout
BluetoothSocket.connect() is a blocking JNI call that ignores coroutine cancellation, so the previous withTimeout in AapAutoConnect only cancelled the suspending wrapper while the native thread stayed pinned. Hung threads accumulated and could trigger ANRs.

Move the timeout inside AapConnection and run the blocking connect on a daemon thread; on timeout, close the socket from the caller thread to unblock the native call (the documented Android pattern for cancelling in-flight L2CAP connects).

Also cancel appScope before delegating uncaught exceptions so coroutines have a best-effort window to release resources before the system handler terminates the process.
2026-05-03 15:39:03 +02:00
darken 7d93e772b4 fix(monitor): Prevent NPE in cache merge from freezing device flow
Battery slot percent comparisons in mergeBatterySlot/hasStateChanged compiled to Intrinsics.areEqual on boxed Float; R8 optimization on Android 10/11 dropped a null check during inlining and the resulting NPE escaped onEach { persistLiveDevices }, cancelling the upstream combine and freezing every observer of DeviceMonitor.devices.

Comparisons now operate on primitive float (cmpg-float in dex) so no Intrinsics.areEqual call remains in the merge path. The persist loop also catches and reports per-profile, and AAP-only profiles with active DeviceInfo are now persisted even when no BLE pod is in range.
2026-05-03 14:20:49 +02:00
darken 82dc88faac fix: Harden uncaught exception safety net
Wrap logging, reporting, and Looper resume calls so the foreground service timing exception suppression cannot itself trigger another crash. Extract handler into a dedicated class with seams for unit tests.
2026-05-03 12:41:32 +02:00
darken 06b41ec8eb fix(monitor): Recover from revoked Bluetooth scan permission 2026-05-03 11:54:11 +02:00
darken acc6e92b2d feat(tile): Add ANC Quick Settings tile 2026-05-03 10:58:05 +02:00
d4rken-org-releaser[bot] 39922df792 Release: 5.1.3-rc0 2026-05-01 19:52:12 +00:00
darken 5a8c4b94ef fix: Correct wear detection on AirPods Max 2
Bit 5 of pubStatus is always set on A3454 and no longer carries the wear flag (unlike Max gen 1). Read bits 1 and 3 instead — the per-earcup sensors. OR rather than AND so phones that only see one bit reliably still report worn correctly.

Closes #548
2026-05-01 21:50:51 +02:00
darken 0ffa87deea CI: Skip release bump-only workflow runs 2026-05-01 20:08:42 +02:00
darken fc98db09a2 fix: Correct PodModel feature flags for ear detection and call controls
Sync flags with what the BLE classes actually report and what iOS exposes:

- AirPods Gen 1/2/3: enable hasEarDetection (already parsed via DualApplePods) and hasEarDetectionToggle

- AirPods Gen 3, Pro 1: enable hasEndCallMuteMic (force-sensor stems)

- Powerbeats Pro, Beats Fit Pro: enable hasEarDetectionToggle (iOS exposes it)

- Beats Solo Pro, Studio 3: drop hasEarDetection (over-ear, BLE class is bare SingleApplePods)

- FAKE_AIRPODS_GEN1/2/3: enable hasEarDetection to match HasEarDetectionDual

- Generalize microphone mode description from 'AirPod' to 'earbud'

Tests rewritten as exhaustive set assertions plus implication invariants.
2026-05-01 20:08:29 +02:00
darken e1c0a702dd chore(ci): Rename stale step name and use client-id for App auth 2026-05-01 09:15:43 +02:00
51 changed files with 2834 additions and 225 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ bats tools/release/bump.bats
Required org secrets (set on the d4rken-org organization, accessible to `capod`):
- `RELEASE_APP_ID` — numeric ID of the `d4rken-org-releaser` GitHub App
- `RELEASE_APP_CLIENT_ID` — Client ID of the `d4rken-org-releaser` GitHub App (visible on the App's settings page, format `Iv1.<hex>` or similar)
- `RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines)
The App is installed on this repo and added as a bypass actor to:
+4 -1
View File
@@ -3,6 +3,9 @@ name: Code tests & eval
on:
push:
branches: [ main ]
paths-ignore:
- VERSION
- version.properties
pull_request:
branches: [ main ]
@@ -101,4 +104,4 @@ jobs:
run: bats tools/release/bump.bats
- name: Verify version.properties + VERSION are consistent
run: ./tools/release/bump.sh --mode=check
run: ./tools/release/bump.sh --mode=check
@@ -4,6 +4,9 @@ on:
push:
branches:
- main
paths-ignore:
- VERSION
- version.properties
pull_request:
branches:
- main
+8
View File
@@ -3,6 +3,14 @@ name: Deploy GitHub Pages
on:
push:
branches: [ main ]
paths:
- _config.yml
- _layouts/**
- README.md
- CHANGELOG.md
- PRIVACY_POLICY.md
- CNAME
- .github/workflows/pages.yml
workflow_dispatch:
permissions:
+3 -3
View File
@@ -142,7 +142,7 @@ jobs:
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Resolve bot identity
@@ -164,12 +164,12 @@ jobs:
persist-credentials: true
token: ${{ steps.app-token.outputs.token }}
- name: Re-validate after approval wait
- name: Verify state still matches plan from Job 1
run: |
set -euo pipefail
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
- name: Re-check tag collision (state may have moved during approval)
- name: Re-check tag collision (state may have moved between jobs)
run: |
set -euo pipefail
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
+1 -1
View File
@@ -1 +1 @@
5.1.2-rc1 50102010
5.1.4-rc0 50104000
+15 -1
View File
@@ -141,6 +141,20 @@
android:name=".monitor.core.worker.MonitorService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
<service
android:name=".main.ui.tile.AncTileService"
android:exported="true"
android:icon="@drawable/ic_anc_on"
android:label="@string/tile_anc_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="false" />
</service>
</application>
</manifest>
</manifest>
+14 -30
View File
@@ -1,17 +1,13 @@
package eu.darken.capod
import android.app.Application
import android.os.Looper
import dagger.hilt.android.HiltAndroidApp
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.LogCatLogger
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
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.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.throttleLatest
@@ -22,16 +18,16 @@ import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.devicesWithProfiles
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import kotlin.system.exitProcess
@HiltAndroidApp
open class App : Application() {
@@ -46,22 +42,19 @@ open class App : Application() {
super.onCreate()
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
val foregroundExceptionHandled = AtomicBoolean(false)
val oldHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
val isTimingExc = throwable.isForegroundServiceTimingException()
val isMain = thread === Looper.getMainLooper().thread
if (isTimingExc && isMain && foregroundExceptionHandled.compareAndSet(false, true)) {
runCatching {
log(TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
Bugs.report(tag = TAG, "Foreground service timing exception suppressed", exception = throwable)
}
Looper.loop()
return@setDefaultUncaughtExceptionHandler
}
runCatching { log(TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
if (oldHandler != null) oldHandler.uncaughtException(thread, throwable) else exitProcess(1)
}
Thread.setDefaultUncaughtExceptionHandler(
CapodUncaughtExceptionHandler(
previousHandler = oldHandler,
cancelBeforeDelegate = { throwable ->
// Best-effort shutdown: the system handler may terminate the process immediately,
// but cancellation can still close sockets if it gets a scheduling window.
if (::appScope.isInitialized) {
appScope.cancel(CancellationException("Uncaught exception", throwable))
}
},
)
)
autoReporting.setup(this)
@@ -90,14 +83,5 @@ open class App : Application() {
companion object {
internal val TAG = logTag("CAP")
private fun Throwable.isForegroundServiceTimingException(): Boolean {
var current: Throwable? = this
while (current != null) {
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
current = current.cause
}
return false
}
}
}
@@ -0,0 +1,73 @@
package eu.darken.capod
import android.os.Looper
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.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.system.exitProcess
internal class CapodUncaughtExceptionHandler(
private val previousHandler: Thread.UncaughtExceptionHandler?,
private val mainThreadProvider: () -> Thread = { Looper.getMainLooper().thread },
private val loopMainThread: () -> Unit = { Looper.loop() },
private val reportForegroundServiceTimingException: (Throwable) -> Unit = { throwable ->
Bugs.report(
tag = App.TAG,
message = "Foreground service timing exception suppressed",
exception = throwable,
)
},
private val cancelBeforeDelegate: (Throwable) -> Unit = {},
private val exit: (Int) -> Unit = { exitProcess(it) },
) : Thread.UncaughtExceptionHandler {
private val foregroundExceptionHandled = AtomicBoolean(false)
override fun uncaughtException(thread: Thread, throwable: Throwable) {
if (shouldSuppress(thread, throwable)) {
runCatching {
log(App.TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
reportForegroundServiceTimingException(throwable)
}
val loopResult = runCatching { loopMainThread() }
if (loopResult.isSuccess) return
val loopFailure = loopResult.exceptionOrNull()!!
runCatching {
log(App.TAG, ERROR) {
"Main loop failed after foreground service timing exception suppression: ${loopFailure.asLog()}"
}
}
delegate(thread, loopFailure)
return
}
runCatching { log(App.TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
delegate(thread, throwable)
}
private fun shouldSuppress(thread: Thread, throwable: Throwable): Boolean {
val isMainThread = runCatching { thread === mainThreadProvider() }.getOrDefault(false)
return throwable.isForegroundServiceTimingException() &&
isMainThread &&
foregroundExceptionHandled.compareAndSet(false, true)
}
private fun delegate(thread: Thread, throwable: Throwable) {
runCatching { cancelBeforeDelegate(throwable) }
previousHandler?.uncaughtException(thread, throwable) ?: exit(1)
}
}
internal fun Throwable.isForegroundServiceTimingException(): Boolean {
var current: Throwable? = this
while (current != null) {
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
current = current.cause
}
return false
}
@@ -16,6 +16,7 @@ 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.logTag
import eu.darken.capod.common.notifications.PendingIntentCompat
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -113,22 +114,7 @@ class BleScanner @Inject constructor(
null
}
val flushJob = if (!disableDirectScanCallback) {
launch {
log(TAG) { "Flush job launched" }
while (isActive) {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
}
} else {
null
}
var flushJob: Job? = null
val filterList = when {
useOffloadedFiltering -> filters.toList()
@@ -167,26 +153,55 @@ class BleScanner @Inject constructor(
setReportDelay(delay)
}.build()
if (disableDirectScanCallback) {
val callbackIntent = createStartIntent()
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
try {
if (disableDirectScanCallback) {
val callbackIntent = createStartIntent()
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=intent)"
}
scanner.startScan(filterList, scanSettings, callbackIntent)
} else {
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
}
scanner.startScan(filterList, scanSettings, callback)
flushJob = launch {
log(TAG) { "Flush job launched" }
while (isActive) {
try {
// Can undercut the minimum setReportDelay(), e.g. 5000ms on a Pixel5@12
scanner.flushPendingScanResults(callback)
} catch (e: SecurityException) {
log(TAG, WARN) { "flushPendingScanResults() denied: ${e.message}" }
close(e)
break
}
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
}
}
scanner.startScan(filterList, scanSettings, callbackIntent)
} else {
log(TAG) {
"startScan(mode=$scannerMode, filterCount=${filterList.size}, batching=$useOffloadedBatching, filtering=$useOffloadedFiltering, callback=direct)"
}
scanner.startScan(filterList, scanSettings, callback)
} catch (e: SecurityException) {
log(TAG, WARN) { "startScan() denied: ${e.message}" }
forwarderConsumer?.cancel()
close(e)
return@callbackFlow
}
awaitClose {
forwarderConsumer?.cancel()
flushJob?.cancel()
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
try {
if (disableDirectScanCallback) {
scanner.stopScan(createStopIntent())
} else {
scanner.stopScan(callback)
}
} catch (e: SecurityException) {
log(TAG, WARN) { "stopScan() denied: ${e.message}" }
}
log(TAG) { "BleScanner stopped" }
}
@@ -5,6 +5,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
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.asLog
import eu.darken.capod.common.debug.logging.asLogSummary
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
@@ -15,13 +16,20 @@ object Bugs {
message: String,
exception: Throwable
) {
log(TAG, VERBOSE) { "Reporting $exception" }
log(tag, ERROR) { "$message\n${exception.asLog()}" }
runCatching { log(TAG, VERBOSE) { "Reporting ${exception.asLogSummary()}" } }
runCatching { log(tag, ERROR) { "$message\n${exception.asLog()}" } }
reporter?.notify(exception) ?: run {
log(TAG, WARN) { "Bug tracking not initialized yet." }
val bugReporter = reporter
if (bugReporter == null) {
runCatching { log(TAG, WARN) { "Bug tracking not initialized yet." } }
return
}
runCatching { bugReporter.notify(exception) }
.onFailure { failure ->
runCatching { log(TAG, WARN) { "Bug reporter failed: ${failure.asLog()}" } }
}
}
private val TAG = logTag("Bugs")
}
}
@@ -61,16 +61,19 @@ object Logging {
message: String
) {
val snapshot = synchronized(internalLoggers) { internalLoggers.toList() }
snapshot
.filter { it.isLoggable(priority) }
.forEach {
it.log(
priority = priority,
tag = tag,
metaData = metaData,
message = message
)
snapshot.forEach {
val isLoggable = runCatching { it.isLoggable(priority) }.getOrDefault(false)
if (isLoggable) {
runCatching {
it.log(
priority = priority,
tag = tag,
metaData = metaData,
message = message
)
}
}
}
}
fun clearAll() {
@@ -110,14 +113,28 @@ inline fun log(
}
}
fun Throwable.asLog(): String {
fun Throwable.asLog(): String = runCatching {
val stringWriter = StringWriter(256)
val printWriter = PrintWriter(stringWriter, false)
printStackTrace(printWriter)
printWriter.flush()
return stringWriter.toString()
stringWriter.toString()
}.getOrElse { renderFailure ->
"${asLogSummary()}\n<stacktrace unavailable: ${renderFailure.asLogSummary()}>"
}
fun Throwable.asLogSummary(): String {
val throwableClass = javaClass.name
val throwableMessage = safeMessage()
return if (throwableMessage.isNullOrBlank()) {
throwableClass
} else {
"$throwableClass: $throwableMessage"
}
}
private fun Throwable.safeMessage(): String? = runCatching { message }.getOrNull()
@PublishedApi
internal fun Any.logTagViaCallSite(): String {
val javaClass = this::class.java
@@ -1,11 +1,10 @@
package eu.darken.capod.common.permissions
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.PowerManager
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.content.PermissionChecker
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.R
import eu.darken.capod.common.withinApiLevel
@@ -18,7 +17,7 @@ enum class Permission(
val permissionId: String,
val isScanBlocking: Boolean = false,
val isGranted: (Context) -> Boolean = {
ContextCompat.checkSelfPermission(it, permissionId) == PackageManager.PERMISSION_GRANTED
PermissionChecker.checkSelfPermission(it, permissionId) == PermissionChecker.PERMISSION_GRANTED
},
) {
BLUETOOTH(
@@ -87,4 +86,4 @@ enum class Permission(
fun Permission.isRequired(context: Context): Boolean = when {
!withinApiLevel(minApiLevel, maxApiLevel) -> false
else -> !isGranted(context)
}
}
@@ -1,6 +1,7 @@
package eu.darken.capod.main.ui.components
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.AutoAwesome
@@ -28,3 +29,11 @@ fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) {
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
}
@DrawableRes
fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
}
@@ -386,7 +386,7 @@ fun DeviceSettingsScreen(
val showSoundSection =
(features.hasPersonalizedVolume && personalizedVol != null) ||
(features.hasToneVolume && toneVol != null) ||
features.hasMicrophoneMode
(features.hasMicrophoneMode && device.microphoneMode != null)
if (showSoundSection) {
item("sound_section") {
SoundCard(
@@ -404,7 +404,7 @@ fun DeviceSettingsScreen(
}
// ── Controls ─────────────────────────────────
val showControlsSection = features.hasStemConfig ||
val showControlsSection = (features.hasStemConfig && device.stemConfig != null) ||
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
(features.hasPressSpeed && device.pressSpeed != null) ||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
@@ -29,10 +29,10 @@ internal fun ControlsCard(
val volSwipe = device.volumeSwipe
val volSwipeLen = device.volumeSwipeLength
val showPressControlsNav = features.hasStemConfig ||
features.hasPressSpeed ||
features.hasPressHoldDuration ||
features.hasEndCallMuteMic
val showPressControlsNav = (features.hasStemConfig && device.stemConfig != null) ||
(features.hasPressSpeed && device.pressSpeed != null) ||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
(features.hasEndCallMuteMic && device.endCallMuteMic != null)
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
if (showPressControlsNav) {
@@ -81,7 +81,7 @@ internal fun SoundCard(
)
}
}
if (features.hasMicrophoneMode) {
if (features.hasMicrophoneMode && device.microphoneMode != null) {
if (isPro) {
val micMode = device.microphoneMode
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
@@ -23,6 +23,7 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.tierRank
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
@@ -175,7 +176,7 @@ class OverviewViewModel @Inject constructor(
val profiledDevices: List<PodDevice> by lazy {
devices.filter { it.profileId != null }.sortedWith(
compareBy<PodDevice> { deviceTierRank(it) }
compareBy<PodDevice> { it.tierRank() }
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
)
}
@@ -284,12 +285,5 @@ class OverviewViewModel @Inject constructor(
companion object {
private const val FREE_DEVICE_LIMIT = 1
private val TAG = logTag("Overview", "VM")
/** Connection tier rank for sorting: lower = higher priority. */
internal fun deviceTierRank(device: PodDevice): Int = when {
device.isSystemConnected -> 0
device.isLive -> 1
else -> 2
}
}
}
@@ -0,0 +1,142 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.bluetooth.BluetoothAddress
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.VERBOSE
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 eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Process-scoped trailing-edge debouncer for tile-driven `SetAncMode` commands.
*
* The QS panel collapses after each tap, destroying the [AncTileService] instance.
* If the pending-job state lived on the service, taps across consecutive panel
* sessions wouldn't cancel each other — every tap would fire a separate
* `SetAncMode`, overwhelming the AAP verification loop and triggering
* "Rejected after retry" storms that leave the device unresponsive until the
* app restarts. Keeping the job here, on a `@Singleton`, means a tap in panel
* session B can cancel the deferred send queued by panel session A.
*/
@Singleton
class AncTileSendCoordinator @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val aapManager: AapConnectionManager,
) {
private val lock = Any()
private val pendingJobs = mutableMapOf<BluetoothAddress, Job>()
private val timeoutJobs = mutableMapOf<BluetoothAddress, Job>()
private val _pendingModes = MutableStateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>>(emptyMap())
val pendingModes: StateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>> = _pendingModes.asStateFlow()
fun scheduleSetAncMode(
address: BluetoothAddress,
mode: AapSetting.AncMode.Value,
debounce: Duration,
timeout: Duration = 5.seconds,
) {
synchronized(lock) {
val replacing = pendingJobs[address]?.isActive == true
log(TAG, VERBOSE) { "scheduleSetAncMode($mode, addr=$address, debounce=$debounce, replacingPending=$replacing)" }
_pendingModes.value = _pendingModes.value + (address to mode)
pendingJobs.remove(address)?.cancel()
timeoutJobs.remove(address)?.cancel()
pendingJobs[address] = appScope.launch {
delay(debounce)
log(TAG, VERBOSE) { "debounce elapsed, dispatching SetAncMode($mode) to AAP for $address" }
try {
aapManager.sendCommand(address, AapCommand.SetAncMode(mode))
log(TAG, VERBOSE) { "sent SetAncMode($mode) to $address" }
} catch (e: CancellationException) {
log(TAG, VERBOSE) { "send for $mode cancelled (newer tap superseded it)" }
throw e
} catch (e: Exception) {
log(TAG, ERROR) { "sendCommand failed: ${e.asLog()}" }
clearPendingTarget(address, mode)
}
}
timeoutJobs[address] = appScope.launch {
delay(timeout)
if (clearPendingTargetFromTimeout(address, mode)) {
log(TAG, VERBOSE) { "pending tile target $mode timed out before device confirmation" }
}
}
}
}
internal fun applyPendingTarget(state: AncTileState): AncTileState {
val active = state as? AncTileState.Active ?: return state
val address = active.deviceAddress ?: return active
val target = pendingModes.value[address] ?: return active
if (target !in active.visible) return active
if (active.isConfirmed(target)) return active
return active.copy(pending = target)
}
internal fun acknowledgeDeviceState(state: AncTileState) {
val active = state as? AncTileState.Active ?: return
val address = active.deviceAddress ?: return
val target = pendingModes.value[address] ?: return
if (target !in active.visible || active.isConfirmed(target)) {
clearPendingTarget(address, target)
}
}
private fun AncTileState.Active.isConfirmed(target: AapSetting.AncMode.Value): Boolean =
pending == target || (current == target && pending == null)
private fun clearPendingTarget(
address: BluetoothAddress,
expectedMode: AapSetting.AncMode.Value,
): Boolean = synchronized(lock) {
val current = _pendingModes.value[address] ?: return@synchronized false
if (current != expectedMode) return@synchronized false
_pendingModes.value = _pendingModes.value - address
pendingJobs.remove(address)?.cancel()
timeoutJobs.remove(address)?.cancel()
true
}
private fun clearPendingTargetFromTimeout(
address: BluetoothAddress,
expectedMode: AapSetting.AncMode.Value,
): Boolean = synchronized(lock) {
val current = _pendingModes.value[address] ?: return@synchronized false
if (current != expectedMode) return@synchronized false
_pendingModes.value = _pendingModes.value - address
pendingJobs.remove(address)?.cancel()
timeoutJobs.remove(address)
true
}
companion object {
private val TAG = logTag("Tile", "Anc", "Coord")
}
}
@@ -0,0 +1,227 @@
package eu.darken.capod.main.ui.tile
import android.app.PendingIntent
import android.content.Intent
import android.graphics.drawable.Icon
import android.os.SystemClock
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.coroutine.DispatcherProvider
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.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.main.ui.components.iconDrawableRes
import eu.darken.capod.main.ui.components.shortLabel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
/**
* Quick Settings tile that cycles ANC modes for the user-perceived primary AirPods.
* Mirrors [eu.darken.capod.main.ui.widget.AncGlanceWidget] but renders into the system
* QS panel instead of a home-screen widget.
*/
@AndroidEntryPoint
class AncTileService : TileService() {
@Inject lateinit var dispatcherProvider: DispatcherProvider
@Inject lateinit var sendCoordinator: AncTileSendCoordinator
@Inject lateinit var stateStore: AncTileStateStore
private var listenScope: CoroutineScope? = null
private val instanceId = Integer.toHexString(System.identityHashCode(this))
override fun onCreate() {
super.onCreate()
log(TAG, VERBOSE) { "onCreate(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
}
override fun onTileAdded() {
log(TAG, VERBOSE) { "onTileAdded(instance=$instanceId)" }
// Render a static placeholder so we never block SystemUI's bind path on a
// first-emission DataStore/Billing read. onStartListening will fill in real state.
renderTile(AncTileState.Connecting)
}
override fun onStartListening() {
log(TAG, VERBOSE) { "onStartListening(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
listenScope?.cancel()
val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.Default)
listenScope = scope
scope.launch {
stateStore.state.throttleLatest(250).collect { state ->
log(TAG, VERBOSE) { "collector: state=$state" }
withContext(dispatcherProvider.Main) { renderTile(state) }
}
}
}
override fun onStopListening() {
log(TAG, VERBOSE) { "onStopListening(instance=$instanceId)" }
listenScope?.cancel()
listenScope = null
}
override fun onDestroy() {
val now = SystemClock.elapsedRealtime()
log(TAG, VERBOSE) { "onDestroy(instance=$instanceId)" }
lastDestroyAt = now
listenScope?.cancel()
super.onDestroy()
}
override fun onClick() {
log(TAG, VERBOSE) { "onClick(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()}) received tap" }
val state = stateStore.currentState()
log(TAG, VERBOSE) {
"onClick: resolved from state store=$state"
}
dispatchClick(state)
}
private fun dispatchClick(state: AncTileState) {
log(TAG, VERBOSE) { "dispatchClick($state)" }
when (state) {
AncTileState.NotPro,
AncTileState.PermissionRequired -> openMainActivityWithUpgrade()
is AncTileState.Active -> sendNextMode(state)
AncTileState.BluetoothOff,
AncTileState.NoDevice,
AncTileState.NoAncSupport,
AncTileState.NotConnected,
AncTileState.Connecting -> {
// Tile state is STATE_UNAVAILABLE; system shouldn't deliver clicks here,
// but defensively no-op so we don't crash on unexpected delivery.
}
}
}
private fun sendNextMode(state: AncTileState.Active) {
val nextMode = pickNextMode(state.visible, state.current, state.pending)
log(TAG, VERBOSE) {
"sendNextMode: visible=${state.visible} current=${state.current} pending=${state.pending} -> next=$nextMode"
}
if (nextMode == null || nextMode == (state.pending ?: state.current)) {
log(TAG, VERBOSE) { "sendNextMode: no advance possible (visible=${state.visible})" }
return
}
val address: BluetoothAddress = state.deviceAddress ?: run {
log(TAG, WARN) { "sendNextMode: state has no device address" }
return
}
sendCoordinator.scheduleSetAncMode(address, nextMode, 1.seconds)
val optimisticState = state.copy(pending = nextMode)
renderTile(optimisticState)
}
private fun openMainActivityWithUpgrade() {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(MainActivity.EXTRA_NAVIGATE_TO_UPGRADE, true)
}
if (hasApiLevel(34)) {
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
startActivityAndCollapse(intent)
}
}
private fun renderTile(state: AncTileState) {
val tile = qsTile ?: run {
log(TAG, VERBOSE) { "renderTile: skipped, qsTile is null (state=$state)" }
return
}
log(TAG, VERBOSE) { "renderTile: $state" }
val baseLabel = getString(R.string.tile_anc_label)
val (subtitle, iconRes, tileState) = when (state) {
AncTileState.NotPro -> Triple(
getString(R.string.common_upgrade_required_label),
R.drawable.ic_anc_off,
Tile.STATE_INACTIVE,
)
AncTileState.PermissionRequired -> Triple(
getString(R.string.tile_anc_subtitle_permission_required),
R.drawable.ic_anc_off,
Tile.STATE_INACTIVE,
)
AncTileState.BluetoothOff -> Triple(
getString(R.string.tile_anc_subtitle_bluetooth_off),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
AncTileState.NoDevice -> Triple(
getString(R.string.tile_anc_subtitle_no_device),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
AncTileState.NoAncSupport -> Triple(
getString(R.string.tile_anc_subtitle_no_anc_support),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
AncTileState.NotConnected -> Triple(
getString(R.string.anc_widget_aap_not_connected_label),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
AncTileState.Connecting -> Triple(
getString(R.string.anc_widget_aap_connecting_label),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
is AncTileState.Active -> {
val displayMode = state.pending?.takeIf { it in state.visible } ?: state.current
Triple(
displayMode.shortLabel(this),
displayMode.iconDrawableRes(),
Tile.STATE_ACTIVE,
)
}
}
tile.icon = Icon.createWithResource(this, iconRes)
tile.state = tileState
if (hasApiLevel(29)) {
tile.label = baseLabel
tile.subtitle = subtitle
} else {
// Pre-API 29 tiles can't show a subtitle; fold it into the label.
tile.label = "$baseLabel · $subtitle"
}
if (hasApiLevel(30)) {
tile.stateDescription = subtitle
}
tile.updateTile()
}
companion object {
private val TAG = logTag("Tile", "Anc")
@Volatile private var lastDestroyAt: Long? = null
private fun elapsedSinceLastDestroy(): String {
val destroyedAt = lastDestroyAt ?: return "n/a"
return "${SystemClock.elapsedRealtime() - destroyedAt}ms"
}
}
}
@@ -0,0 +1,88 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.visibleAncModes
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
/**
* Pure data → data mapper. Decides which [AncTileState] to render given a snapshot
* of inputs. Mirrors [AncWidgetRenderStateMapper]'s precedence so the two surfaces
* stay in sync, while remaining unit-testable without Android resources.
*
* Note: cached/offline-tier devices land in [AncTileState.NotConnected] (not
* [AncTileState.Connecting]) — without an active AAP session, "Connecting…" would
* be misleading because nothing is in progress.
*/
object AncTileStateMapper {
fun map(
device: PodDevice?,
isPro: Boolean,
isBluetoothEnabled: Boolean,
missingPermissions: Set<Permission>,
): AncTileState {
if (!isPro) return AncTileState.NotPro
if (missingPermissions.any { it.isTileBlocking }) return AncTileState.PermissionRequired
if (!isBluetoothEnabled) return AncTileState.BluetoothOff
if (device == null) return AncTileState.NoDevice
if (!device.hasAncControl) return AncTileState.NoAncSupport
if (!device.isAapConnected) return AncTileState.NotConnected
if (!device.isAapReady) return AncTileState.Connecting
val ancMode = device.ancMode ?: return AncTileState.Connecting
val visible = device.visibleAncModes
if (visible.isEmpty()) return AncTileState.Connecting
return AncTileState.Active(
current = ancMode.current,
pending = device.pendingAncMode,
visible = visible,
deviceLabel = device.label,
deviceAddress = device.address,
)
}
}
/**
* A permission whose absence prevents the tile from working. Scan-blocking permissions
* gate BLE; [Permission.BLUETOOTH_CONNECT] gates the AAP L2CAP socket — without either,
* the tile cannot do useful work.
*/
private val Permission.isTileBlocking: Boolean
get() = isScanBlocking || this == Permission.BLUETOOTH_CONNECT
sealed interface AncTileState {
data object NotPro : AncTileState
data object PermissionRequired : AncTileState
data object BluetoothOff : AncTileState
data object NoDevice : AncTileState
data object NoAncSupport : AncTileState
data object NotConnected : AncTileState
data object Connecting : AncTileState
data class Active(
val current: AapSetting.AncMode.Value,
val pending: AapSetting.AncMode.Value?,
val visible: List<AapSetting.AncMode.Value>,
val deviceLabel: String?,
val deviceAddress: BluetoothAddress?,
) : AncTileState
}
/**
* Cycles to the next mode in [visible]. Anchors on [pending] when it's still in
* [visible] so rapid taps walk forward through the list rather than oscillate against
* the device-echoed [current]. A [pending] that has been filtered out (e.g. user toggled
* Allow Off mid-cycle) falls through to [current].
*/
internal fun pickNextMode(
visible: List<AapSetting.AncMode.Value>,
current: AapSetting.AncMode.Value?,
pending: AapSetting.AncMode.Value?,
): AapSetting.AncMode.Value? {
if (visible.isEmpty()) return null
val anchor = pending?.takeIf { it in visible } ?: current ?: return visible.first()
val idx = visible.indexOf(anchor)
return if (idx < 0) visible.first() else visible[(idx + 1) % visible.size]
}
@@ -0,0 +1,85 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.primaryByTier
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.combine as combineFlows
/**
* Process-scoped state holder for the ANC QS tile.
*
* TileService instances are short-lived and can disappear between taps. Keeping
* the latest state here lets a recreated service resolve clicks synchronously
* while the app process is alive. A cold or hydrating process intentionally
* reports [AncTileState.Connecting] so the tile UI is honest about not being
* ready for input yet.
*/
@Singleton
class AncTileStateStore @Inject constructor(
@AppScope private val appScope: CoroutineScope,
deviceMonitor: DeviceMonitor,
profilesRepo: DeviceProfilesRepo,
upgradeRepo: UpgradeRepo,
bluetoothManager: BluetoothManager2,
permissionTool: PermissionTool,
private val sendCoordinator: AncTileSendCoordinator,
) {
private val rawState: StateFlow<AncTileState> = combine(
deviceMonitor.devices,
profilesRepo.profiles,
upgradeRepo.upgradeInfo.map { it.isPro },
bluetoothManager.isBluetoothEnabled,
permissionTool.missingPermissions,
) { devices, profiles, isPro, isBluetoothEnabled, missingPermissions ->
val profileOrder = profiles.mapIndexed { idx, p -> p.id to idx }.toMap()
val device = devices.primaryByTier(profileOrder)
AncTileStateMapper.map(
device = device,
isPro = isPro,
isBluetoothEnabled = isBluetoothEnabled,
missingPermissions = missingPermissions,
)
}
.distinctUntilChanged()
.stateIn(
scope = appScope,
started = SharingStarted.Eagerly,
initialValue = AncTileState.Connecting,
)
init {
appScope.launch {
rawState.collect { state -> sendCoordinator.acknowledgeDeviceState(state) }
}
}
val state: StateFlow<AncTileState> = combineFlows(
rawState,
sendCoordinator.pendingModes,
) { rawState, _ ->
sendCoordinator.applyPendingTarget(rawState)
}
.distinctUntilChanged()
.stateIn(
scope = appScope,
started = SharingStarted.Eagerly,
initialValue = AncTileState.Connecting,
)
fun currentState(): AncTileState = sendCoordinator.applyPendingTarget(rawState.value)
}
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.main.ui.components.iconDrawableRes
import eu.darken.capod.main.ui.components.shortLabel
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.visibleAncModes
@@ -121,10 +122,3 @@ object AncWidgetRenderStateMapper {
}
}
}
private fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
}
@@ -4,7 +4,10 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.coroutine.AppScope
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.VERBOSE
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 eu.darken.capod.common.flow.replayingShare
@@ -20,6 +23,7 @@ import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.toReactionConfig
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@@ -86,7 +90,34 @@ class DeviceMonitor @Inject constructor(
connectedAddresses = connectedAddresses,
)
}.onEach { liveState ->
persistLiveDevices(liveState.liveDevices)
persistLiveDevices(liveState.liveDevices + aapOnlyForPersistence(liveState))
}
/**
* AAP-only profiles whose state didn't make it into [LiveMergeState.liveDevices] (no BLE pod
* in the current scan). Without this, [persistLiveDevices] would only ever see BLE-backed
* devices, and AAP-delivered DeviceInfo (earbud serials, marketing version) for an out-of-BLE
* pod would never reach the cache even though the AAP socket is alive.
*/
private fun aapOnlyForPersistence(state: LiveMergeState): List<PodDevice> {
val coveredProfileIds = state.liveDevices.mapNotNull { it.profileId }.toSet()
return state.profiles.mapNotNull { profile ->
if (profile.id in coveredProfileIds) return@mapNotNull null
val aap = state.aapStates.forProfile(profile) ?: return@mapNotNull null
PodDevice(
profileId = profile.id,
label = profile.label,
ble = null,
aap = aap,
profileAddress = profile.address,
profileModel = profile.model,
profileKeyState = profile.toBleKeyState(),
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
reactions = profile.toReactionConfig(),
isSystemConnected = profile.address in state.connectedAddresses,
)
}
}
val devices: Flow<List<PodDevice>> = combine(
@@ -181,14 +212,25 @@ class DeviceMonitor @Inject constructor(
dedupedLiveDevices + nonLiveDevices
}.replayingShare(appScope)
private val reportedPersistFailures = mutableSetOf<String>()
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
for (device in devices) {
val profileId = device.profileId ?: continue
val existing = deviceStateCache.cachedStates.value[profileId]
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
try {
val existing = deviceStateCache.cachedStates.value[profileId]
val newState = device.copy(cached = existing).toCachedState(existing, timeSource.now()) ?: continue
log(TAG, VERBOSE) { "Persisting state for $profileId" }
deviceStateCache.save(profileId, newState)
log(TAG, VERBOSE) { "Persisting state for $profileId" }
deviceStateCache.save(profileId, newState)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to persist state for $profileId: ${e.asLog()}" }
if (reportedPersistFailures.add(profileId)) {
runCatching { Bugs.report(tag = TAG, message = "persistLiveDevices failed for $profileId", exception = e) }
}
}
}
}
@@ -0,0 +1,26 @@
package eu.darken.capod.monitor.core
/**
* Connection tier rank used to sort devices by user-perceived "primary":
* lower rank = higher priority. System-connected devices come first, then
* any live device (BLE or AAP), then profiled-but-offline.
*
* Distinct from [DeviceMonitor.primaryDevice] which is intentionally
* non-tier-ranked for reaction flows that want "any profiled device".
*/
fun PodDevice.tierRank(): Int = when {
isSystemConnected -> 0
isLive -> 1
else -> 2
}
/**
* Picks the user-perceived primary profiled device: lowest [tierRank], with
* the user's profile-list order as the tiebreaker.
*/
fun List<PodDevice>.primaryByTier(profileOrder: Map<String, Int>): PodDevice? =
filter { it.profileId != null }
.minWithOrNull(
compareBy<PodDevice> { it.tierRank() }
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
)
@@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
@@ -77,9 +76,7 @@ class AapAutoConnect @Inject constructor(
log(TAG) { "AAP connecting to $address (${profile.label})" }
try {
withTimeout(CONNECT_TIMEOUT) {
aapManager.connect(address, bonded.internal, profile.model)
}
aapManager.connect(address, bonded.internal, profile.model)
log(TAG) { "AAP connected to $address" }
} catch (e: Exception) {
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
@@ -102,9 +99,7 @@ class AapAutoConnect @Inject constructor(
try {
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
withTimeout(CONNECT_TIMEOUT) {
aapManager.connect(address, bonded.internal, profile.model)
}
aapManager.connect(address, bonded.internal, profile.model)
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
break
} catch (retryException: Exception) {
@@ -164,9 +159,7 @@ class AapAutoConnect @Inject constructor(
try {
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
withTimeout(CONNECT_TIMEOUT) {
aapManager.connect(address, bonded.internal, profile.model)
}
aapManager.connect(address, bonded.internal, profile.model)
log(TAG) { "AAP reconnected to $address" }
break
} catch (e: Exception) {
@@ -231,6 +224,5 @@ class AapAutoConnect @Inject constructor(
companion object {
private val TAG = logTag("Monitor", "AapAutoConnect")
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
private val CONNECT_TIMEOUT = 5.seconds
}
}
@@ -27,6 +27,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
@@ -51,7 +53,7 @@ class BlePodMonitor @Inject constructor(
private val generalSettings: GeneralSettings,
bluetoothManager: BluetoothManager2,
private val debugSettings: DebugSettings,
permissionTool: PermissionTool,
private val permissionTool: PermissionTool,
private val profilesRepo: DeviceProfilesRepo,
) {
@@ -89,8 +91,10 @@ class BlePodMonitor @Inject constructor(
log(
TAG,
Logging.Priority.WARN
) { "PodMonitor failed due to missing permission, not retrying: ${cause.asLog()}" }
false
) { "PodMonitor failed due to missing permission, rechecking and retrying: ${cause.asLog()}" }
permissionTool.recheck()
delay(3000)
true
} else {
log(TAG, Logging.Priority.WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000)
@@ -160,13 +164,27 @@ class BlePodMonitor @Inject constructor(
else -> ProximityPairing.getBleScanFilter()
}
bleScanner.scan(
filters = filters,
scannerMode = options.scannerMode,
disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
)
flow {
emitAll(
bleScanner.scan(
filters = filters,
scannerMode = options.scannerMode,
disableOffloadFiltering = options.offloadedFilteringDisabled,
disableOffloadBatching = options.offloadedBatchingDisabled,
disableDirectScanCallback = options.disableDirectCallback,
)
)
}.catch { cause ->
if (cause is SecurityException) {
log(TAG, Logging.Priority.WARN) {
"BLE scanner failed due to missing permission, rechecking permissions: ${cause.asLog()}"
}
permissionTool.recheck()
emit(emptyList())
} else {
throw cause
}
}
}
.map { it.onlyNewAndUnique() }
@@ -36,7 +36,9 @@ data class CachedDeviceState(
) {
val deviceInfo: AapDeviceInfo?
get() {
if (deviceName == null && serialNumber == null && firmwareVersion == null) return null
if (deviceName == null && serialNumber == null && firmwareVersion == null
&& leftEarbudSerial == null && rightEarbudSerial == null && marketingVersion == null
) return null
return AapDeviceInfo(
name = deviceName ?: "",
modelNumber = "",
@@ -14,7 +14,7 @@ import java.time.Instant
* Returns null if:
* - The device is not live (cached-only)
* - The device has no profile
* - All live battery values are null
* - All live battery values AND live DeviceInfo are null (nothing fresh to persist)
* - The state hasn't changed from [existing] (dedup)
*/
fun PodDevice.toCachedState(
@@ -28,11 +28,12 @@ fun PodDevice.toCachedState(
val liveRight = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
val liveCase = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
val liveHeadset = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null
val liveDeviceInfo = aap?.deviceInfo
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null && liveDeviceInfo == null) {
return null
}
val newState = CachedDeviceState(
profileId = pid,
model = model,
@@ -64,36 +65,34 @@ private fun mergeBatterySlot(
existing: CachedBatterySlot?,
now: Instant,
): CachedBatterySlot? {
if (livePercent == null) return existing
if (existing == null) return CachedBatterySlot(livePercent, now)
val live: Float = livePercent ?: return existing
val current: CachedBatterySlot = existing ?: return CachedBatterySlot(live, now)
val isStale = Duration.between(existing.updatedAt, now).abs() > Duration.ofMinutes(1)
return if (existing.percent == livePercent && !isStale) existing else CachedBatterySlot(livePercent, now)
val isStale = Duration.between(current.updatedAt, now).abs() > Duration.ofMinutes(1)
return if (current.percent == live && !isStale) current else CachedBatterySlot(live, now)
}
private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boolean {
if (Duration.between(old.lastSeenAt, new.lastSeenAt).abs() > Duration.ofMinutes(1)) return true
if (hasSlotTimestampChanged(old.left, new.left)) return true
if (hasSlotTimestampChanged(old.right, new.right)) return true
if (hasSlotTimestampChanged(old.case, new.case)) return true
if (hasSlotTimestampChanged(old.headset, new.headset)) return true
return old.left?.percent != new.left?.percent
|| old.right?.percent != new.right?.percent
|| old.case?.percent != new.case?.percent
|| old.headset?.percent != new.headset?.percent
|| old.isLeftCharging != new.isLeftCharging
if (hasSlotChanged(old.left, new.left)) return true
if (hasSlotChanged(old.right, new.right)) return true
if (hasSlotChanged(old.case, new.case)) return true
if (hasSlotChanged(old.headset, new.headset)) return true
return old.isLeftCharging != new.isLeftCharging
|| old.isRightCharging != new.isRightCharging
|| old.isCaseCharging != new.isCaseCharging
|| old.isHeadsetCharging != new.isHeadsetCharging
|| old.deviceName != new.deviceName
|| old.serialNumber != new.serialNumber
|| old.firmwareVersion != new.firmwareVersion
|| old.leftEarbudSerial != new.leftEarbudSerial
|| old.rightEarbudSerial != new.rightEarbudSerial
|| old.marketingVersion != new.marketingVersion
}
private fun hasSlotTimestampChanged(
old: CachedBatterySlot?,
new: CachedBatterySlot?,
): Boolean {
if (old == null || new == null) return false
private fun hasSlotChanged(old: CachedBatterySlot?, new: CachedBatterySlot?): Boolean {
if (old == null && new == null) return false
if (old == null || new == null) return true
if (old.percent != new.percent) return true
return Duration.between(old.updatedAt, new.updatedAt).abs() > Duration.ofMinutes(1)
}
@@ -22,7 +22,9 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
@@ -37,7 +39,9 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
@@ -52,10 +56,13 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasPressSpeed = true,
hasPressHoldDuration = true,
hasToneVolume = true,
hasEndCallMuteMic = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
@@ -74,6 +81,7 @@ enum class PodModel(
hasPressSpeed = true,
hasPressHoldDuration = true,
hasToneVolume = true,
hasEndCallMuteMic = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
hasSleepDetection = true,
@@ -314,7 +322,6 @@ enum class PodModel(
"Beats Solo Pro",
R.drawable.device_beats_headphones,
Features(
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A1881"), // headphones
@@ -343,7 +350,6 @@ enum class PodModel(
"Beats Studio 3",
R.drawable.device_beats_studio3,
Features(
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A1914"), // headphones
@@ -410,6 +416,7 @@ enum class PodModel(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
leftPodIconRes = R.drawable.device_powerbeats_pro_left,
@@ -426,6 +433,7 @@ enum class PodModel(
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
hasSleepDetection = true,
),
@@ -444,6 +452,8 @@ enum class PodModel(
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
leftPodIconRes = R.drawable.device_beats_fitpro_left,
@@ -458,6 +468,7 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
),
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -471,6 +482,7 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
),
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -484,6 +496,7 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
),
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
@@ -16,6 +16,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -26,7 +27,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Manages a single AAP L2CAP connection to a device.
@@ -39,6 +44,7 @@ internal class AapConnection(
private val profile: AapDeviceProfile,
private val socketFactory: L2capSocketFactory,
timeSource: TimeSource,
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
) {
private val engine = AapSessionEngine(profile, timeSource)
@@ -68,7 +74,7 @@ internal class AapConnection(
try {
val sock = socketFactory.createSocket(device, PSM)
sock.connect()
sock.connectCancellable()
socket = sock
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
@@ -212,15 +218,47 @@ internal class AapConnection(
}
private fun cleanupSocket() {
socket?.closeQuietly()
socket = null
}
private suspend fun BluetoothSocket.connectCancellable() {
val result = CompletableDeferred<Result<Unit>>()
val cancelled = AtomicBoolean(false)
val connectThread = Thread(
{
val outcome = runCatching { connect() }
result.complete(outcome)
if (cancelled.get() && outcome.isSuccess) closeQuietly()
},
"AAP-L2CAP-connect-${device.address}",
).apply {
isDaemon = true
start()
}
try {
socket?.close()
withTimeout(connectTimeout) {
result.await().getOrThrow()
}
} catch (e: Exception) {
cancelled.set(true)
closeQuietly()
connectThread.interrupt()
throw e
}
}
private fun BluetoothSocket.closeQuietly() {
try {
close()
} catch (_: Exception) {
}
socket = null
}
companion object {
private const val PSM = 0x1001
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
private val TAG = logTag("AAP", "Connection")
}
}
}
@@ -44,8 +44,18 @@ data class AirPodsMax2(
return pubFlags.isBitSet(0)
}
// Aggregate "any wear sensor active" — NOT "both earcups worn".
// Observed on A3454: bit 5 of pubStatus is always set while advertising and
// no longer carries the wear flag (unlike Max gen 1). Bits 1 and 3 of
// pubStatus reflect the two earcup sensors (same byte positions used by
// DualApplePods, but without the primary/flip semantics).
//
// We OR them because some Android pairings only see one of the two bits
// reliably (issue #548: "both worn" advertises as 0x23 — bit 1 only —
// while macOS sees 0x2B with both bits set). AND-ing would falsely report
// "not worn" during normal use on those phones.
override val isBeingWorn: Boolean
get() = pubStatus.isBitSet(5)
get() = pubStatus.isBitSet(1) || pubStatus.isBitSet(3)
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
+6 -1
View File
@@ -158,6 +158,11 @@
<string name="anc_widget_no_anc_support_label">This device does not support noise control</string>
<string name="anc_widget_aap_not_connected_label">Not connected</string>
<string name="anc_widget_aap_connecting_label">Connecting…</string>
<string name="tile_anc_label">Noise Control</string>
<string name="tile_anc_subtitle_permission_required">Permission required</string>
<string name="tile_anc_subtitle_no_device">No device</string>
<string name="tile_anc_subtitle_no_anc_support">No noise control</string>
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth off</string>
<string name="widget_config_screen_title">Widget Configuration</string>
<string name="widget_configuration_title">Select Device</string>
<string name="widget_configuration_description">Choose which device profile this widget should display.</string>
@@ -515,7 +520,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">General</string>
<string name="device_settings_microphone_mode_label">Microphone</string>
<string name="device_settings_microphone_mode_description">Which AirPod is used as the microphone</string>
<string name="device_settings_microphone_mode_description">Which earbud is used as the microphone</string>
<string name="device_settings_microphone_mode_auto">Auto</string>
<string name="device_settings_microphone_mode_right">Right</string>
<string name="device_settings_microphone_mode_left">Left</string>
@@ -0,0 +1,143 @@
package eu.darken.capod
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class CapodUncaughtExceptionHandlerTest : BaseTest() {
@Test
fun `suppresses first main thread foreground service timing exception`() {
val mainThread = Thread.currentThread()
val previousHandler = RecordingHandler()
val reports = mutableListOf<Throwable>()
var loopCalls = 0
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { loopCalls++ },
reportForegroundServiceTimingException = { reports += it },
exit = { throw AssertionError("exitProcess($it)") },
)
val throwable = ForegroundServiceDidNotStartInTimeException()
handler.uncaughtException(mainThread, throwable)
loopCalls shouldBe 1
reports shouldBe listOf(throwable)
previousHandler.throwables shouldBe emptyList()
}
@Test
fun `delegates repeated main thread foreground service timing exception`() {
val mainThread = Thread.currentThread()
val previousHandler = RecordingHandler()
val reports = mutableListOf<Throwable>()
var loopCalls = 0
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { loopCalls++ },
reportForegroundServiceTimingException = { reports += it },
exit = { throw AssertionError("exitProcess($it)") },
)
val first = ForegroundServiceDidNotStartInTimeException()
val second = ForegroundServiceDidNotStartInTimeException()
handler.uncaughtException(mainThread, first)
handler.uncaughtException(mainThread, second)
loopCalls shouldBe 1
reports shouldBe listOf(first)
previousHandler.throwables shouldBe listOf(second)
}
@Test
fun `delegates foreground service timing exception from non-main thread`() {
val mainThread = Thread.currentThread()
val workerThread = Thread()
val previousHandler = RecordingHandler()
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { throw AssertionError("loopMainThread should not run") },
reportForegroundServiceTimingException = { throw AssertionError("report should not run") },
exit = { throw AssertionError("exitProcess($it)") },
)
val throwable = ForegroundServiceDidNotStartInTimeException()
handler.uncaughtException(workerThread, throwable)
previousHandler.throwables shouldBe listOf(throwable)
}
@Test
fun `delegates unrelated main thread exception`() {
val mainThread = Thread.currentThread()
val previousHandler = RecordingHandler()
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { throw AssertionError("loopMainThread should not run") },
reportForegroundServiceTimingException = { throw AssertionError("report should not run") },
exit = { throw AssertionError("exitProcess($it)") },
)
val throwable = IllegalStateException("boom")
handler.uncaughtException(mainThread, throwable)
previousHandler.throwables shouldBe listOf(throwable)
}
@Test
fun `cancels before delegating fatal exception`() {
val mainThread = Thread.currentThread()
val events = mutableListOf<String>()
val previousHandler = object : Thread.UncaughtExceptionHandler {
override fun uncaughtException(thread: Thread, throwable: Throwable) {
events += "delegate"
}
}
val throwable = IllegalStateException("boom")
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { throw AssertionError("loopMainThread should not run") },
reportForegroundServiceTimingException = { throw AssertionError("report should not run") },
cancelBeforeDelegate = { events += "cancel" },
exit = { throw AssertionError("exitProcess($it)") },
)
handler.uncaughtException(mainThread, throwable)
events shouldBe listOf("cancel", "delegate")
}
@Test
fun `delegates loop failure after suppression`() {
val mainThread = Thread.currentThread()
val previousHandler = RecordingHandler()
val loopFailure = IllegalStateException("loop failed")
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { throw loopFailure },
reportForegroundServiceTimingException = {},
exit = { throw AssertionError("exitProcess($it)") },
)
handler.uncaughtException(mainThread, ForegroundServiceDidNotStartInTimeException())
previousHandler.throwables shouldBe listOf(loopFailure)
}
private class RecordingHandler : Thread.UncaughtExceptionHandler {
val throwables = mutableListOf<Throwable>()
override fun uncaughtException(thread: Thread, throwable: Throwable) {
throwables += throwable
}
}
private class ForegroundServiceDidNotStartInTimeException : RuntimeException("timed out")
}
@@ -0,0 +1,71 @@
package eu.darken.capod.common.debug
import android.app.Application
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.Logging
import io.kotest.assertions.throwables.shouldNotThrowAny
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class BugsTest : BaseTest() {
@AfterEach
fun cleanup() {
Bugs.reporter = null
Logging.clearAll()
}
@Test
fun `report does not throw if logging fails`() {
Logging.clearAll()
Logging.install(ThrowingLogger())
shouldNotThrowAny {
Bugs.report(TAG, "Something failed", HostileThrowable())
}
}
@Test
fun `report does not throw if reporter fails`() {
var notified = false
Bugs.reporter = object : AutomaticBugReporter {
override fun setup(application: Application) = Unit
override fun notify(throwable: Throwable) {
notified = true
throw IllegalStateException("reporter failed")
}
}
shouldNotThrowAny {
Bugs.report(TAG, "Something failed", IllegalStateException("boom"))
}
notified shouldBe true
}
private class HostileThrowable : Throwable() {
override val message: String?
get() = throw IllegalStateException("message failed")
override fun toString(): String = throw IllegalStateException("toString failed")
}
private class ThrowingLogger : Logging.Logger {
override fun isLoggable(priority: Logging.Priority): Boolean = true
override fun log(
priority: Logging.Priority,
tag: String,
message: String,
metaData: Map<String, Any>?
) {
throw IllegalStateException("log failed")
}
}
companion object {
private const val TAG = "TEST"
}
}
@@ -0,0 +1,53 @@
package eu.darken.capod.common.debug.logging
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class LoggingTest : BaseTest() {
@Test
fun `asLog renders normal throwable`() {
val log = IllegalStateException("boom").asLog()
log.contains("java.lang.IllegalStateException: boom") shouldBe true
log.contains("LoggingTest") shouldBe true
}
@Test
fun `asLog falls back when throwable rendering fails`() {
val log = HostileThrowable().asLog()
log.contains("HostileThrowable") shouldBe true
log.contains("stacktrace unavailable") shouldBe true
}
@Test
fun `logInternal ignores logger failures`() {
Logging.install(ThrowingLogger())
log("TEST") { "message" }
}
private class HostileThrowable : Throwable() {
override val message: String?
get() = throw IllegalStateException("message failed")
override fun toString(): String = throw IllegalStateException("toString failed")
}
private class ThrowingLogger : Logging.Logger {
override fun isLoggable(priority: Logging.Priority): Boolean {
throw IllegalStateException("isLoggable failed")
}
override fun log(
priority: Logging.Priority,
tag: String,
message: String,
metaData: Map<String, Any>?
) {
throw IllegalStateException("log failed")
}
}
}
@@ -0,0 +1,62 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class AncTileCycleTest : BaseTest() {
private val off = AapSetting.AncMode.Value.OFF
private val on = AapSetting.AncMode.Value.ON
private val tx = AapSetting.AncMode.Value.TRANSPARENCY
private val ad = AapSetting.AncMode.Value.ADAPTIVE
@Test
fun `empty visible list returns null`() {
pickNextMode(visible = emptyList(), current = on, pending = null) shouldBe null
}
@Test
fun `single visible mode returns same mode`() {
pickNextMode(visible = listOf(on), current = on, pending = null) shouldBe on
}
@Test
fun `current null and non-empty visible returns first`() {
pickNextMode(visible = listOf(off, tx, ad), current = null, pending = null) shouldBe off
}
@Test
fun `wraps around at end of list`() {
pickNextMode(visible = listOf(off, tx, ad), current = ad, pending = null) shouldBe off
}
@Test
fun `advances through middle of list`() {
pickNextMode(visible = listOf(off, tx, ad), current = tx, pending = null) shouldBe ad
}
@Test
fun `pending wins over current when pending is in visible`() {
// current=off, pending=tx → next is ad (anchor on pending so rapid taps walk forward)
pickNextMode(visible = listOf(off, tx, ad), current = off, pending = tx) shouldBe ad
}
@Test
fun `pending falls through to current when pending was filtered out`() {
// pending=off but visible no longer contains off → fall through to current=tx → next is ad
pickNextMode(visible = listOf(tx, ad), current = tx, pending = off) shouldBe ad
}
@Test
fun `current not in visible falls through to first`() {
// current=on but visible doesn't contain it → start at first (off)
pickNextMode(visible = listOf(off, tx, ad), current = on, pending = null) shouldBe off
}
@Test
fun `pending not visible and current null falls back to first`() {
pickNextMode(visible = listOf(off, tx, ad), current = null, pending = on) shouldBe off
}
}
@@ -0,0 +1,194 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import kotlin.time.Duration.Companion.seconds
class AncTileSendCoordinatorTest : BaseTest() {
private val address = "00:11:22:33:44:55"
private val off = AapSetting.AncMode.Value.OFF
private val on = AapSetting.AncMode.Value.ON
private val tx = AapSetting.AncMode.Value.TRANSPARENCY
private val ad = AapSetting.AncMode.Value.ADAPTIVE
private val visible = listOf(off, on, tx, ad)
@Test
fun `pending target is visible immediately and survives service restart state`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
val rendered = coordinator.applyPendingTarget(active(current = off, pending = null))
rendered.shouldBeInstanceOf<AncTileState.Active>()
rendered.pending shouldBe tx
pickNextMode(rendered.visible, rendered.current, rendered.pending) shouldBe ad
}
@Test
fun `replacing pending send dispatches only latest target`() = runTest {
val aapManager = mockk<AapConnectionManager>(relaxed = true)
val coordinator = coordinator(aapManager)
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
coordinator.pendingModes.value[address] shouldBe tx
advanceTimeBy(999)
runCurrent()
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(tx)) }
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(ad)) }
coordinator.scheduleSetAncMode(address, ad, debounce = 1.seconds)
coordinator.pendingModes.value[address] shouldBe ad
advanceTimeBy(999)
runCurrent()
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(tx)) }
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(ad)) }
advanceTimeBy(1)
runCurrent()
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(tx)) }
coVerify(exactly = 1) { aapManager.sendCommand(address, AapCommand.SetAncMode(ad)) }
}
@Test
fun `applying device pending confirmation is pure and keeps rendered pending mode`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
val rendered = coordinator.applyPendingTarget(active(current = off, pending = tx))
rendered.shouldBeInstanceOf<AncTileState.Active>()
rendered.pending shouldBe tx
coordinator.pendingModes.value[address] shouldBe tx
}
@Test
fun `acknowledging device pending confirmation clears process target`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
coordinator.acknowledgeDeviceState(active(current = off, pending = tx))
coordinator.pendingModes.value[address] shouldBe null
}
@Test
fun `applying device current confirmation is pure`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
val rendered = coordinator.applyPendingTarget(active(current = tx, pending = null))
rendered.shouldBeInstanceOf<AncTileState.Active>()
rendered.pending shouldBe null
coordinator.pendingModes.value[address] shouldBe tx
}
@Test
fun `acknowledging device current confirmation clears process target`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
coordinator.acknowledgeDeviceState(active(current = tx, pending = null))
coordinator.pendingModes.value[address] shouldBe null
}
@Test
fun `target matching current is kept while device reports different pending mode`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
val rendered = coordinator.applyPendingTarget(active(current = tx, pending = off))
rendered.shouldBeInstanceOf<AncTileState.Active>()
rendered.pending shouldBe tx
coordinator.pendingModes.value[address] shouldBe tx
}
@Test
fun `applying target filtered out of visible modes is pure`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, off, debounce = 1.seconds)
val rendered = coordinator.applyPendingTarget(active(current = on, pending = null, visible = listOf(on, tx, ad)))
rendered.shouldBeInstanceOf<AncTileState.Active>()
rendered.pending shouldBe null
coordinator.pendingModes.value[address] shouldBe off
}
@Test
fun `acknowledging target filtered out of visible modes clears process target`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, off, debounce = 1.seconds)
coordinator.acknowledgeDeviceState(active(current = on, pending = null, visible = listOf(on, tx, ad)))
coordinator.pendingModes.value[address] shouldBe null
}
@Test
fun `pending target clears after timeout without confirmation`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds, timeout = 5.seconds)
coordinator.pendingModes.value[address] shouldBe tx
advanceTimeBy(5_000)
runCurrent()
coordinator.pendingModes.value[address] shouldBe null
}
@Test
fun `send failure clears pending target`() = runTest {
val aapManager = mockk<AapConnectionManager>(relaxed = true)
coEvery {
aapManager.sendCommand(address, AapCommand.SetAncMode(tx))
} throws IllegalStateException("not connected")
val coordinator = coordinator(aapManager)
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
coordinator.pendingModes.value[address] shouldBe tx
advanceTimeBy(1_000)
runCurrent()
coordinator.pendingModes.value[address] shouldBe null
}
private fun TestScope.coordinator(
aapManager: AapConnectionManager = mockk(relaxed = true),
): AncTileSendCoordinator = AncTileSendCoordinator(
appScope = backgroundScope,
aapManager = aapManager,
)
private fun active(
current: AapSetting.AncMode.Value,
pending: AapSetting.AncMode.Value?,
visible: List<AapSetting.AncMode.Value> = this.visible,
) = AncTileState.Active(
current = current,
pending = pending,
visible = visible,
deviceLabel = "Pods",
deviceAddress = address,
)
}
@@ -0,0 +1,218 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class AncTileStateMapperTest : BaseTest() {
private val noPermissions = emptySet<Permission>()
private val supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
)
private fun activeDevice(
currentMode: AapSetting.AncMode.Value = AapSetting.AncMode.Value.ON,
pendingMode: AapSetting.AncMode.Value? = null,
connectionState: AapPodState.ConnectionState = AapPodState.ConnectionState.READY,
model: PodModel = PodModel.AIRPODS_PRO,
): PodDevice {
val ancSetting = AapSetting.AncMode(current = currentMode, supported = supportedModes)
return PodDevice(
profileId = "p1",
ble = null,
aap = AapPodState(
connectionState = connectionState,
settings = mapOf(AapSetting.AncMode::class to ancSetting),
pendingAncMode = pendingMode,
),
profileModel = model,
)
}
@Test
fun `not pro returns NotPro regardless of other state`() {
AncTileStateMapper.map(
device = activeDevice(),
isPro = false,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
) shouldBe AncTileState.NotPro
}
@Test
fun `missing scan permission returns PermissionRequired`() {
AncTileStateMapper.map(
device = activeDevice(),
isPro = true,
isBluetoothEnabled = true,
missingPermissions = setOf(Permission.BLUETOOTH_SCAN),
) shouldBe AncTileState.PermissionRequired
}
@Test
fun `missing BLUETOOTH_CONNECT returns PermissionRequired`() {
AncTileStateMapper.map(
device = activeDevice(),
isPro = true,
isBluetoothEnabled = true,
missingPermissions = setOf(Permission.BLUETOOTH_CONNECT),
) shouldBe AncTileState.PermissionRequired
}
@Test
fun `missing non-blocking permission does not flip state`() {
AncTileStateMapper.map(
device = activeDevice(),
isPro = true,
isBluetoothEnabled = true,
missingPermissions = setOf(Permission.POST_NOTIFICATIONS),
).shouldBeInstanceOf<AncTileState.Active>()
}
@Test
fun `bluetooth disabled returns BluetoothOff`() {
AncTileStateMapper.map(
device = activeDevice(),
isPro = true,
isBluetoothEnabled = false,
missingPermissions = noPermissions,
) shouldBe AncTileState.BluetoothOff
}
@Test
fun `null device returns NoDevice`() {
AncTileStateMapper.map(
device = null,
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
) shouldBe AncTileState.NoDevice
}
@Test
fun `device without ANC support returns NoAncSupport`() {
val device = PodDevice(
profileId = "p1",
ble = null,
aap = null,
profileModel = PodModel.AIRPODS_GEN1,
)
AncTileStateMapper.map(
device = device,
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
) shouldBe AncTileState.NoAncSupport
}
@Test
fun `cached device with no AAP session returns NotConnected`() {
// ANC-capable model but aap == null → user sees "Disconnected", not "Connecting forever".
val device = PodDevice(
profileId = "p1",
ble = null,
aap = null,
profileModel = PodModel.AIRPODS_PRO,
)
AncTileStateMapper.map(
device = device,
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
) shouldBe AncTileState.NotConnected
}
@Test
fun `aap connected but not ready returns Connecting`() {
AncTileStateMapper.map(
device = activeDevice(connectionState = AapPodState.ConnectionState.HANDSHAKING),
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
) shouldBe AncTileState.Connecting
}
@Test
fun `aap ready without AncMode setting returns Connecting`() {
val device = PodDevice(
profileId = "p1",
ble = null,
aap = AapPodState(connectionState = AapPodState.ConnectionState.READY),
profileModel = PodModel.AIRPODS_PRO,
)
AncTileStateMapper.map(
device = device,
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
) shouldBe AncTileState.Connecting
}
@Test
fun `fully ready device returns Active with current and visible modes`() {
val state = AncTileStateMapper.map(
device = activeDevice(currentMode = AapSetting.AncMode.Value.TRANSPARENCY),
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
)
state.shouldBeInstanceOf<AncTileState.Active>()
state.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
state.visible shouldBe supportedModes
}
@Test
fun `pending mode is propagated to Active`() {
val state = AncTileStateMapper.map(
device = activeDevice(
currentMode = AapSetting.AncMode.Value.OFF,
pendingMode = AapSetting.AncMode.Value.TRANSPARENCY,
),
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
)
state.shouldBeInstanceOf<AncTileState.Active>()
state.pending shouldBe AapSetting.AncMode.Value.TRANSPARENCY
}
@Test
fun `precedence pro gating wins over bluetooth off`() {
AncTileStateMapper.map(
device = activeDevice(),
isPro = false,
isBluetoothEnabled = false,
missingPermissions = noPermissions,
) shouldBe AncTileState.NotPro
}
@Test
fun `precedence bluetooth off wins over no device`() {
AncTileStateMapper.map(
device = null,
isPro = true,
isBluetoothEnabled = false,
missingPermissions = noPermissions,
) shouldBe AncTileState.BluetoothOff
}
@Test
fun `precedence permission required wins over bluetooth off`() {
AncTileStateMapper.map(
device = null,
isPro = true,
isBluetoothEnabled = false,
missingPermissions = setOf(Permission.BLUETOOTH_SCAN),
) shouldBe AncTileState.PermissionRequired
}
}
@@ -0,0 +1,198 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
import kotlin.time.Duration.Companion.seconds
class AncTileStateStoreTest : BaseTest() {
private val address = "00:11:22:33:44:55"
private val off = AapSetting.AncMode.Value.OFF
private val on = AapSetting.AncMode.Value.ON
private val tx = AapSetting.AncMode.Value.TRANSPARENCY
private val ad = AapSetting.AncMode.Value.ADAPTIVE
private val visible = listOf(off, on, tx, ad)
@Test
fun `current state survives service listener gap`() = runTest {
val devices = MutableStateFlow(listOf(activeDevice(currentMode = off)))
val store = store(devices = devices)
val listener = collectState(store)
runCurrent()
val activeBeforeGap = store.currentState()
activeBeforeGap.shouldBeInstanceOf<AncTileState.Active>()
activeBeforeGap.current shouldBe off
listener.cancel()
runCurrent()
val activeAfterGap = store.currentState()
activeAfterGap.shouldBeInstanceOf<AncTileState.Active>()
activeAfterGap.current shouldBe off
}
@Test
fun `current state warms without service listener`() = runTest {
val store = store(devices = MutableStateFlow(listOf(activeDevice(currentMode = off))))
runCurrent()
val current = store.currentState()
current.shouldBeInstanceOf<AncTileState.Active>()
current.current shouldBe off
}
@Test
fun `current state reports connecting while live device is not ready`() = runTest {
val devices = MutableStateFlow(listOf(connectingDevice()))
val store = store(devices = devices)
runCurrent()
store.currentState() shouldBe AncTileState.Connecting
}
@Test
fun `current state overlays coordinator target before collected state updates`() = runTest {
val coordinator = coordinator()
val store = store(
devices = MutableStateFlow(listOf(activeDevice(currentMode = off))),
sendCoordinator = coordinator,
)
val listener = collectState(store)
runCurrent()
coordinator.scheduleSetAncMode(address, on, debounce = 1.seconds)
val current = store.currentState()
current.shouldBeInstanceOf<AncTileState.Active>()
current.pending shouldBe on
listener.cancel()
}
@Test
fun `device confirmation clears coordinator target through state store`() = runTest {
val devices = MutableStateFlow(listOf(activeDevice(currentMode = off)))
val coordinator = coordinator()
val store = store(
devices = devices,
sendCoordinator = coordinator,
)
runCurrent()
coordinator.scheduleSetAncMode(address, on, debounce = 1.seconds)
coordinator.pendingModes.value[address] shouldBe on
devices.value = listOf(activeDevice(currentMode = on))
runCurrent()
coordinator.pendingModes.value[address] shouldBe null
val current = store.currentState()
current.shouldBeInstanceOf<AncTileState.Active>()
current.current shouldBe on
current.pending shouldBe null
}
private fun TestScope.collectState(store: AncTileStateStore): Job = launch {
store.state.collect {}
}
private fun TestScope.store(
devices: MutableStateFlow<List<PodDevice>>,
profiles: MutableStateFlow<List<DeviceProfile>> = MutableStateFlow(emptyList()),
isPro: MutableStateFlow<Boolean> = MutableStateFlow(true),
bluetoothEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
missingPermissions: MutableStateFlow<Set<Permission>> = MutableStateFlow(emptySet()),
sendCoordinator: AncTileSendCoordinator = coordinator(),
): AncTileStateStore {
val deviceMonitor = mockk<DeviceMonitor> {
every { this@mockk.devices } returns devices
}
val profilesRepo = mockk<DeviceProfilesRepo> {
every { this@mockk.profiles } returns profiles
}
val upgradeRepo = mockk<UpgradeRepo> {
every { upgradeInfo } returns MutableStateFlow(upgradeInfo(isPro.value))
}
val bluetoothManager = mockk<BluetoothManager2> {
every { isBluetoothEnabled } returns bluetoothEnabled
}
val permissionTool = mockk<PermissionTool> {
every { this@mockk.missingPermissions } returns missingPermissions
}
return AncTileStateStore(
appScope = backgroundScope,
deviceMonitor = deviceMonitor,
profilesRepo = profilesRepo,
upgradeRepo = upgradeRepo,
bluetoothManager = bluetoothManager,
permissionTool = permissionTool,
sendCoordinator = sendCoordinator,
)
}
private fun TestScope.coordinator(
aapManager: AapConnectionManager = mockk(relaxed = true),
): AncTileSendCoordinator = AncTileSendCoordinator(
appScope = backgroundScope,
aapManager = aapManager,
)
private fun activeDevice(
currentMode: AapSetting.AncMode.Value,
pendingMode: AapSetting.AncMode.Value? = null,
): PodDevice {
val ancSetting = AapSetting.AncMode(current = currentMode, supported = visible)
return PodDevice(
profileId = "p1",
ble = null,
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(AapSetting.AncMode::class to ancSetting),
pendingAncMode = pendingMode,
),
profileAddress = address,
profileModel = PodModel.AIRPODS_PRO,
)
}
private fun connectingDevice(): PodDevice = PodDevice(
profileId = "p1",
ble = null,
aap = AapPodState(connectionState = AapPodState.ConnectionState.HANDSHAKING),
profileAddress = address,
profileModel = PodModel.AIRPODS_PRO,
)
private fun upgradeInfo(isPro: Boolean) = object : UpgradeRepo.Info {
override val type: UpgradeRepo.Type = UpgradeRepo.Type.FOSS
override val isPro: Boolean = isPro
override val upgradedAt: Instant? = null
override val error: Throwable? = null
}
}
@@ -3,6 +3,8 @@ package eu.darken.capod.monitor.core
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.monitor.core.aap.AapLifecycleManager
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import eu.darken.capod.monitor.core.cache.CachedDeviceState
@@ -22,6 +24,7 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
@@ -31,6 +34,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
@@ -536,6 +540,96 @@ class DeviceMonitorTest : BaseTest() {
devices.size shouldBe 2
}
@AfterEach
fun resetBugsReporter() {
Bugs.reporter = null
}
/**
* Regression: a NPE in the cache merge path used to throw out of `onEach { persistLiveDevices }`,
* cancelling the upstream `combine` and freezing every downstream observer (overview, widgets,
* etc.) for the rest of the process lifetime. The persist loop now catches and reports.
*
* Throws from inside `toCachedState` (via a BLE getter) rather than from `save()` so the test
* locks in that the catch covers the actual NPE boundary, not just the cache I/O boundary.
*/
@Test
fun `flow keeps emitting after persist failure and only reports the bug once per profile`() =
runTest(testDispatcher) {
val reporter = mockk<AutomaticBugReporter>(relaxed = true)
Bugs.reporter = reporter
val bleFlow = MutableStateFlow(listOf(mockThrowingDualBlePodWithProfile(testProfile)))
val aapFlow = MutableStateFlow(emptyMap<BluetoothAddress, AapPodState>())
val cacheFlow = MutableStateFlow<Map<String, CachedDeviceState>>(emptyMap())
val profilesFlow = MutableStateFlow<List<DeviceProfile>>(listOf(testProfile))
val blePodMonitor: BlePodMonitor = mockk { every { devices } returns bleFlow }
val aapManager: AapConnectionManager = mockk { every { allStates } returns aapFlow }
val deviceStateCache: DeviceStateCache = mockk(relaxed = true) {
every { cachedStates } returns cacheFlow
coEvery { load(any()) } answers { cacheFlow.value[firstArg<String>()] }
}
val profilesRepo: DeviceProfilesRepo = mockk { every { profiles } returns profilesFlow }
val aapLifecycleManager: AapLifecycleManager = mockk(relaxed = true)
val bluetoothManager: BluetoothManager2 = mockk {
every { connectedDevices } returns MutableStateFlow(emptyList())
}
val monitor = DeviceMonitor(
appScope = backgroundScope,
blePodMonitor = blePodMonitor,
aapManager = aapManager,
bluetoothManager = bluetoothManager,
deviceStateCache = deviceStateCache,
profilesRepo = profilesRepo,
aapLifecycleManager = aapLifecycleManager,
timeSource = timeSource,
)
val received = mutableListOf<List<PodDevice>>()
val collector = backgroundScope.launch {
monitor.devices.collect { received += it }
}
advanceUntilIdle()
val initialEmissionCount = received.size
initialEmissionCount shouldNotBe 0
// Trigger more emissions; toCachedState keeps throwing inside the persist loop, but
// the flow must survive instead of cancelling its upstream combine.
bleFlow.value = listOf(mockThrowingDualBlePodWithProfile(testProfile))
advanceUntilIdle()
bleFlow.value = listOf(mockThrowingDualBlePodWithProfile(testProfile))
advanceUntilIdle()
// The flow survived: at least two more emissions arrived after the failing merge.
(received.size - initialEmissionCount) shouldBe 2
// The cache write must NOT have been attempted — the failure was upstream of save().
coVerify(exactly = 0) { deviceStateCache.save(any(), any()) }
// Dedup: even though merge failed on every emission for the same profile, only one report.
verify(exactly = 1) { reporter.notify(any()) }
collector.cancel()
}
/**
* A live BLE pod whose battery getter throws on read. Used to simulate the NPE that R8/JIT
* was producing inside the cache merge path — the throw originates inside `toCachedState`,
* before `save()` is called.
*/
private fun mockThrowingDualBlePodWithProfile(profile: DeviceProfile): BlePodSnapshot {
val bleMeta = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile? = profile
}
return mockk<DualBlePodSnapshot>(relaxed = true) {
every { meta } returns bleMeta
every { this@mockk.model } returns profile.model
every { seenFirstAt } returns Instant.parse("2026-04-05T17:50:00Z")
every { seenLastAt } returns Instant.parse("2026-04-05T18:00:00Z")
every { batteryLeftPodPercent } throws NullPointerException("synthetic merge failure")
}
}
@Test
fun `cache-only refresh does not trigger another persist cycle`() = runTest(testDispatcher) {
val bleFlow = MutableStateFlow(listOf(mockLiveDualBlePodWithProfile(testProfile)))
@@ -12,6 +12,7 @@ import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
import eu.darken.capod.pods.core.apple.ble.devices.SingleApplePods
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
@@ -200,6 +201,23 @@ class PodDeviceTest : BaseTest() {
device.isEitherPodInEar shouldBe true
}
@Test
fun `BLE isBeingWorn delegates to HasEarDetection on single-pod devices`() {
val mock = mockk<SingleApplePods>(relaxed = true, moreInterfaces = arrayOf(HasEarDetection::class)) {
every { model } returns PodModel.AIRPODS_MAX2
every { (this@mockk as HasEarDetection).isBeingWorn } returns true
}
val device = PodDevice(profileId = null, ble = mock, aap = null)
device.isBeingWorn shouldBe true
val mockNotWorn = mockk<SingleApplePods>(relaxed = true, moreInterfaces = arrayOf(HasEarDetection::class)) {
every { model } returns PodModel.AIRPODS_MAX2
every { (this@mockk as HasEarDetection).isBeingWorn } returns false
}
val deviceNotWorn = PodDevice(profileId = null, ble = mockNotWorn, aap = null)
deviceNotWorn.isBeingWorn shouldBe false
}
@Test
fun `AAP ear detection preferred over BLE`() {
val mock = mockk<DualApplePods>(relaxed = true) {
@@ -0,0 +1,84 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class PodDeviceTierTest : BaseTest() {
private fun device(
profileId: String?,
isSystemConnected: Boolean = false,
isLive: Boolean = false,
): PodDevice {
// isLive is derived from ble != null || aap != null. Use a minimal AapPodState so we
// don't need to fabricate a BLE snapshot for the live case.
val aap = if (isLive) AapPodState() else null
return PodDevice(
profileId = profileId,
ble = null,
aap = aap,
profileModel = PodModel.AIRPODS_PRO,
isSystemConnected = isSystemConnected,
)
}
@Test
fun `system connected ranks above live which ranks above offline`() {
device(profileId = "a", isSystemConnected = true).tierRank() shouldBe 0
device(profileId = "a", isLive = true).tierRank() shouldBe 1
device(profileId = "a").tierRank() shouldBe 2
}
@Test
fun `system connected wins regardless of live`() {
// isSystemConnected true takes precedence even if also live
device(profileId = "a", isSystemConnected = true, isLive = true).tierRank() shouldBe 0
}
@Test
fun `primaryByTier picks system-connected device first`() {
val live = device(profileId = "a", isLive = true)
val systemConnected = device(profileId = "b", isSystemConnected = true)
val devices = listOf(live, systemConnected)
devices.primaryByTier(profileOrder = mapOf("a" to 0, "b" to 1)) shouldBe systemConnected
}
@Test
fun `primaryByTier uses profile order as tiebreaker within a tier`() {
val first = device(profileId = "a", isLive = true)
val second = device(profileId = "b", isLive = true)
val devices = listOf(second, first)
devices.primaryByTier(profileOrder = mapOf("a" to 0, "b" to 1)) shouldBe first
}
@Test
fun `primaryByTier ignores profileless devices`() {
val anonymous = device(profileId = null, isSystemConnected = true)
val profiled = device(profileId = "a", isLive = true)
val devices = listOf(anonymous, profiled)
devices.primaryByTier(profileOrder = mapOf("a" to 0)) shouldBe profiled
}
@Test
fun `primaryByTier returns null when no profiled devices`() {
val devices = listOf(device(profileId = null, isLive = true))
devices.primaryByTier(profileOrder = emptyMap()) shouldBe null
}
@Test
fun `primaryByTier returns null on empty list`() {
emptyList<PodDevice>().primaryByTier(profileOrder = emptyMap()) shouldBe null
}
@Test
fun `primaryByTier handles missing profile order with stable fallback`() {
// Profile id not present in map → falls back to Int.MAX_VALUE (last)
val withOrder = device(profileId = "a", isLive = true)
val withoutOrder = device(profileId = "z", isLive = true)
val devices = listOf(withoutOrder, withOrder)
devices.primaryByTier(profileOrder = mapOf("a" to 0)) shouldBe withOrder
}
}
@@ -0,0 +1,148 @@
package eu.darken.capod.monitor.core.ble
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.apple.ble.PodFactory
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPairing
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.verify
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
import testhelpers.datastore.FakeDataStoreValue
class BlePodMonitorTest : BaseTest() {
@Test
fun `scan security exception emits empty devices and rechecks permissions`() = runTest {
val fixture = createFixture {
flow<Collection<BleScanResult>> { throw SecurityException("scan denied") }
}
fixture.monitor.devices.drop(1).first() shouldBe emptyList()
verify(exactly = 1) { fixture.permissionTool.recheck() }
verify(exactly = 1) {
fixture.bleScanner.scan(
filters = any(),
scannerMode = any(),
disableOffloadFiltering = any(),
disableOffloadBatching = any(),
disableDirectScanCallback = any(),
)
}
}
@Test
fun `non security scan failure retries`() = runTest {
var attempts = 0
val fixture = createFixture {
attempts += 1
if (attempts == 1) {
flow<Collection<BleScanResult>> { throw IllegalStateException("temporary scanner failure") }
} else {
flowOf(emptyList())
}
}
fixture.monitor.devices.drop(1).first() shouldBe emptyList()
attempts shouldBe 2
verify(exactly = 0) { fixture.permissionTool.recheck() }
verify(exactly = 2) {
fixture.bleScanner.scan(
filters = any(),
scannerMode = any(),
disableOffloadFiltering = any(),
disableOffloadBatching = any(),
disableDirectScanCallback = any(),
)
}
}
private fun TestScope.createFixture(
scanFlowFactory: () -> Flow<Collection<BleScanResult>>,
): Fixture {
mockkObject(ProximityPairing)
every { ProximityPairing.getBleScanFilter() } returns emptySet()
val bleScanner = mockk<BleScanner>().apply {
every {
scan(
filters = any(),
scannerMode = any(),
disableOffloadFiltering = any(),
disableOffloadBatching = any(),
disableDirectScanCallback = any(),
)
} answers { scanFlowFactory() }
}
val scanModeController = mockk<BleScanModeController>().apply {
every { scannerMode } returns MutableStateFlow(ScannerMode.BALANCED)
}
val generalSettings = mockk<GeneralSettings>().apply {
every { isOffloadedBatchingDisabled } returns FakeDataStoreValue(false).mock
every { isOffloadedFilteringDisabled } returns FakeDataStoreValue(false).mock
every { useIndirectScanResultCallback } returns FakeDataStoreValue(false).mock
}
val debugSettings = mockk<DebugSettings>().apply {
every { showUnfiltered } returns FakeDataStoreValue(false).mock
}
val permissionTool = mockk<PermissionTool>().apply {
every { missingScanPermissions } returns MutableStateFlow<Set<Permission>>(emptySet())
every { recheck() } just Runs
}
val bluetoothManager = mockk<BluetoothManager2>().apply {
every { isBluetoothEnabled } returns MutableStateFlow(true)
}
val profilesRepo = mockk<DeviceProfilesRepo>().apply {
every { profiles } returns MutableStateFlow(emptyList())
}
val timeSource: TimeSource = TestTimeSource()
return Fixture(
monitor = BlePodMonitor(
appScope = backgroundScope,
bleScanner = bleScanner,
bleScanModeController = scanModeController,
podFactory = mockk<PodFactory>(relaxed = true),
timeSource = timeSource,
generalSettings = generalSettings,
bluetoothManager = bluetoothManager,
debugSettings = debugSettings,
permissionTool = permissionTool,
profilesRepo = profilesRepo,
),
bleScanner = bleScanner,
permissionTool = permissionTool,
)
}
private data class Fixture(
val monitor: BlePodMonitor,
val bleScanner: BleScanner,
val permissionTool: PermissionTool,
)
}
@@ -71,4 +71,22 @@ class CachedDeviceStateMigrationTest : BaseTest() {
val state = json.decodeFromString(CachedDeviceState.serializer(), legacy)
state.deviceInfo!!.marketingVersion shouldBe "8454480"
}
@Test
fun `deviceInfo is non-null when only new earbud-serial fields are present`() {
val state = CachedDeviceState(
profileId = "earbud-only",
model = PodModel.AIRPODS_PRO3,
leftEarbudSerial = "L-9",
rightEarbudSerial = "R-9",
marketingVersion = "8888",
lastSeenAt = java.time.Instant.ofEpochMilli(1767364074000L),
)
val info = state.deviceInfo!!
info.leftEarbudSerial shouldBe "L-9"
info.rightEarbudSerial shouldBe "R-9"
info.marketingVersion shouldBe "8888"
info.name shouldBe ""
}
}
@@ -2,6 +2,8 @@ package eu.darken.capod.monitor.core.cache
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
@@ -154,5 +156,119 @@ class ToCachedStateTest : BaseTest() {
.toCachedState(existing, now)
.shouldNotBeNull()
}
@Test
fun `returns new state when only earbud serial changes`() {
val existing = CachedDeviceState(
profileId = "test-profile",
model = PodModel.AIRPODS_PRO3,
left = CachedDeviceState.CachedBatterySlot(0.8f, now),
right = CachedDeviceState.CachedBatterySlot(0.7f, now),
case = CachedDeviceState.CachedBatterySlot(0.5f, now),
isLeftCharging = false,
isRightCharging = false,
isCaseCharging = false,
isHeadsetCharging = false,
deviceName = "AirPods",
serialNumber = "",
firmwareVersion = "",
leftEarbudSerial = "OLD-LEFT",
rightEarbudSerial = "R-1",
marketingVersion = "1234",
lastSeenAt = now,
)
val device = PodDevice(
profileId = "test-profile",
ble = mockDualPod(leftBattery = 0.8f, rightBattery = 0.7f, caseBattery = 0.5f),
aap = AapPodState(
deviceInfo = deviceInfo(
leftEarbudSerial = "NEW-LEFT",
rightEarbudSerial = "R-1",
marketingVersion = "1234",
),
),
)
val result = device.toCachedState(existing, now).shouldNotBeNull()
result.leftEarbudSerial shouldBe "NEW-LEFT"
result.rightEarbudSerial shouldBe "R-1"
result.marketingVersion shouldBe "1234"
}
@Test
fun `returns new state when only marketing version changes`() {
val existing = CachedDeviceState(
profileId = "test-profile",
model = PodModel.AIRPODS_PRO3,
left = CachedDeviceState.CachedBatterySlot(0.8f, now),
isLeftCharging = false,
isRightCharging = false,
isCaseCharging = false,
isHeadsetCharging = false,
deviceName = "AirPods",
serialNumber = "",
firmwareVersion = "",
marketingVersion = "1234",
lastSeenAt = now,
)
val device = PodDevice(
profileId = "test-profile",
ble = mockDualPod(leftBattery = 0.8f),
aap = AapPodState(deviceInfo = deviceInfo(marketingVersion = "5678")),
)
val result = device.toCachedState(existing, now).shouldNotBeNull()
result.marketingVersion shouldBe "5678"
}
}
@Nested
inner class DeviceInfoOnly {
@Test
fun `persists DeviceInfo when no live battery is available`() {
// Mirror how DeviceMonitor.aapOnlyForPersistence builds an AAP-only PodDevice:
// profileModel and profileAddress carry the model/address since `ble` is null.
val device = PodDevice(
profileId = "test-profile",
ble = null,
aap = AapPodState(
deviceInfo = deviceInfo(
name = "Pro 3",
leftEarbudSerial = "L-1",
rightEarbudSerial = "R-1",
marketingVersion = "9999",
),
),
profileModel = PodModel.AIRPODS_PRO3,
profileAddress = "AA:BB:CC:DD:EE:FF",
)
val result = device.toCachedState(existing = null, now = now).shouldNotBeNull()
result.model shouldBe PodModel.AIRPODS_PRO3
result.address shouldBe "AA:BB:CC:DD:EE:FF"
result.deviceName shouldBe "Pro 3"
result.leftEarbudSerial shouldBe "L-1"
result.rightEarbudSerial shouldBe "R-1"
result.marketingVersion shouldBe "9999"
}
}
private fun deviceInfo(
name: String = "AirPods",
serialNumber: String = "",
firmwareVersion: String = "",
leftEarbudSerial: String? = null,
rightEarbudSerial: String? = null,
marketingVersion: String? = null,
) = AapDeviceInfo(
name = name,
modelNumber = "",
manufacturer = "",
serialNumber = serialNumber,
firmwareVersion = firmwareVersion,
leftEarbudSerial = leftEarbudSerial,
rightEarbudSerial = rightEarbudSerial,
marketingVersion = marketingVersion,
)
}
@@ -1,5 +1,6 @@
package eu.darken.capod.pods.core.apple
import io.kotest.assertions.withClue
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
@@ -7,79 +8,364 @@ import testhelpers.BaseTest
class ModelFeaturesTest : BaseTest() {
@Test
fun `AirPods Pro 3 has all features`() {
val f = PodModel.AIRPODS_PRO3.features
f.hasDualPods shouldBe true
f.hasCase shouldBe true
f.hasEarDetection shouldBe true
f.hasAncControl shouldBe true
fun `feature flags match supported model matrix`() {
featureExpectations.forEach { expectation ->
withClue(expectation.name) {
modelsWith(expectation.predicate) shouldBe expectation.models
}
}
}
@Test
fun `AirPods Gen 1 has dual pods and case but no ear detection or ANC`() {
val f = PodModel.AIRPODS_GEN1.features
f.hasDualPods shouldBe true
f.hasCase shouldBe true
f.hasEarDetection shouldBe false
f.hasAncControl shouldBe false
}
@Test
fun `AirPods Max is single device with ANC but no dual pods or case`() {
val f = PodModel.AIRPODS_MAX.features
f.hasDualPods shouldBe false
f.hasCase shouldBe false
f.hasEarDetection shouldBe true
f.hasAncControl shouldBe true
}
@Test
fun `Beats Solo 3 has no features`() {
val f = PodModel.BEATS_SOLO_3.features
f.hasDualPods shouldBe false
f.hasCase shouldBe false
f.hasEarDetection shouldBe false
f.hasAncControl shouldBe false
}
@Test
fun `PowerBeats Pro has dual pods, case, ear detection but no ANC`() {
val f = PodModel.POWERBEATS_PRO.features
f.hasDualPods shouldBe true
f.hasCase shouldBe true
f.hasEarDetection shouldBe true
f.hasAncControl shouldBe false
fun `models without ear detection match intentional matrix`() {
modelsWithout { it.hasEarDetection } shouldBe setOf(
PodModel.BEATS_FLEX,
PodModel.BEATS_SOLO_3,
PodModel.BEATS_SOLO_PRO,
PodModel.BEATS_SOLO_4,
PodModel.BEATS_SOLO_BUDS,
PodModel.BEATS_STUDIO_3,
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
PodModel.BEATS_STUDIO_PRO,
PodModel.BEATS_X,
PodModel.POWERBEATS_3,
PodModel.POWERBEATS_4,
PodModel.UNKNOWN,
)
}
@Test
fun `UNKNOWN model has no features`() {
val f = PodModel.UNKNOWN.features
f.hasDualPods shouldBe false
f.hasCase shouldBe false
f.hasEarDetection shouldBe false
f.hasAncControl shouldBe false
PodModel.UNKNOWN.features shouldBe PodModel.Features()
}
@Test
fun `all ANC-capable dual-pod models have ear detection except Studio Buds`() {
val noEarDetectionAncModels = setOf(
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
)
fun `ear detection toggle implies ear detection`() {
PodModel.entries
.filter { it.features.hasAncControl && it.features.hasDualPods }
.filter { it !in noEarDetectionAncModels }
.filter { it.features.hasEarDetectionToggle }
.forEach { model ->
model.features.hasEarDetection shouldBe true
withClue(model.name) {
model.features.hasEarDetection shouldBe true
}
}
}
@Test
fun `all models with case also have dual pods`() {
fun `sleep detection implies ear detection`() {
PodModel.entries
.filter { it.features.hasSleepDetection }
.forEach { model ->
withClue(model.name) {
model.features.hasEarDetection shouldBe true
}
}
}
@Test
fun `case implies dual pods`() {
PodModel.entries
.filter { it.features.hasCase }
.forEach { model ->
model.features.hasDualPods shouldBe true
withClue(model.name) {
model.features.hasDualPods shouldBe true
}
}
}
}
@Test
fun `adaptive ANC implies ANC control`() {
PodModel.entries
.filter { it.features.hasAdaptiveAnc }
.forEach { model ->
withClue(model.name) {
model.features.hasAncControl shouldBe true
}
}
}
@Test
fun `adaptive audio noise implies adaptive ANC`() {
PodModel.entries
.filter { it.features.hasAdaptiveAudioNoise }
.forEach { model ->
withClue(model.name) {
model.features.hasAdaptiveAnc shouldBe true
}
}
}
@Test
fun `listening mode cycle implies ANC control`() {
PodModel.entries
.filter { it.features.hasListeningModeCycle }
.forEach { model ->
withClue(model.name) {
model.features.hasAncControl shouldBe true
}
}
}
@Test
fun `allow off option implies listening mode cycle`() {
PodModel.entries
.filter { it.features.hasAllowOffOption }
.forEach { model ->
withClue(model.name) {
model.features.hasListeningModeCycle shouldBe true
}
}
}
private fun modelsWith(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
.filter { predicate(it.features) }
.toSet()
private fun modelsWithout(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
.filterNot { predicate(it.features) }
.toSet()
private data class FeatureExpectation(
val name: String,
val predicate: (PodModel.Features) -> Boolean,
val models: Set<PodModel>,
)
private fun feature(
name: String,
predicate: (PodModel.Features) -> Boolean,
models: Set<PodModel>,
) = FeatureExpectation(name, predicate, models)
private val dualPodModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.BEATS_SOLO_BUDS,
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
PodModel.POWERBEATS_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
PodModel.FAKE_AIRPODS_GEN1,
PodModel.FAKE_AIRPODS_GEN2,
PodModel.FAKE_AIRPODS_GEN3,
PodModel.FAKE_AIRPODS_PRO,
PodModel.FAKE_AIRPODS_PRO2,
)
private val earDetectionModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
PodModel.POWERBEATS_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
PodModel.FAKE_AIRPODS_GEN1,
PodModel.FAKE_AIRPODS_GEN2,
PodModel.FAKE_AIRPODS_GEN3,
PodModel.FAKE_AIRPODS_PRO,
PodModel.FAKE_AIRPODS_PRO2,
)
private val ancControlModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
PodModel.BEATS_SOLO_PRO,
PodModel.BEATS_STUDIO_3,
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
PodModel.BEATS_STUDIO_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
PodModel.FAKE_AIRPODS_PRO,
PodModel.FAKE_AIRPODS_PRO2,
)
private val adaptiveAncModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val conversationAwarenessModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val ncOneAirpodModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val pressSpeedModels = setOf(
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val volumeSwipeModels = setOf(
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val personalizedVolumeModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val toneVolumeModels = setOf(
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val endCallMuteMicModels = setOf(
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val adaptiveAudioNoiseModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val microphoneModeModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
)
private val earDetectionToggleModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
PodModel.POWERBEATS_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
)
private val listeningModeCycleModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val stemConfigModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val sleepDetectionModels = setOf(
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.POWERBEATS_PRO2,
)
private val featureExpectations = listOf(
feature("hasDualPods", { it.hasDualPods }, dualPodModels),
feature("hasCase", { it.hasCase }, dualPodModels),
feature("hasEarDetection", { it.hasEarDetection }, earDetectionModels),
feature("hasAncControl", { it.hasAncControl }, ancControlModels),
feature("hasAdaptiveAnc", { it.hasAdaptiveAnc }, adaptiveAncModels),
feature("hasConversationAwareness", { it.hasConversationAwareness }, conversationAwarenessModels),
feature("hasNcOneAirpod", { it.hasNcOneAirpod }, ncOneAirpodModels),
feature("hasPressSpeed", { it.hasPressSpeed }, pressSpeedModels),
feature("hasPressHoldDuration", { it.hasPressHoldDuration }, pressSpeedModels),
feature("hasVolumeSwipe", { it.hasVolumeSwipe }, volumeSwipeModels),
feature("hasVolumeSwipeLength", { it.hasVolumeSwipeLength }, volumeSwipeModels),
feature("hasPersonalizedVolume", { it.hasPersonalizedVolume }, personalizedVolumeModels),
feature("hasToneVolume", { it.hasToneVolume }, toneVolumeModels),
feature("hasEndCallMuteMic", { it.hasEndCallMuteMic }, endCallMuteMicModels),
feature("hasAdaptiveAudioNoise", { it.hasAdaptiveAudioNoise }, adaptiveAudioNoiseModels),
feature("hasMicrophoneMode", { it.hasMicrophoneMode }, microphoneModeModels),
feature("hasEarDetectionToggle", { it.hasEarDetectionToggle }, earDetectionToggleModels),
feature("hasListeningModeCycle", { it.hasListeningModeCycle }, listeningModeCycleModels),
feature("hasAllowOffOption", { it.hasAllowOffOption }, listeningModeCycleModels),
feature("hasStemConfig", { it.hasStemConfig }, stemConfigModels),
feature("hasSleepDetection", { it.hasSleepDetection }, sleepDetectionModels),
feature("hasDynamicEndOfCharge", { it.hasDynamicEndOfCharge }, setOf(PodModel.AIRPODS_PRO3)),
)
}
@@ -5,7 +5,9 @@ import android.bluetooth.BluetoothSocket
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.engine.AapConnection
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.maps.shouldBeEmpty
@@ -13,6 +15,7 @@ import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@@ -24,6 +27,10 @@ import testhelpers.TestTimeSource
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration.Companion.milliseconds
class AapConnectionManagerTest : BaseTest() {
@@ -89,6 +96,45 @@ class AapConnectionManagerTest : BaseTest() {
manager.allStates.value.shouldBeEmpty()
}
@Test
fun `connect timeout closes in-flight socket and allows reconnect`() = testScope.runTest {
val closeCalled = CountDownLatch(1)
val closeCalls = AtomicInteger(0)
val blockingSocket = mockk<BluetoothSocket>(relaxed = true) {
every { connect() } answers {
closeCalled.await(1, TimeUnit.SECONDS)
throw IOException("closed")
}
every { close() } answers {
closeCalls.incrementAndGet()
closeCalled.countDown()
}
}
val reconnectSocket = mockk<BluetoothSocket>(relaxed = true) {
every { outputStream } returns ByteArrayOutputStream()
every { inputStream } returns ByteArrayInputStream(byteArrayOf())
}
every { socketFactory.createSocket(any(), any()) } returnsMany listOf(blockingSocket, reconnectSocket)
val connection = AapConnection(
device = testDevice,
profile = AapDeviceProfile.forModel(PodModel.AIRPODS_PRO3),
socketFactory = socketFactory,
timeSource = timeSource,
connectTimeout = 50.milliseconds,
)
shouldThrow<TimeoutCancellationException> {
connection.connect(testScope)
}
closeCalled.await(1, TimeUnit.SECONDS) shouldBe true
closeCalls.get() shouldBe 1
connection.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
connection.connect(testScope)
advanceUntilIdle()
}
@Test
fun `remote disconnect cleans up allStates`() = testScope.runTest {
// Empty inputStream → readLoop gets -1 immediately → DISCONNECTED
@@ -337,6 +337,7 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasPressSpeed shouldBe true
f.hasPressHoldDuration shouldBe true
f.hasToneVolume shouldBe true
f.hasEndCallMuteMic shouldBe true
f.hasListeningModeCycle shouldBe false
f.hasStemConfig shouldBe false
f.hasSleepDetection shouldBe true
@@ -351,14 +352,31 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasStemConfig shouldBe false
}
@Test fun `Gen 1 has mic mode only`() {
@Test fun `Gen 1 has mic mode and ear detection toggle`() {
val f = PodModel.AIRPODS_GEN1.features
f.hasMicrophoneMode shouldBe true
f.hasEarDetectionToggle shouldBe false
f.hasEarDetection shouldBe true
f.hasEarDetectionToggle shouldBe true
f.hasListeningModeCycle shouldBe false
f.hasStemConfig shouldBe false
}
@Test fun `Gen 2 and Gen 3 expose ear detection toggle`() {
val gen2 = PodModel.AIRPODS_GEN2.features
gen2.hasMicrophoneMode shouldBe true
gen2.hasEarDetection shouldBe true
gen2.hasEarDetectionToggle shouldBe true
val gen3 = PodModel.AIRPODS_GEN3.features
gen3.hasMicrophoneMode shouldBe true
gen3.hasEarDetection shouldBe true
gen3.hasEarDetectionToggle shouldBe true
gen3.hasPressSpeed shouldBe true
gen3.hasPressHoldDuration shouldBe true
gen3.hasToneVolume shouldBe true
gen3.hasEndCallMuteMic shouldBe true
}
@Test fun `Max 2 has H2 features`() {
val f = PodModel.AIRPODS_MAX2.features
f.hasAdaptiveAnc shouldBe true
@@ -382,6 +400,18 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasEarDetectionToggle shouldBe true
f.hasAncControl shouldBe true
f.hasEarDetection shouldBe true
f.hasMicrophoneMode shouldBe true
}
@Test fun `Powerbeats Pro and Beats Fit Pro expose ear detection toggle`() {
val powerbeatsPro = PodModel.POWERBEATS_PRO.features
powerbeatsPro.hasEarDetection shouldBe true
powerbeatsPro.hasEarDetectionToggle shouldBe true
val beatsFitPro = PodModel.BEATS_FIT_PRO.features
beatsFitPro.hasEarDetection shouldBe true
beatsFitPro.hasEarDetectionToggle shouldBe true
beatsFitPro.hasMicrophoneMode shouldBe true
}
}
@@ -413,6 +443,14 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
}
}
@Test fun `earDetectionToggle implies earDetection`() {
for (model in PodModel.entries) {
if (model.features.hasEarDetectionToggle) {
model.features.hasEarDetection shouldBe true
}
}
}
@Test fun `adaptiveAudioNoise implies adaptiveAnc`() {
for (model in PodModel.entries) {
if (model.features.hasAdaptiveAudioNoise) {
@@ -24,6 +24,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe true
model shouldBe PodModel.AIRPODS_MAX2
}
@@ -35,6 +36,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
pubStatus shouldBe 0x20.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe false
model shouldBe PodModel.AIRPODS_MAX2
}
@@ -42,6 +44,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
pubStatus shouldBe 0x21.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe false
model shouldBe PodModel.AIRPODS_MAX2
}
@@ -49,6 +52,17 @@ class AirPodsMax2Test : BaseBlePodsTest() {
pubStatus shouldBe 0x23.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe true
model shouldBe PodModel.AIRPODS_MAX2
}
// Synthetic: one earcup off, the other on (issue #548 sees 0x29 in this state on
// both macOS and Android; we don't claim left vs right side semantics here).
create<AirPodsMax2>("07 19 01 2D 20 29 F6 8F 03 14 04 E1 8B 99 98 20 0B C3 C1 12 2D B0 43 98 94 D6 A0") {
pubStatus shouldBe 0x29.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe true
model shouldBe PodModel.AIRPODS_MAX2
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
### Updated by tools/release/bump.sh ###
project.versioning.major=5
project.versioning.minor=1
project.versioning.patch=2
project.versioning.build=1
project.versioning.patch=4
project.versioning.build=0
project.versioning.type=rc
#############################