mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
12
Commits
v5.1.2-rc1
...
v5.1.4-rc0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d541de8c38 | ||
|
|
2821da434d | ||
|
|
07c72dc86c | ||
|
|
7d93e772b4 | ||
|
|
82dc88faac | ||
|
|
06b41ec8eb | ||
|
|
acc6e92b2d | ||
|
|
39922df792 | ||
|
|
5a8c4b94ef | ||
|
|
0ffa87deea | ||
|
|
fc98db09a2 | ||
|
|
e1c0a702dd |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
+21
-22
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
+18
@@ -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
|
||||
|
||||
+40
-2
@@ -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) {
|
||||
|
||||
+14
@@ -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
@@ -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
|
||||
#############################
|
||||
|
||||
Reference in New Issue
Block a user