Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd4d87c06a | ||
|
|
b07428f894 | ||
|
|
dbd1d422c6 | ||
|
|
0b83e86c5e | ||
|
|
b396ce457d | ||
|
|
734e15c94d | ||
|
|
edf84248ab | ||
|
|
83dfdb9219 | ||
|
|
70b9c2981e | ||
|
|
971fcbd34c | ||
|
|
cebed0a60d | ||
|
|
8817da309f | ||
|
|
27cc0e8d00 | ||
|
|
cecd3088be | ||
|
|
412ea369f1 | ||
|
|
22ca3a2a46 | ||
|
|
92feea8a10 |
@@ -189,6 +189,7 @@ dependencies {
|
||||
|
||||
addCompose()
|
||||
addGlance()
|
||||
addWorkerManager()
|
||||
addDataStore()
|
||||
addNavigation3()
|
||||
addSerialization()
|
||||
|
||||
@@ -23,6 +23,7 @@ import eu.darken.capod.common.upgrade.core.billing.Sku
|
||||
import eu.darken.capod.common.upgrade.core.billing.SkuDetails
|
||||
import eu.darken.capod.common.upgrade.core.billing.UserCanceledBillingException
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.redacted
|
||||
import eu.darken.capod.common.upgrade.core.billing.work.PurchaseAckScheduler
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
@@ -58,6 +59,7 @@ class UpgradeRepoGplay @Inject constructor(
|
||||
private val billingManager: BillingManager,
|
||||
private val billingCache: BillingCache,
|
||||
private val curriculumVitae: CurriculumVitae,
|
||||
private val ackScheduler: PurchaseAckScheduler,
|
||||
) : UpgradeRepo {
|
||||
|
||||
override val storeSite: String = STORE_SITE
|
||||
@@ -137,6 +139,7 @@ class UpgradeRepoGplay @Inject constructor(
|
||||
.onEach { failedAt -> recordProUnconfirmed(failedAt) }
|
||||
.setupCommonEventHandlers(TAG) { "connectionFailureRecorder" }
|
||||
.launchIn(scope)
|
||||
|
||||
}
|
||||
|
||||
// Settledness travels WITH the ownership data (Info.isSettled), never on a parallel flow —
|
||||
@@ -255,6 +258,18 @@ class UpgradeRepoGplay @Inject constructor(
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Persistent ack safety net, launch trigger: armed and AWAITED before the Play sheet
|
||||
// can open, so the WorkManager DB transaction lands even if the process dies around
|
||||
// the sheet — the exact window behind Play's unacknowledged-purchase auto-refunds.
|
||||
// Failure to arm never blocks the purchase; the foreground ack path still exists.
|
||||
try {
|
||||
ackScheduler.armForBillingFlowLaunch()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to arm ack safety net for launch: ${e.asLog()}" }
|
||||
}
|
||||
|
||||
// Bounded, like every other Play path (refresh, restore, SKU query, ack). useConnection
|
||||
// waits for a healthy connection indefinitely, so a Play outage between rendering the
|
||||
// offers and this tap would park the launch forever — with launchBusySku still held,
|
||||
|
||||
@@ -16,6 +16,7 @@ import eu.darken.capod.common.upgrade.core.billing.client.BillingConnection
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.BillingConnectionProvider
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.isPurchased
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.redacted
|
||||
import eu.darken.capod.common.upgrade.core.billing.work.PurchaseAckScheduler
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -25,6 +26,8 @@ import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -33,6 +36,7 @@ import javax.inject.Singleton
|
||||
class BillingManager @Inject constructor(
|
||||
@AppScope private val scope: CoroutineScope,
|
||||
connectionProvider: BillingConnectionProvider,
|
||||
private val ackScheduler: PurchaseAckScheduler,
|
||||
) {
|
||||
|
||||
// Fresh Play data plus its provenance: a query result covers owned products of the queried
|
||||
@@ -174,6 +178,11 @@ class BillingManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Serializes acknowledgement work between the reactive ack collector and explicit
|
||||
// ensureAllAcknowledged() sweeps (PurchaseAckWorker): both paths mutate the token bookkeeping
|
||||
// sets and both must never double-drive the same purchase's inline retry sequence.
|
||||
private val ackMutex = Mutex()
|
||||
|
||||
// Re-drives the ack pass WITHOUT a new purchases emission: `purchases` is distinctUntilChanged,
|
||||
// so a refresh returning a byte-identical (still unacknowledged) list is deduped and could never
|
||||
// retry a failed ack -- the pipeline starved until Play sent something different. Declared ahead
|
||||
@@ -256,13 +265,13 @@ class BillingManager @Inject constructor(
|
||||
// below. The immutable Purchase snapshot keeps reporting isAcknowledged=false until a fresh Play
|
||||
// query supersedes it, so the ack re-fires every emission until then; re-acking is a documented
|
||||
// no-op on Play's side, whereas skipping a needed ack gets the purchase auto-refunded after 3
|
||||
// days -- so the ack stays unconditional and this set only quiets the log spam. Single
|
||||
// sequential collector (the ack pass below), no locking needed.
|
||||
// days -- so the ack stays unconditional and this set only quiets the log spam. Confined by
|
||||
// ackMutex (the collector's pass and explicit sweeps both run under it).
|
||||
private val loggedAckTokens = mutableSetOf<String>()
|
||||
|
||||
// Tokens whose PERMANENT ack failure was already reported. Play will keep rejecting these
|
||||
// (developer error, item not owned, unsupported feature), so the bug report fires once per token
|
||||
// instead of once per pass. Same single-collector confinement as loggedAckTokens.
|
||||
// instead of once per pass. Same ackMutex confinement as loggedAckTokens.
|
||||
private val reportedAckFailures = mutableSetOf<String>()
|
||||
|
||||
// At most one reschedule timer in flight: repeated failures must not stack timers.
|
||||
@@ -296,10 +305,13 @@ class BillingManager @Inject constructor(
|
||||
// .isAcknowledged, whose immutable snapshot stays false until a fresh Play query.
|
||||
private enum class AckOutcome { SUCCESS, TRANSIENT, PERMANENT }
|
||||
|
||||
// Aggregate outcome of one ack pass; ensureAllAcknowledged() maps it to a sweep result.
|
||||
data class AckPassOutcome(val transient: Int, val permanent: Int)
|
||||
|
||||
// One acknowledgement pass over the canonical purchase list. Never throws except cancellation:
|
||||
// transient failures schedule a re-drive, permanent ones are reported and left to organic fresh
|
||||
// -data signals.
|
||||
private suspend fun runAckPass(purchases: Collection<Purchase>) {
|
||||
private suspend fun runAckPass(purchases: Collection<Purchase>): AckPassOutcome = ackMutex.withLock {
|
||||
val needAck = purchases.filter {
|
||||
// The canonical list carries pending payments too. Play rejects acknowledging one
|
||||
// PERMANENTLY, so an unfiltered pass would fire a bug report for every pending purchase,
|
||||
@@ -317,7 +329,23 @@ class BillingManager @Inject constructor(
|
||||
needsAck
|
||||
}
|
||||
|
||||
if (needAck.isNotEmpty()) {
|
||||
// Arm the persistent safety net BEFORE attempting anything, and AWAIT the enqueue (the
|
||||
// scheduler bounds it): the inline retries below can span minutes, and a process death
|
||||
// inside them must not strand the purchase until Play's 3-day auto-refund. A deferred
|
||||
// signal (channel + collector) would reintroduce exactly that window. Fail-open: the
|
||||
// net is an extra layer, never a reason to skip the acks themselves.
|
||||
try {
|
||||
ackScheduler.armForUnackedPurchases(needAck.maxOf { it.purchaseTime } + ACK_SAFETY_NET_DEADLINE_MS)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to arm ack safety net: ${e.asLog()}" }
|
||||
}
|
||||
}
|
||||
|
||||
var transientFailures = 0
|
||||
var permanentFailures = 0
|
||||
|
||||
for (purchase in needAck) {
|
||||
// First ack of a token is INFO; idempotent repeats drop to DEBUG. This never gates the
|
||||
@@ -381,6 +409,7 @@ class BillingManager @Inject constructor(
|
||||
}
|
||||
|
||||
if (outcome == AckOutcome.TRANSIENT) transientFailures++
|
||||
if (outcome == AckOutcome.PERMANENT) permanentFailures++
|
||||
if (abortPass) break
|
||||
}
|
||||
|
||||
@@ -391,6 +420,41 @@ class BillingManager @Inject constructor(
|
||||
}
|
||||
scheduleAckRetry()
|
||||
}
|
||||
|
||||
AckPassOutcome(transient = transientFailures, permanent = permanentFailures)
|
||||
}
|
||||
|
||||
// Outcome of an explicit safety-net sweep, see ensureAllAcknowledged().
|
||||
enum class AckSweepResult { COMPLETE, RETRY, PERMANENT_FAILURE }
|
||||
|
||||
/**
|
||||
* One self-contained acknowledgement sweep for the persistent safety net (PurchaseAckWorker):
|
||||
* refresh from Play, then acknowledge everything unacknowledged IN THIS COROUTINE. The reactive
|
||||
* ack collector consumes purchase state asynchronously, so a caller that needs proof the acks
|
||||
* actually happened before it reports success (a worker deciding success vs retry) cannot rely
|
||||
* on it. Never throws except cancellation.
|
||||
*/
|
||||
suspend fun ensureAllAcknowledged(): AckSweepResult {
|
||||
log(TAG) { "ensureAllAcknowledged()" }
|
||||
val fresh = try {
|
||||
useConnection { refreshPurchases() }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "ensureAllAcknowledged(): refresh failed: ${e.asLog()}" }
|
||||
return AckSweepResult.RETRY
|
||||
}
|
||||
// Same bookkeeping every other refresh exit owes: grace episode clock + dead-binder teardown.
|
||||
processReconciliation(fresh)
|
||||
val outcome = runAckPass(fresh.purchases)
|
||||
return when {
|
||||
// An incomplete refresh may be hiding an unacknowledged purchase of the failed type,
|
||||
// and a transient ack failure is retriable by definition.
|
||||
outcome.transient > 0 || !fresh.isComplete -> AckSweepResult.RETRY
|
||||
// Play will keep rejecting these no matter how often the worker comes back.
|
||||
outcome.permanent > 0 -> AckSweepResult.PERMANENT_FAILURE
|
||||
else -> AckSweepResult.COMPLETE
|
||||
}
|
||||
}
|
||||
|
||||
// A purchase Play will keep rejecting: report it once per token, then stay quiet. The pass still
|
||||
@@ -586,6 +650,10 @@ class BillingManager @Inject constructor(
|
||||
BillingResponseCode.ITEM_NOT_OWNED,
|
||||
)
|
||||
|
||||
// Play auto-refunds purchases not acknowledged within 3 days; every safety-net deadline
|
||||
// derives from this.
|
||||
const val ACK_SAFETY_NET_DEADLINE_MS = 3 * 24 * 60 * 60 * 1000L
|
||||
|
||||
private const val INITIAL_REFRESH_TIMEOUT_MS = 30_000L
|
||||
private const val MAX_BACKOFF_MS = 300_000L
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package eu.darken.capod.common.upgrade.core.billing.work
|
||||
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.await
|
||||
import androidx.work.workDataOf
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
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.upgrade.core.billing.BillingManager
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Arms the [PurchaseAckWorker] safety net. Two triggers:
|
||||
* - a billing flow is about to launch (armed and awaited BEFORE the Play sheet, so the WorkManager
|
||||
* DB transaction lands even if the process dies around the sheet),
|
||||
* - an ack pass discovered unacknowledged purchases (called directly, pre-attempt, from
|
||||
* BillingManager's runAckPass).
|
||||
*/
|
||||
@Singleton
|
||||
class PurchaseAckScheduler @Inject constructor(
|
||||
// Resolved on the first arm, not at construction: fleet App classes eagerly inject the billing
|
||||
// stack during Application field injection, and resolving WorkManager there can trigger its
|
||||
// on-demand initialization before the Application's worker factory field is set.
|
||||
private val workManager: Provider<WorkManager>,
|
||||
) {
|
||||
|
||||
// A genuinely new flow refreshes the watch window: REPLACE the previous LAUNCH watch. The
|
||||
// worker sweeps ALL unacknowledged purchases, so replacing an older watch loses nothing — and a
|
||||
// pending rescue for an already-discovered purchase has its own identity, so starting another
|
||||
// purchase can never displace it. The long delay keeps the worker out of the window where the
|
||||
// user may still be in the Play sheet.
|
||||
suspend fun armForBillingFlowLaunch() = arm(
|
||||
name = WORK_NAME_LAUNCH,
|
||||
policy = ExistingWorkPolicy.REPLACE,
|
||||
expiresAt = System.currentTimeMillis() + BillingManager.ACK_SAFETY_NET_DEADLINE_MS,
|
||||
initialDelayMs = LAUNCH_DELAY_MS,
|
||||
)
|
||||
|
||||
// Any pending rescue already covers every unacknowledged purchase: KEEP it. Once completed
|
||||
// work exists, KEEP inserts a fresh request. Short delay — the purchase already EXISTS (unlike
|
||||
// the launch trigger), possibly for days, so waiting 30min could waste real deadline time.
|
||||
// Accepted edge of KEEP, within the rescue lane only: a pending request keeps its original
|
||||
// (possibly earlier) expiry; a newer purchase with a later deadline is only re-covered once a
|
||||
// later pass re-arms after the old work completed. Bounded residual, only reachable via
|
||||
// out-of-band purchases.
|
||||
suspend fun armForUnackedPurchases(expiresAt: Long) = arm(
|
||||
name = WORK_NAME_RESCUE,
|
||||
policy = ExistingWorkPolicy.KEEP,
|
||||
expiresAt = expiresAt,
|
||||
initialDelayMs = DISCOVERY_DELAY_MS,
|
||||
)
|
||||
|
||||
private suspend fun arm(
|
||||
name: String,
|
||||
policy: ExistingWorkPolicy,
|
||||
expiresAt: Long,
|
||||
initialDelayMs: Long,
|
||||
) {
|
||||
if (expiresAt <= System.currentTimeMillis()) {
|
||||
// Play has already voided (or is about to void) such a purchase; a sweep can't help.
|
||||
log(TAG, WARN) { "arm($policy): deadline $expiresAt already passed, not scheduling" }
|
||||
return
|
||||
}
|
||||
val request = OneTimeWorkRequestBuilder<PurchaseAckWorker>().apply {
|
||||
setConstraints(
|
||||
Constraints.Builder().apply {
|
||||
setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
}.build()
|
||||
)
|
||||
// Launch trigger: the worker must not run while the user may still be in the Play
|
||||
// sheet — an immediate sweep would find nothing unacknowledged, report success, and
|
||||
// complete the net before the purchase it exists for even happened.
|
||||
setInitialDelay(initialDelayMs, TimeUnit.MILLISECONDS)
|
||||
setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_DELAY_MS, TimeUnit.MILLISECONDS)
|
||||
setInputData(workDataOf(PurchaseAckWorker.KEY_EXPIRES_AT to expiresAt))
|
||||
}.build()
|
||||
|
||||
// Await the enqueue: the caller arms this because the process may die at any moment — a
|
||||
// fire-and-forget enqueue could be lost with it. Cancellable and BOUNDED: every caller
|
||||
// needs a durable enqueue without an unbounded stall — a WorkManager that never settles
|
||||
// must become an exception (handled fail-open by every caller) instead of a hang (which on
|
||||
// the launch lane would park the purchase and its busy guard forever).
|
||||
val operation = workManager.get().enqueueUniqueWork(name, policy, request)
|
||||
withTimeoutOrNull(ENQUEUE_TIMEOUT_MS) { operation.await() }
|
||||
?: throw IllegalStateException("WorkManager enqueue did not settle within ${ENQUEUE_TIMEOUT_MS}ms")
|
||||
log(TAG) { "arm($policy): safety net armed, expiresAt=$expiresAt" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
// WorkManager persists these names AND the worker's class name in its DB across app
|
||||
// updates: keep all of them stable while old work may exist (hence the version suffix for
|
||||
// future changes). Separate identities per trigger: the launch watch's REPLACE must not be
|
||||
// able to displace a pending rescue for a purchase that already exists.
|
||||
private val WORK_NAME_LAUNCH = "${BuildConfigWrap.APPLICATION_ID}.gplay.purchase-ack.launch.v1"
|
||||
private val WORK_NAME_RESCUE = "${BuildConfigWrap.APPLICATION_ID}.gplay.purchase-ack.rescue.v1"
|
||||
|
||||
private const val LAUNCH_DELAY_MS = 30 * 60 * 1000L
|
||||
private const val DISCOVERY_DELAY_MS = 60 * 1000L
|
||||
private const val BACKOFF_DELAY_MS = 30 * 60 * 1000L
|
||||
private const val ENQUEUE_TIMEOUT_MS = 10 * 1000L
|
||||
|
||||
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "AckScheduler")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package eu.darken.capod.common.upgrade.core.billing.work
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ListenableWorker.Result
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.core.billing.BillingManager
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Persistent acknowledgement safety net, armed by [PurchaseAckScheduler].
|
||||
*
|
||||
* Play auto-refunds (and revokes) any purchase not acknowledged within 3 days. The in-process ack
|
||||
* machinery in [BillingManager] handles every case where the process lives long enough — this
|
||||
* worker covers the case it can't: the process dies around the Play purchase sheet (OEM task
|
||||
* killers) and the user doesn't reopen the app before the deadline. Play voids such purchases and
|
||||
* revokes the entitlement, so the user loses what they signed up for.
|
||||
*
|
||||
* Self-completing by design: nothing cancels this work from the foreground ack path (an ack pass
|
||||
* can legitimately see zero unacknowledged purchases while the Play sheet is still open, which
|
||||
* must not tear down the net). The redundant sweep after a successful foreground ack is one
|
||||
* purchase query.
|
||||
*/
|
||||
@HiltWorker
|
||||
class PurchaseAckWorker @AssistedInject constructor(
|
||||
@Assisted private val context: Context,
|
||||
@Assisted private val params: WorkerParameters,
|
||||
private val billingManager: BillingManager,
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val expiresAt = inputData.getLong(KEY_EXPIRES_AT, 0L)
|
||||
log(TAG) { "doWork(): attempt=$runAttemptCount, expiresAt=$expiresAt" }
|
||||
|
||||
if (!isWorthSweeping(System.currentTimeMillis(), expiresAt)) {
|
||||
// Past Play's refund deadline (or malformed input): retrying can't achieve anything.
|
||||
// failure() is deliberate over success() — it is visible in WorkManager diagnostics,
|
||||
// and a completed state lets a later KEEP enqueue insert fresh work.
|
||||
log(TAG, WARN) { "doWork(): deadline passed, giving up" }
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
// Bounded well below WorkManager's 10-minute execution limit, but generous enough for the
|
||||
// connection wait plus the per-purchase inline retries. A sweep that ran out of time is a
|
||||
// transient outcome, not a verdict. External cancellation propagates out of doWork — it
|
||||
// must never be converted into success.
|
||||
val sweep = withTimeoutOrNull(SWEEP_TIMEOUT_MS) {
|
||||
billingManager.ensureAllAcknowledged()
|
||||
}
|
||||
log(TAG, INFO) { "doWork(): sweep=$sweep" }
|
||||
|
||||
return mapSweep(sweep, System.currentTimeMillis(), expiresAt)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Persisted in WorkManager's request data — keep the key stable while old work may exist.
|
||||
const val KEY_EXPIRES_AT = "purchase.ack.expiresAt"
|
||||
|
||||
private const val SWEEP_TIMEOUT_MS = 4 * 60 * 1000L
|
||||
|
||||
// Pure so the retry/expiry decision is unit-testable without a WorkManager test harness.
|
||||
internal fun isWorthSweeping(now: Long, expiresAt: Long): Boolean =
|
||||
expiresAt > 0L && now < expiresAt
|
||||
|
||||
internal fun mapSweep(
|
||||
sweep: BillingManager.AckSweepResult?,
|
||||
now: Long,
|
||||
expiresAt: Long,
|
||||
): Result = when (sweep) {
|
||||
BillingManager.AckSweepResult.COMPLETE -> Result.success()
|
||||
BillingManager.AckSweepResult.PERMANENT_FAILURE -> Result.failure()
|
||||
// RETRY or timeout (null): keep trying until the deadline. WorkManager's exponential
|
||||
// backoff caps at 5h, so the 3-day window still yields many attempts.
|
||||
else -> if (now < expiresAt) Result.retry() else Result.failure()
|
||||
}
|
||||
|
||||
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "AckWorker")
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,19 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
|
||||
</provider>
|
||||
|
||||
<!-- Debug stuff-->
|
||||
<activity
|
||||
android:name=".common.debug.recording.ui.RecorderActivity"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package eu.darken.capod
|
||||
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
|
||||
import eu.darken.capod.common.debug.logging.LogCatLogger
|
||||
@@ -34,8 +37,9 @@ import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
open class App : Application() {
|
||||
open class App : Application(), Configuration.Provider {
|
||||
|
||||
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||
@Inject lateinit var autoReporting: AutomaticBugReporter
|
||||
@Inject lateinit var deviceMonitor: DeviceMonitor
|
||||
@Inject lateinit var widgetManager: WidgetManager
|
||||
@@ -96,6 +100,22 @@ open class App : Application() {
|
||||
.launchIn(appScope)
|
||||
}
|
||||
|
||||
// WorkManager 2.7.1 (see Dependencies.addWorkerManager) still declares Configuration.Provider
|
||||
// as getWorkManagerConfiguration(); the `workManagerConfiguration` property form only exists
|
||||
// from 2.9.0 onwards.
|
||||
override fun getWorkManagerConfiguration(): Configuration = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(
|
||||
when {
|
||||
BuildConfigWrap.DEBUG -> android.util.Log.VERBOSE
|
||||
BuildConfigWrap.BUILD_TYPE == BuildConfigWrap.BuildType.DEV -> android.util.Log.DEBUG
|
||||
BuildConfigWrap.BUILD_TYPE == BuildConfigWrap.BuildType.BETA -> android.util.Log.INFO
|
||||
BuildConfigWrap.BUILD_TYPE == BuildConfigWrap.BuildType.RELEASE -> android.util.Log.WARN
|
||||
else -> android.util.Log.VERBOSE
|
||||
}
|
||||
)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
internal val TAG = logTag("CAP")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioPlaybackConfiguration
|
||||
import android.os.Build
|
||||
@@ -25,6 +27,7 @@ class MediaControl @Inject constructor(
|
||||
private val audioManager: AudioManager,
|
||||
private val timeSource: TimeSource,
|
||||
@AudioCallbackHandler private val audioCallbackHandler: Handler,
|
||||
private val duckFocusRequestFactory: DuckFocusRequestFactory,
|
||||
) {
|
||||
/**
|
||||
* Set when [sendPause] dispatches a pause we expect to take effect, cleared when [sendPlay]
|
||||
@@ -47,6 +50,11 @@ class MediaControl @Inject constructor(
|
||||
*/
|
||||
private val dispatchLock = Mutex()
|
||||
|
||||
/** The granted ducking focus request, or `null` when we don't hold focus. Guarded by `this`. */
|
||||
private var duckFocusRequest: AudioFocusRequest? = null
|
||||
|
||||
private val duckFocusListener = AudioManager.OnAudioFocusChangeListener { change -> onDuckFocusChanged(change) }
|
||||
|
||||
private val playbackCallback = object : AudioManager.AudioPlaybackCallback() {
|
||||
override fun onPlaybackConfigChanged(configs: List<AudioPlaybackConfiguration>) {
|
||||
// The edge is derived from this delivery's own snapshot, not a live isMusicActive read:
|
||||
@@ -263,23 +271,25 @@ class MediaControl @Inject constructor(
|
||||
fun currentMusicVolume(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
|
||||
/**
|
||||
* Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and returns
|
||||
* the prior + the volume actually applied, so the caller can later restore it and detect whether
|
||||
* the user changed the volume in the meantime.
|
||||
* Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and classifies
|
||||
* what actually happened, so the caller can restore the prior level later, fall back to audio
|
||||
* focus, or do nothing at all.
|
||||
*
|
||||
* Returns `null` (no-op) when nothing is playing, the device has fixed volume, or the computed
|
||||
* target wouldn't actually lower the volume. No [AudioManager.FLAG_SHOW_UI] — this fires on a
|
||||
* frequent push event and the volume panel flashing would be noisy. The applied target is read
|
||||
* back from the system because Bluetooth absolute-volume routes can quantize the requested value.
|
||||
* Returns [DuckOutcome.Skipped] when nothing is playing, the device has fixed volume, the computed
|
||||
* target wouldn't actually lower the volume, the level came back higher, or the write was denied.
|
||||
* Returns [DuckOutcome.Unchanged] when the write was accepted but the level did not drop. No
|
||||
* [AudioManager.FLAG_SHOW_UI] — this fires on a frequent push event and the volume panel
|
||||
* flashing would be noisy. The applied target is read back from the system because Bluetooth
|
||||
* absolute-volume routes can quantize the requested value.
|
||||
*/
|
||||
fun duckMusicVolume(reductionPercent: Int): VolumeDuck? {
|
||||
fun duckMusicVolume(reductionPercent: Int): DuckOutcome {
|
||||
if (!audioManager.isMusicActive) {
|
||||
log(TAG, INFO) { "duckMusicVolume: nothing playing, skipping" }
|
||||
return null
|
||||
return DuckOutcome.Skipped
|
||||
}
|
||||
if (audioManager.isVolumeFixed) {
|
||||
log(TAG, INFO) { "duckMusicVolume: device has fixed volume, skipping" }
|
||||
return null
|
||||
return DuckOutcome.Skipped
|
||||
}
|
||||
val percent = reductionPercent.coerceIn(0, 100)
|
||||
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
@@ -292,17 +302,45 @@ class MediaControl @Inject constructor(
|
||||
val target = (prior * (100 - percent) / 100).coerceIn(min, max)
|
||||
if (target >= prior) {
|
||||
log(TAG, INFO) { "duckMusicVolume: target $target >= current $prior, skipping" }
|
||||
return null
|
||||
return DuckOutcome.Skipped
|
||||
}
|
||||
return try {
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
|
||||
val applied = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" }
|
||||
VolumeDuck(priorVolume = prior, appliedVolume = applied)
|
||||
when {
|
||||
applied > prior -> {
|
||||
// The level came back HIGHER than we found it: the user raised the volume between
|
||||
// the two reads. That is not the ROM refusing the write, so it must not read as
|
||||
// one — a fallback here would fire on a device whose volume writes work fine.
|
||||
log(TAG, WARN) {
|
||||
"duckMusicVolume($percent%): volume increased, $prior -> $applied " +
|
||||
"(requested $target, min=$min, max=$max)"
|
||||
}
|
||||
DuckOutcome.Skipped
|
||||
}
|
||||
|
||||
applied == prior -> {
|
||||
// No attenuation happened even though the write was accepted: ColorOS 16 does
|
||||
// this while the app is in the background (no exception, volume untouched), and
|
||||
// a route may quantize the target back up to where it started. Reporting a duck
|
||||
// would have the caller track a session that never attenuated anything, and
|
||||
// later "restore" a level it never left.
|
||||
log(TAG, WARN) {
|
||||
"duckMusicVolume($percent%): volume did not decrease, $prior -> $applied " +
|
||||
"(requested $target, min=$min, max=$max)"
|
||||
}
|
||||
DuckOutcome.Unchanged(priorVolume = prior)
|
||||
}
|
||||
|
||||
else -> {
|
||||
log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" }
|
||||
DuckOutcome.Ducked(priorVolume = prior, appliedVolume = applied)
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
// setStreamVolume throws under Do-Not-Disturb without notification policy access.
|
||||
log(TAG, WARN) { "duckMusicVolume: setStreamVolume denied: ${e.message}" }
|
||||
null
|
||||
DuckOutcome.Skipped
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,11 +355,73 @@ class MediaControl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of a volume duck so the caller can restore the prior level and detect user changes. */
|
||||
data class VolumeDuck(
|
||||
val priorVolume: Int,
|
||||
val appliedVolume: Int,
|
||||
)
|
||||
/**
|
||||
* Requests transient ducking audio focus so the framework attenuates the other player for us.
|
||||
* Fallback for devices where the volume write is accepted but ignored ([DuckOutcome.Unchanged]).
|
||||
*
|
||||
* Idempotent — returns `true` when focus is held, whether this call obtained it or an earlier one
|
||||
* did. A grant only describes the instant of the request, so the held state is dropped again when
|
||||
* the system takes focus away permanently.
|
||||
*/
|
||||
@Synchronized
|
||||
fun requestDuckFocus(): Boolean {
|
||||
if (duckFocusRequest != null) {
|
||||
log(TAG, INFO) { "requestDuckFocus(): already held" }
|
||||
return true
|
||||
}
|
||||
val request = duckFocusRequestFactory.create(duckFocusListener)
|
||||
val result = audioManager.requestAudioFocus(request)
|
||||
return if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
duckFocusRequest = request
|
||||
log(TAG, INFO) { "requestDuckFocus(): granted" }
|
||||
true
|
||||
} else {
|
||||
log(TAG, INFO) { "requestDuckFocus(): denied ($result)" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Releases the ducking focus taken by [requestDuckFocus]. No-op when we don't hold it. */
|
||||
@Synchronized
|
||||
fun abandonDuckFocus() {
|
||||
val request = duckFocusRequest ?: return
|
||||
duckFocusRequest = null
|
||||
audioManager.abandonAudioFocusRequest(request)
|
||||
log(TAG, INFO) { "abandonDuckFocus(): focus released" }
|
||||
}
|
||||
|
||||
val isDuckFocusHeld: Boolean
|
||||
@Synchronized get() = duckFocusRequest != null
|
||||
|
||||
@Synchronized
|
||||
private fun onDuckFocusChanged(change: Int) {
|
||||
// Only a PERMANENT loss ends our request. AUDIOFOCUS_LOSS_TRANSIENT leaves it in place, so
|
||||
// clearing on that would forget a request the system still tracks and let the next
|
||||
// abandon/re-request pair fight the other app over a temporary interruption.
|
||||
if (change != AudioManager.AUDIOFOCUS_LOSS) return
|
||||
log(TAG, INFO) { "Duck focus permanently lost" }
|
||||
duckFocusRequest = null
|
||||
}
|
||||
|
||||
/** What a [duckMusicVolume] call actually achieved. */
|
||||
sealed interface DuckOutcome {
|
||||
/** The level dropped: [priorVolume] is what to restore, [appliedVolume] what landed. */
|
||||
data class Ducked(val priorVolume: Int, val appliedVolume: Int) : DuckOutcome
|
||||
|
||||
/** The write was accepted but the level did not move — the read-back proves no attenuation. */
|
||||
data class Unchanged(val priorVolume: Int) : DuckOutcome
|
||||
|
||||
/** Nothing was attempted, or the result is nothing for the caller to act on. */
|
||||
data object Skipped : DuckOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam for building the ducking focus request: [AudioFocusRequest.Builder] and
|
||||
* [AudioAttributes.Builder] are unmocked stubs that throw in this module's plain JVM unit tests.
|
||||
*/
|
||||
fun interface DuckFocusRequestFactory {
|
||||
fun create(listener: AudioManager.OnAudioFocusChangeListener): AudioFocusRequest
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("MediaControl")
|
||||
|
||||
@@ -79,11 +79,22 @@ class BleScanner @Inject constructor(
|
||||
}
|
||||
|
||||
val callback = object : ScanCallback() {
|
||||
var lastScanAt = timeSource.currentTimeMillis()
|
||||
// Updated outside the log lambdas below: those only run while a logger is attached, so
|
||||
// folding the bookkeeping into them made the first delay of a debug recording measure
|
||||
// the time since the *previous* recording ended instead of the actual callback gap.
|
||||
// Monotonic clock, so a wall-clock correction can't fabricate a gap either.
|
||||
var lastScanAt = timeSource.elapsedRealtime()
|
||||
|
||||
private fun takeDelay(): Long {
|
||||
val now = timeSource.elapsedRealtime()
|
||||
val delay = now - lastScanAt
|
||||
lastScanAt = now
|
||||
return delay
|
||||
}
|
||||
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val delay = takeDelay()
|
||||
log(TAG, VERBOSE) {
|
||||
val delay = timeSource.currentTimeMillis() - lastScanAt
|
||||
lastScanAt = timeSource.currentTimeMillis()
|
||||
"onScanResult(delay=${delay}ms, callbackType=$callbackType, ${result.logSummary()})"
|
||||
}
|
||||
|
||||
@@ -91,11 +102,8 @@ class BleScanner @Inject constructor(
|
||||
}
|
||||
|
||||
override fun onBatchScanResults(results: MutableList<ScanResult>) {
|
||||
log(TAG, VERBOSE) {
|
||||
val delay = timeSource.currentTimeMillis() - lastScanAt
|
||||
lastScanAt = timeSource.currentTimeMillis()
|
||||
"onBatchScanResults(delay=${delay}ms, ${results.logSummary()})"
|
||||
}
|
||||
val delay = takeDelay()
|
||||
log(TAG, VERBOSE) { "onBatchScanResults(delay=${delay}ms, ${results.logSummary()})" }
|
||||
|
||||
trySend(filterResults(results))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.app.Application
|
||||
import android.app.NotificationManager
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
@@ -11,6 +13,7 @@ import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import eu.darken.capod.common.MediaControl
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -43,6 +46,21 @@ class AndroidModule {
|
||||
fun audioCallbackHandler(): Handler =
|
||||
Handler(HandlerThread("CAPod-MediaControl").apply { start() }.looper)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun duckFocusRequestFactory(): MediaControl.DuckFocusRequestFactory =
|
||||
MediaControl.DuckFocusRequestFactory { listener ->
|
||||
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
)
|
||||
.setOnAudioFocusChangeListener(listener)
|
||||
.build()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Qualifier
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package eu.darken.capod.common.worker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.WorkManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
class WorkManagerModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun workManager(context: Context): WorkManager = WorkManager.getInstance(context)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.AutoAwesome
|
||||
import androidx.compose.material.icons.twotone.DoNotDisturbOn
|
||||
import androidx.compose.material.icons.twotone.Headphones
|
||||
import androidx.compose.material.icons.twotone.Hearing
|
||||
import androidx.compose.material.icons.twotone.NoiseAware
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
@@ -25,7 +25,9 @@ fun AapSetting.AncMode.Value.shortLabel(context: Context): String = context.getS
|
||||
fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) {
|
||||
AapSetting.AncMode.Value.OFF -> Icons.TwoTone.DoNotDisturbOn
|
||||
AapSetting.AncMode.Value.ON -> Icons.TwoTone.Headphones
|
||||
AapSetting.AncMode.Value.TRANSPARENCY -> Icons.TwoTone.Hearing
|
||||
// Not Hearing: that glyph is already the "In ear" status chip, so the two looked identical
|
||||
// sitting in the same card. NoiseAware is the purpose-built ambient/transparency icon.
|
||||
AapSetting.AncMode.Value.TRANSPARENCY -> Icons.TwoTone.NoiseAware
|
||||
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.CustomEqDebugCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
|
||||
@@ -90,6 +91,7 @@ fun DeviceSettingsScreenHost(
|
||||
var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
|
||||
val ancNotConfirmedMessage = stringResource(R.string.anc_mode_not_confirmed_message)
|
||||
val chargeCapRejectedMessage = stringResource(R.string.device_settings_charge_cap_rejected_message)
|
||||
val pendingInfoMessage = stringResource(R.string.device_settings_pending_info)
|
||||
|
||||
@@ -125,6 +127,10 @@ fun DeviceSettingsScreenHost(
|
||||
snackbarHostState.showSnackbar(offRejectedMessage)
|
||||
}
|
||||
|
||||
DeviceSettingsViewModel.Event.AncModeNotConfirmedByDevice -> {
|
||||
snackbarHostState.showSnackbar(ancNotConfirmedMessage)
|
||||
}
|
||||
|
||||
DeviceSettingsViewModel.Event.DynamicEndOfChargeRejectedByDevice -> {
|
||||
snackbarHostState.showSnackbar(chargeCapRejectedMessage)
|
||||
}
|
||||
@@ -185,6 +191,7 @@ fun DeviceSettingsScreenHost(
|
||||
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
|
||||
onBatteryEstimateEnabledChange = { vm.setBatteryEstimateEnabled(it) },
|
||||
onResetBatteryEstimate = { vm.resetBatteryEstimate() },
|
||||
onCustomEqApply = { mode, low, mid, high -> vm.setCustomEq(mode, low, mid, high) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -230,6 +237,7 @@ fun DeviceSettingsScreen(
|
||||
onOpenAapTracker: () -> Unit = {},
|
||||
onBatteryEstimateEnabledChange: (Boolean) -> Unit = {},
|
||||
onResetBatteryEstimate: () -> Unit = {},
|
||||
onCustomEqApply: (AapSetting.CustomEq.Mode, Int, Int, Int) -> Unit = { _, _, _, _ -> },
|
||||
) {
|
||||
val device = state.device
|
||||
val features = device?.model?.features
|
||||
@@ -513,6 +521,20 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom EQ evaluation control (debug only, opcode 0x63). The 0x63 wire format
|
||||
// has never been confirmed on hardware, so this exists to find out whether a real
|
||||
// device accepts it. Deliberately ungated by capability or model — gating on an
|
||||
// unknown capability bit would defeat the test.
|
||||
if (eu.darken.capod.BuildConfig.DEBUG) {
|
||||
item("custom_eq_debug_section") {
|
||||
CustomEqDebugCard(
|
||||
device = device,
|
||||
enabled = enabled,
|
||||
onApply = onCustomEqApply,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced settings unavailable — phone's Bluetooth lacks AAP support; passive info, shown last
|
||||
|
||||
@@ -90,6 +90,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
data class SendFailed(val command: AapCommand, val message: String?) : Event
|
||||
data object SystemRenameUnavailable : Event
|
||||
data object OffModeRejectedByDevice : Event
|
||||
data object AncModeNotConfirmedByDevice : Event
|
||||
data object DynamicEndOfChargeRejectedByDevice : Event
|
||||
}
|
||||
|
||||
@@ -109,7 +110,11 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
when (command) {
|
||||
is AapCommand.SetDynamicEndOfCharge ->
|
||||
events.tryEmit(Event.DynamicEndOfChargeRejectedByDevice)
|
||||
else -> Unit // Other rejected commands handled elsewhere (e.g. ANC OFF)
|
||||
// OFF has its own, more specific message via offRejectedEvents.
|
||||
is AapCommand.SetAncMode -> if (command.mode != AapSetting.AncMode.Value.OFF) {
|
||||
events.tryEmit(Event.AncModeNotConfirmedByDevice)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,6 +347,14 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
|
||||
fun setDynamicEndOfCharge(enabled: Boolean) = send(AapCommand.SetDynamicEndOfCharge(enabled))
|
||||
|
||||
/**
|
||||
* Debug-only evaluation control (see `CustomEqDebugCard`). One tap sends exactly one packet
|
||||
* carrying the complete tuple — the 0x63 format is unconfirmed and the point is to observe
|
||||
* how a real device answers a single write.
|
||||
*/
|
||||
fun setCustomEq(mode: AapSetting.CustomEq.Mode, low: Int, mid: Int, high: Int) =
|
||||
send(AapCommand.SetCustomEq(mode, low, mid, high))
|
||||
|
||||
fun setDeviceName(name: String) = launch {
|
||||
val address = currentAddress() ?: return@launch
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package eu.darken.capod.main.ui.devicesettings.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.GraphicEq
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.settings.SettingsSection
|
||||
import eu.darken.capod.common.settings.SettingsSliderItem
|
||||
import eu.darken.capod.main.ui.devicesettings.components.SegmentedSettingRow
|
||||
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
/**
|
||||
* Debug-only evaluation control for Custom EQ (opcode 0x63), whose wire format has never been
|
||||
* confirmed on hardware. It is a measurement instrument, not a shipped feature: the sliders and
|
||||
* the mode toggle edit local draft state only, and exactly one packet leaves the device per
|
||||
* Apply tap, so the logcat observation window stays readable.
|
||||
*
|
||||
* Every label below is hardcoded English on purpose. This card is throwaway instrumentation that
|
||||
* only renders under [eu.darken.capod.BuildConfig.DEBUG]; routing its labels through the base
|
||||
* locale would push a dozen strings to Crowdin and have translators work through them for every
|
||||
* locale, for text no release build can ever show.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CustomEqDebugCard(
|
||||
device: PodDevice,
|
||||
enabled: Boolean,
|
||||
onApply: (AapSetting.CustomEq.Mode, Int, Int, Int) -> Unit = { _, _, _, _ -> },
|
||||
) {
|
||||
val reported = device.customEq
|
||||
|
||||
// AapOutboundController ear-gates every command except SetDeviceName and SetDynamicEndOfCharge:
|
||||
// with no pod in ear the write is queued and only flushed on the next in-ear event, so the
|
||||
// packet would surface minutes later at an unrelated moment and poison the logcat window this
|
||||
// card exists to produce.
|
||||
//
|
||||
// The controller reads the AAP EarDetection setting alone and does not gate while that setting
|
||||
// is absent, so this mirror has to gate on the same source: hasAapEarDetection makes
|
||||
// isEitherPodInEar return the AAP value without ever falling back to the BLE ear bits, which
|
||||
// phantom-report "in ear" for pods resting in the case.
|
||||
val wouldQueue = device.hasAapEarDetection && device.isEitherPodInEar != true
|
||||
|
||||
var draftMode by remember(reported) {
|
||||
mutableStateOf(reported?.mode ?: AapSetting.CustomEq.Mode.RECOMMENDED)
|
||||
}
|
||||
var draftLow by remember(reported) { mutableIntStateOf(reported?.low ?: NEUTRAL_BAND) }
|
||||
var draftMid by remember(reported) { mutableIntStateOf(reported?.mid ?: NEUTRAL_BAND) }
|
||||
var draftHigh by remember(reported) { mutableIntStateOf(reported?.high ?: NEUTRAL_BAND) }
|
||||
|
||||
SettingsSection(title = "Custom EQ") {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
text = "Reported by device",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = if (reported != null) {
|
||||
bandsText(reported.mode, reported.low, reported.mid, reported.high)
|
||||
} else {
|
||||
"Not reported"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
text = "Draft to send",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = bandsText(draftMode, draftLow, draftMid, draftHigh),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
SegmentedSettingRow(
|
||||
icon = Icons.TwoTone.GraphicEq,
|
||||
title = "Mode",
|
||||
options = AapSetting.CustomEq.Mode.entries.map { it.label to it },
|
||||
selected = draftMode,
|
||||
onSelected = { draftMode = it },
|
||||
enabled = enabled,
|
||||
)
|
||||
|
||||
BandSlider(
|
||||
title = "Low",
|
||||
value = draftLow,
|
||||
onValueChange = { draftLow = it },
|
||||
enabled = enabled,
|
||||
)
|
||||
BandSlider(
|
||||
title = "Mid",
|
||||
value = draftMid,
|
||||
onValueChange = { draftMid = it },
|
||||
enabled = enabled,
|
||||
)
|
||||
BandSlider(
|
||||
title = "High",
|
||||
value = draftHigh,
|
||||
onValueChange = { draftHigh = it },
|
||||
enabled = enabled,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (wouldQueue) {
|
||||
Text(
|
||||
text = NOT_IN_EAR_NOTICE,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 12.dp),
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { onApply(draftMode, draftLow, draftMid, draftHigh) },
|
||||
enabled = enabled && !wouldQueue,
|
||||
) {
|
||||
Text("Apply")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BandSlider(
|
||||
title: String,
|
||||
value: Int,
|
||||
onValueChange: (Int) -> Unit,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
SettingsSliderItem(
|
||||
icon = Icons.TwoTone.GraphicEq,
|
||||
title = title,
|
||||
value = value.toFloat(),
|
||||
// Draft only — dispatching from here (or from onValueChangeFinished) would flood the link
|
||||
// with intermediate tuples. The Apply button is the sole sender.
|
||||
onValueChange = { onValueChange(it.toInt()) },
|
||||
valueRange = 0f..100f,
|
||||
steps = 99,
|
||||
enabled = enabled,
|
||||
valueLabel = { it.toInt().toString() },
|
||||
)
|
||||
}
|
||||
|
||||
private const val NEUTRAL_BAND = 50
|
||||
|
||||
internal const val NOT_IN_EAR_NOTICE =
|
||||
"No pod in ear, a write would be queued instead of sent. Wear a pod before applying."
|
||||
|
||||
private fun bandsText(mode: AapSetting.CustomEq.Mode, low: Int, mid: Int, high: Int): String =
|
||||
"${mode.label} · low $low / mid $mid / high $high"
|
||||
|
||||
private val AapSetting.CustomEq.Mode.label: String
|
||||
get() = when (this) {
|
||||
AapSetting.CustomEq.Mode.RECOMMENDED -> "Recommended"
|
||||
AapSetting.CustomEq.Mode.CUSTOM -> "Custom"
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun CustomEqDebugCardNotReportedPreview() = PreviewWrapper {
|
||||
CustomEqDebugCard(
|
||||
device = previewFullState(isPro = true).device!!,
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun CustomEqDebugCardReportedPreview() = PreviewWrapper {
|
||||
val device = previewFullState(isPro = true).device!!
|
||||
CustomEqDebugCard(
|
||||
device = device.copy(
|
||||
aap = device.aap!!.withSetting(
|
||||
AapSetting.CustomEq::class,
|
||||
AapSetting.CustomEq(
|
||||
mode = AapSetting.CustomEq.Mode.CUSTOM,
|
||||
low = 60,
|
||||
mid = 50,
|
||||
high = 35,
|
||||
),
|
||||
),
|
||||
),
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
@@ -96,6 +96,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
|
||||
val ancNotConfirmedMessage = stringResource(R.string.anc_mode_not_confirmed_message)
|
||||
|
||||
// Collect workerAutolaunch passively to keep it active
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -108,6 +109,10 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
OverviewViewModel.Event.OffModeRejectedByDevice -> {
|
||||
snackbarHostState.showSnackbar(offRejectedMessage)
|
||||
}
|
||||
|
||||
OverviewViewModel.Event.AncModeNotConfirmedByDevice -> {
|
||||
snackbarHostState.showSnackbar(ancNotConfirmedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ class OverviewViewModel @Inject constructor(
|
||||
|
||||
sealed interface Event {
|
||||
data object OffModeRejectedByDevice : Event
|
||||
data object AncModeNotConfirmedByDevice : Event
|
||||
}
|
||||
|
||||
val events = SingleEventFlow<Event>()
|
||||
@@ -84,6 +85,14 @@ class OverviewViewModel @Inject constructor(
|
||||
events.tryEmit(Event.OffModeRejectedByDevice)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
// OFF has its own, more specific message via offRejectedEvents.
|
||||
aapManager.settingRejectedEvents.collect { (_, command) ->
|
||||
if (command is AapCommand.SetAncMode && command.mode != AapSetting.AncMode.Value.OFF) {
|
||||
events.tryEmit(Event.AncModeNotConfirmedByDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val showUnmatchedDevices = MutableStateFlow(false)
|
||||
|
||||
@@ -284,10 +284,10 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
// ANC mode selector
|
||||
val ancMode = device.ancMode
|
||||
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
AncModeSelector(
|
||||
currentMode = ancMode.current,
|
||||
supportedModes = device.visibleAncModes,
|
||||
supportedModes = device.visibleAncModes,
|
||||
onModeSelected = { onAncModeChange?.invoke(it) },
|
||||
pendingMode = device.pendingAncMode,
|
||||
)
|
||||
|
||||
@@ -335,7 +335,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
AncModeSelector(
|
||||
currentMode = ancMode.current,
|
||||
supportedModes = device.visibleAncModes,
|
||||
supportedModes = device.visibleAncModes,
|
||||
onModeSelected = { onAncModeChange?.invoke(it) },
|
||||
pendingMode = device.pendingAncMode,
|
||||
)
|
||||
|
||||
@@ -1,34 +1,74 @@
|
||||
package eu.darken.capod.main.ui.overview.cards.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.main.ui.components.icon
|
||||
import eu.darken.capod.main.ui.components.shortLabelRes
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val TrackShape = RoundedCornerShape(16.dp)
|
||||
private val ThumbShape = RoundedCornerShape(12.dp)
|
||||
private val TrackInset = 4.dp
|
||||
private val TrackHeight = 48.dp
|
||||
private val CollapsedSlotWidth = 52.dp
|
||||
|
||||
/**
|
||||
* Listening-mode picker: one thumb travelling across a filled track, naming only the mode it sits
|
||||
* on. Inactive modes stay icon-sized, which keeps the active label free of the width budget and
|
||||
* therefore readable in every locale.
|
||||
*/
|
||||
@Composable
|
||||
fun AncModeSelector(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -38,73 +78,212 @@ fun AncModeSelector(
|
||||
pendingMode: AapSetting.AncMode.Value? = null,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val displayMode = pendingMode ?: currentMode
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
supportedModes.forEachIndexed { index, mode ->
|
||||
SegmentedButton(
|
||||
selected = mode == displayMode,
|
||||
onClick = { onModeSelected(mode) },
|
||||
enabled = enabled,
|
||||
shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
|
||||
colors = if (pendingMode != null && mode == displayMode) {
|
||||
SegmentedButtonDefaults.colors(
|
||||
activeContainerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
activeContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
} else {
|
||||
SegmentedButtonDefaults.colors()
|
||||
},
|
||||
icon = {},
|
||||
label = {
|
||||
val isSelected = mode == displayMode
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = if (index == 0) 6.dp else 0.dp,
|
||||
end = if (index == supportedModes.lastIndex) 6.dp else 0.dp,
|
||||
top = 4.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = mode.icon(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(mode.shortLabelRes()),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||
textDecoration = if (isSelected) TextDecoration.Underline else TextDecoration.None,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (supportedModes.isEmpty()) return
|
||||
|
||||
val currentIndex = supportedModes.indexOf(currentMode)
|
||||
// A requested mode can be missing from the list (e.g. one filtered out of the listening-mode
|
||||
// cycle), in which case the thumb stays on the mode the pods are actually in.
|
||||
val requestedIndex = supportedModes.indexOf(pendingMode ?: currentMode)
|
||||
val targetIndex = if (requestedIndex >= 0) requestedIndex else currentIndex
|
||||
val isPending = pendingMode != null && pendingMode != currentMode
|
||||
|
||||
// With neither mode on the list there is nothing to highlight: park the thumb where it was and
|
||||
// fade it out rather than falsely marking the first slot as active.
|
||||
val lastKnownIndex = remember { mutableIntStateOf(targetIndex.coerceAtLeast(0)) }
|
||||
val thumbIndex = if (targetIndex >= 0) targetIndex else lastKnownIndex.intValue
|
||||
SideEffect { if (targetIndex >= 0) lastKnownIndex.intValue = targetIndex }
|
||||
|
||||
val animatedIndex by animateFloatAsState(
|
||||
targetValue = thumbIndex.toFloat(),
|
||||
animationSpec = spring(dampingRatio = 0.72f, stiffness = Spring.StiffnessMediumLow),
|
||||
label = "ancThumbPosition",
|
||||
)
|
||||
val thumbAlpha by animateFloatAsState(
|
||||
targetValue = if (targetIndex >= 0) 1f else 0f,
|
||||
animationSpec = tween(200),
|
||||
label = "ancThumbAlpha",
|
||||
)
|
||||
// While a mode change is in flight the thumb stays hollow and breathes; it only fills in solid
|
||||
// once the pods confirm the new mode.
|
||||
val pendingFill = if (isPending) {
|
||||
val pulse by rememberInfiniteTransition(label = "ancPending").animateFloat(
|
||||
initialValue = 0.10f,
|
||||
targetValue = 0.30f,
|
||||
animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
|
||||
label = "ancPendingPulse",
|
||||
)
|
||||
pulse
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
val activeContent = if (isPending) accent else MaterialTheme.colorScheme.onPrimary
|
||||
val idleContent = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (enabled) Modifier else Modifier.alpha(0.5f)),
|
||||
shape = TrackShape,
|
||||
// Same tonal step as the battery panel above it, so the two read as siblings and the thumb
|
||||
// is the only thing in the card competing with the gauges for attention.
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.padding(TrackInset)
|
||||
.height(TrackHeight),
|
||||
) {
|
||||
val slots = supportedModes.size
|
||||
val equalWidth = maxWidth / slots.toFloat()
|
||||
val collapsedWidth = minOf(CollapsedSlotWidth, equalWidth)
|
||||
val expandedWidth = maxWidth - collapsedWidth * (slots - 1)
|
||||
|
||||
// The spring is underdamped and overshoots past the end slots; clamp before deriving any
|
||||
// geometry, otherwise the reveals stop summing to 1 and the slots no longer fill the row.
|
||||
val position = animatedIndex.coerceIn(0f, (slots - 1).toFloat())
|
||||
// Distance to the (fractional) thumb position drives slot width, label reveal and tint
|
||||
// alike, so geometry and content stay in lockstep for the whole travel.
|
||||
val reveals = supportedModes.indices.map { (1f - abs(it - position)).coerceIn(0f, 1f) }
|
||||
val widths = reveals.map { collapsedWidth + (expandedWidth - collapsedWidth) * it }
|
||||
// Everything left of the thumb is collapsed by definition, so its leading edge is just
|
||||
// the fractional position in collapsed-slot units. Summing the animating widths instead
|
||||
// would overshoot the target and crawl back.
|
||||
val thumbOffset = collapsedWidth * position
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = thumbOffset)
|
||||
.width(expandedWidth)
|
||||
.fillMaxHeight()
|
||||
.alpha(thumbAlpha)
|
||||
.clip(ThumbShape)
|
||||
.background(if (isPending) accent.copy(alpha = pendingFill) else accent)
|
||||
.then(if (isPending) Modifier.border(1.5.dp, accent, ThumbShape) else Modifier),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.selectableGroup(),
|
||||
) {
|
||||
supportedModes.forEachIndexed { index, mode ->
|
||||
AncModeSlot(
|
||||
mode = mode,
|
||||
label = stringResource(mode.shortLabelRes()),
|
||||
reveal = reveals[index],
|
||||
width = widths[index],
|
||||
contentColor = when {
|
||||
// Mid-flight the still-active mode keeps an accent tint, so it stays
|
||||
// readable what the pods are doing while the request is unconfirmed.
|
||||
isPending && index == currentIndex -> accent
|
||||
else -> lerp(idleContent, activeContent, reveals[index])
|
||||
},
|
||||
selected = index == targetIndex,
|
||||
enabled = enabled,
|
||||
onClick = { onModeSelected(mode) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AncModeSlot(
|
||||
mode: AapSetting.AncMode.Value,
|
||||
label: String,
|
||||
reveal: Float,
|
||||
width: Dp,
|
||||
contentColor: Color,
|
||||
selected: Boolean,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.fillMaxHeight()
|
||||
.clip(ThumbShape)
|
||||
.selectable(
|
||||
selected = selected,
|
||||
enabled = enabled,
|
||||
role = Role.RadioButton,
|
||||
onClick = onClick,
|
||||
)
|
||||
// Named on the slot itself rather than on whichever child happens to be visible: the
|
||||
// label only exists once it has revealed, so deriving the name from the children would
|
||||
// leave the freshly selected mode unnamed for the length of the animation.
|
||||
.semantics { contentDescription = label }
|
||||
.clipToBounds(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = mode.icon(),
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp * reveal))
|
||||
RevealingLabel(text = label, reveal = reveal, color = contentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Label that wipes in from the left as [reveal] grows: it is measured at its natural width but
|
||||
* reports only a fraction of it, so the row around it grows continuously instead of jumping the
|
||||
* moment the text appears.
|
||||
*/
|
||||
@Composable
|
||||
private fun RevealingLabel(
|
||||
text: String,
|
||||
reveal: Float,
|
||||
color: Color,
|
||||
) {
|
||||
if (reveal <= 0f) return
|
||||
Box(modifier = Modifier.clipToBounds()) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = color,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
// The slot already carries the accessible name; the text is decoration on top of it.
|
||||
modifier = Modifier
|
||||
.clearAndSetSemantics { }
|
||||
.alpha(reveal)
|
||||
.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints.copy(minWidth = 0))
|
||||
layout((placeable.width * reveal).roundToInt(), placeable.height) {
|
||||
// Relative, so RTL anchors the text at its start edge and wipes in from the
|
||||
// right instead of exposing the tail of the word.
|
||||
placeable.placeRelative(0, 0)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val PreviewModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun AncModeSelectorPreview() = PreviewWrapper {
|
||||
AncModeSelector(
|
||||
currentMode = AapSetting.AncMode.Value.ON,
|
||||
supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
),
|
||||
supportedModes = PreviewModes,
|
||||
onModeSelected = {},
|
||||
)
|
||||
}
|
||||
@@ -114,12 +293,19 @@ private fun AncModeSelectorPreview() = PreviewWrapper {
|
||||
private fun AncModeSelectorPendingPreview() = PreviewWrapper {
|
||||
AncModeSelector(
|
||||
currentMode = AapSetting.AncMode.Value.ON,
|
||||
supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
),
|
||||
supportedModes = PreviewModes.dropLast(1),
|
||||
onModeSelected = {},
|
||||
pendingMode = AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun AncModeSelectorDisabledPreview() = PreviewWrapper {
|
||||
AncModeSelector(
|
||||
currentMode = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
supportedModes = PreviewModes,
|
||||
onModeSelected = {},
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -408,6 +408,9 @@ data class PodDevice(
|
||||
val pmeConfig: AapSetting.PmeConfig?
|
||||
get() = aap?.setting()
|
||||
|
||||
val customEq: AapSetting.CustomEq?
|
||||
get() = aap?.setting()
|
||||
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
get() = aap?.deviceInfo ?: cached?.deviceInfo
|
||||
|
||||
|
||||
@@ -18,18 +18,30 @@ fun resolvedAncCycleMask(
|
||||
null
|
||||
}
|
||||
|
||||
fun visibleAncModes(
|
||||
supportedModes: List<AapSetting.AncMode.Value>,
|
||||
currentMode: AapSetting.AncMode.Value,
|
||||
/**
|
||||
* Whether the device is expected to be able to sit in [mode] at all, based on the listening mode
|
||||
* cycle and the Allow Off option. Both of those are inferred rather than device-reported: AirPods
|
||||
* never push 0x1A/0x34, so this is a belief, not ground truth.
|
||||
*/
|
||||
fun isAncModePermitted(
|
||||
mode: AapSetting.AncMode.Value,
|
||||
cycleMask: Int?,
|
||||
allowOffEnabled: Boolean,
|
||||
): List<AapSetting.AncMode.Value> = supportedModes.filter { mode ->
|
||||
): Boolean {
|
||||
val inCycle = if (cycleMask != null) {
|
||||
(cycleMask and mode.cycleBit()) != 0
|
||||
} else {
|
||||
true
|
||||
}
|
||||
inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode
|
||||
return inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled)
|
||||
}
|
||||
|
||||
fun visibleAncModes(
|
||||
supportedModes: List<AapSetting.AncMode.Value>,
|
||||
cycleMask: Int?,
|
||||
allowOffEnabled: Boolean,
|
||||
): List<AapSetting.AncMode.Value> = supportedModes.filter { mode ->
|
||||
isAncModePermitted(mode, cycleMask, allowOffEnabled)
|
||||
}
|
||||
|
||||
val PodDevice.resolvedAncCycleMask: Int?
|
||||
@@ -38,15 +50,17 @@ val PodDevice.resolvedAncCycleMask: Int?
|
||||
reportedCycleMask = listeningModeCycle?.modeMask,
|
||||
)
|
||||
|
||||
// Unknown (null) is treated as allowed so OFF is visible optimistically. Only a
|
||||
// confirmed enabled=false (direct device report or inferred rejection) hides OFF.
|
||||
private val PodDevice.resolvedAllowOffEnabled: Boolean
|
||||
get() = allowOffOption?.enabled != false
|
||||
|
||||
val PodDevice.visibleAncModes: List<AapSetting.AncMode.Value>
|
||||
get() {
|
||||
val ancMode = ancMode ?: return emptyList()
|
||||
return visibleAncModes(
|
||||
supportedModes = ancMode.supported,
|
||||
currentMode = ancMode.current,
|
||||
cycleMask = resolvedAncCycleMask,
|
||||
// Unknown (null) is treated as allowed so OFF is visible optimistically. Only a
|
||||
// confirmed enabled=false (direct device report or inferred rejection) hides OFF.
|
||||
allowOffEnabled = allowOffOption?.enabled != false,
|
||||
allowOffEnabled = resolvedAllowOffEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@ import kotlin.reflect.KClass
|
||||
internal data class AncRuntimeState(
|
||||
val latestObservedAncMode: AapSetting.AncMode? = null,
|
||||
val pendingDebouncedAnc: PendingDebouncedAnc? = null,
|
||||
/**
|
||||
* Set when the device reported OFF while we had a request for a different mode outstanding.
|
||||
*
|
||||
* AirPods Pro 3 have been seen answering a listening mode write with OFF while actually
|
||||
* switching to the requested mode. Such a report must not be fed to the Allow Off inference,
|
||||
* or a single glitch permanently teaches CAPod that OFF is a permitted mode. Cleared as soon
|
||||
* as any non-OFF mode is reported, so an unsolicited switch into OFF (stem press, another
|
||||
* phone) still trains the inference normally.
|
||||
*/
|
||||
val offReportContradicted: Boolean = false,
|
||||
)
|
||||
|
||||
internal data class PendingDebouncedAnc(
|
||||
@@ -34,7 +44,17 @@ internal class AapAncController {
|
||||
now: Instant,
|
||||
): AncDecision {
|
||||
val previous = podState.settings[key]
|
||||
val updatedRuntime = runtimeState.copy(latestObservedAncMode = value)
|
||||
val pendingMode = podState.pendingAncMode
|
||||
// An OFF report with no competing request of our own is taken at face value, which both
|
||||
// clears any earlier contradiction and keeps the Allow Off self-heal working after a
|
||||
// glitch (stem press / another phone switching the pods into OFF for real).
|
||||
val contradicted = value.current == AapSetting.AncMode.Value.OFF &&
|
||||
pendingMode != null &&
|
||||
pendingMode != AapSetting.AncMode.Value.OFF
|
||||
val updatedRuntime = runtimeState.copy(
|
||||
latestObservedAncMode = value,
|
||||
offReportContradicted = contradicted,
|
||||
)
|
||||
val timerActions = mutableListOf<EngineTimerAction>()
|
||||
timerActions.plusAssign(planAllowOffInferenceTimer(podState, updatedRuntime))
|
||||
|
||||
@@ -90,6 +110,7 @@ internal class AapAncController {
|
||||
val latestEarDetection = podState.setting<AapSetting.EarDetection>()
|
||||
val latestAllowOffOption = podState.setting<AapSetting.AllowOffOption>()
|
||||
if (latestAncMode?.current == AapSetting.AncMode.Value.OFF &&
|
||||
!runtimeState.offReportContradicted &&
|
||||
latestEarDetection?.isEitherPodInEar == true &&
|
||||
latestAllowOffOption?.enabled != true
|
||||
) {
|
||||
@@ -153,6 +174,7 @@ internal class AapAncController {
|
||||
val earDetection = podState.setting<AapSetting.EarDetection>()
|
||||
val allowOffOption = podState.setting<AapSetting.AllowOffOption>()
|
||||
return if (observedAncMode?.current == AapSetting.AncMode.Value.OFF &&
|
||||
!runtimeState.offReportContradicted &&
|
||||
earDetection?.isEitherPodInEar == true &&
|
||||
allowOffOption?.enabled != true
|
||||
) {
|
||||
|
||||
@@ -30,6 +30,23 @@ internal class AapOutboundController(
|
||||
|
||||
private val coordinator = AapSettingsCoordinator(timeSource)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* How long to wait for the device to confirm a setting write before treating it as diverged.
|
||||
*
|
||||
* AirPods Pro 3 answer a listening mode write in roughly 0.8-1.1s (measured: 833ms, 887ms,
|
||||
* 956ms, 1008ms). A 1000ms deadline sits inside that spread, so a perfectly healthy reply
|
||||
* could land just after the timer and trigger a bogus "Divergence detected" plus a
|
||||
* redundant re-send. The deadline is only a backstop now: [onStateObserved] resolves the
|
||||
* verification as soon as a matching report arrives, so raising this does not slow the
|
||||
* success path, only how long a genuinely unanswered write waits.
|
||||
*
|
||||
* Kept at roughly 2x the worst measured reply rather than higher, because a real rejection
|
||||
* still costs two full deadlines before the user is told about it.
|
||||
*/
|
||||
const val VERIFICATION_TIMEOUT_MS = 2000L
|
||||
}
|
||||
|
||||
fun onCommandRequested(
|
||||
podState: AapPodState,
|
||||
runtimeState: OutboundRuntimeState,
|
||||
@@ -74,7 +91,7 @@ internal class AapOutboundController(
|
||||
),
|
||||
commandsToSend = listOf(command),
|
||||
timerActions = if (verificationCheck != null) {
|
||||
listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L))
|
||||
listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS))
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
@@ -108,7 +125,7 @@ internal class AapOutboundController(
|
||||
),
|
||||
commandsToSend = result.commands,
|
||||
timerActions = if (verificationCheck != null) {
|
||||
listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L))
|
||||
listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS))
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
@@ -116,6 +133,34 @@ internal class AapOutboundController(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check the outstanding verification against freshly applied device state, so a confirmation
|
||||
* is honoured the moment it arrives instead of waiting out [VERIFICATION_TIMEOUT_MS] and racing
|
||||
* it.
|
||||
*
|
||||
* Deliberately limited to [AapCommand.SetAncMode]. Every other verified command gets an
|
||||
* optimistic write into state when it is queued (see AapSettingsCoordinator.optimisticUpdate),
|
||||
* which satisfies its own verification predicate straight away - only the device's contradicting
|
||||
* echo later makes it fail. Reconciling those on arbitrary inbound frames would cancel the
|
||||
* verification before that echo lands and silently swallow the rejection. SetAncMode is exempt
|
||||
* from the optimistic write, so its predicate only becomes true once the device really confirms.
|
||||
*/
|
||||
fun onStateObserved(
|
||||
podState: AapPodState,
|
||||
runtimeState: OutboundRuntimeState,
|
||||
): OutboundDecision {
|
||||
val verification = runtimeState.verification ?: return OutboundDecision(podState, runtimeState)
|
||||
if (verification.command !is AapCommand.SetAncMode) return OutboundDecision(podState, runtimeState)
|
||||
val check = coordinator.verificationFor(verification.command)
|
||||
?: return OutboundDecision(podState, runtimeState)
|
||||
if (!check(podState)) return OutboundDecision(podState, runtimeState)
|
||||
return OutboundDecision(
|
||||
podState = clearPendingForCommand(podState, verification.command),
|
||||
runtimeState = runtimeState.copy(verification = null),
|
||||
timerActions = listOf(EngineTimerAction.Cancel(EngineTimerKey.Verification)),
|
||||
)
|
||||
}
|
||||
|
||||
fun onVerificationTimerFired(
|
||||
podState: AapPodState,
|
||||
runtimeState: OutboundRuntimeState,
|
||||
@@ -136,8 +181,10 @@ internal class AapOutboundController(
|
||||
|
||||
val ear = podState.setting<AapSetting.EarDetection>()
|
||||
if (ear != null && !ear.isEitherPodInEar) {
|
||||
// Drop the pending mode too: nothing is going to confirm it now, and leaving it set
|
||||
// would keep the UI showing a mode the device never reached.
|
||||
return OutboundDecision(
|
||||
podState = podState,
|
||||
podState = clearPendingForCommand(podState, verification.command),
|
||||
runtimeState = runtimeState.copy(verification = null),
|
||||
logs = listOf("Verification aborted for ${verification.command::class.simpleName}: no pod in ear"),
|
||||
)
|
||||
@@ -148,7 +195,7 @@ internal class AapOutboundController(
|
||||
podState = podState,
|
||||
runtimeState = runtimeState.copy(verification = verification.copy(attempt = 1)),
|
||||
commandsToSend = listOf(verification.command),
|
||||
timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)),
|
||||
timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, VERIFICATION_TIMEOUT_MS)),
|
||||
logs = listOf("Divergence detected for ${verification.command::class.simpleName}, re-sending"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -295,6 +295,7 @@ internal class AapSessionEngine(
|
||||
now = timeSource.now(),
|
||||
)
|
||||
applyAncDecision(decision)
|
||||
reconcileVerification()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -313,6 +314,8 @@ internal class AapSessionEngine(
|
||||
"Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]"
|
||||
}
|
||||
|
||||
reconcileVerification()
|
||||
|
||||
if (value is AapSetting.EarDetection) {
|
||||
applyAncDecision(ancController.onEarDetectionUpdated(_state.value, runtimeState.anc))
|
||||
if (value.isEitherPodInEar) {
|
||||
@@ -361,6 +364,21 @@ internal class AapSessionEngine(
|
||||
applyTimerActions(decision.timerActions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle an outstanding verification against state we just applied. Confirmations are honoured
|
||||
* the moment the device's report lands rather than at the verification deadline, so a reply
|
||||
* arriving close to that deadline can't be misread as a divergence.
|
||||
*/
|
||||
private fun reconcileVerification() {
|
||||
if (runtimeState.outbound.verification == null) return
|
||||
val decision = outboundController.onStateObserved(_state.value, runtimeState.outbound)
|
||||
if (decision.runtimeState.verification != null) return
|
||||
_state.value = decision.podState
|
||||
runtimeState = runtimeState.copy(outbound = decision.runtimeState)
|
||||
decision.logs.forEach { log(TAG) { it } }
|
||||
applyTimerActions(decision.timerActions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the non-send side of a decision (state, runtime, logs) and prepare the send context.
|
||||
*
|
||||
@@ -397,6 +415,16 @@ internal class AapSessionEngine(
|
||||
runtimeState = runtimeState.copy(outbound = runtimeState.outbound.copy(verification = previousVerification))
|
||||
}
|
||||
|
||||
/**
|
||||
* A send that threw never reached the device, so an optimistically stored pending ANC mode has
|
||||
* nothing left to confirm it. Clearing it stops the UI from showing a mode we failed to request.
|
||||
*/
|
||||
private fun clearPendingAncAfterFailedSend(commands: List<AapCommand>) {
|
||||
val ancCommand = commands.filterIsInstance<AapCommand.SetAncMode>().lastOrNull() ?: return
|
||||
if (_state.value.pendingAncMode != ancCommand.mode) return
|
||||
_state.value = _state.value.copy(pendingAncMode = null)
|
||||
}
|
||||
|
||||
/** User-initiated send: runs in the caller's coroutine, errors propagate back to the caller. */
|
||||
private suspend fun applyOutboundDecisionInline(decision: OutboundDecision) {
|
||||
val ctx = applyDecisionStateAndPrepareSend(decision) ?: return
|
||||
@@ -406,6 +434,7 @@ internal class AapSessionEngine(
|
||||
handleRejectedCommand(ctx.decision.rejectedCommand)
|
||||
} catch (e: Exception) {
|
||||
restoreVerification(ctx.previousVerification)
|
||||
clearPendingAncAfterFailedSend(ctx.decision.commandsToSend)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -417,6 +446,7 @@ internal class AapSessionEngine(
|
||||
if (currentScope == null) {
|
||||
log(TAG, ERROR) { "No scope available for outbound async send" }
|
||||
restoreVerification(ctx.previousVerification)
|
||||
clearPendingAncAfterFailedSend(ctx.decision.commandsToSend)
|
||||
return
|
||||
}
|
||||
currentScope.launch {
|
||||
@@ -426,6 +456,7 @@ internal class AapSessionEngine(
|
||||
handleRejectedCommand(ctx.decision.rejectedCommand)
|
||||
} catch (_: Exception) {
|
||||
restoreVerification(ctx.previousVerification)
|
||||
clearPendingAncAfterFailedSend(ctx.decision.commandsToSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,11 @@ internal class AapSettingsCoordinator(
|
||||
AapSetting.DynamicEndOfCharge::class to AapSetting.DynamicEndOfCharge(enabled = command.enabled)
|
||||
}
|
||||
|
||||
// No optimistic update: an optimistic update would render the UI as though an
|
||||
// unacknowledged write had succeeded, which would actively falsify the on-device
|
||||
// evaluation this command exists to support.
|
||||
is AapCommand.SetCustomEq -> return null
|
||||
|
||||
is AapCommand.SetDeviceName -> {
|
||||
val currentInfo = baseState.deviceInfo ?: return null
|
||||
return baseState.copy(
|
||||
@@ -211,5 +216,9 @@ internal class AapSettingsCoordinator(
|
||||
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
|
||||
is AapCommand.SetDynamicEndOfCharge -> { s -> s.setting<AapSetting.DynamicEndOfCharge>()?.enabled == command.enabled }
|
||||
is AapCommand.SetDeviceName -> null
|
||||
// Verification compares against a device-reported setting, and we have no evidence the
|
||||
// device reports this one at all. A predicate here would manufacture spurious
|
||||
// divergence-detected churn on every write.
|
||||
is AapCommand.SetCustomEq -> null
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,16 @@ sealed class AapCommand {
|
||||
data class SetSleepDetection(val enabled: Boolean) : AapCommand()
|
||||
data class SetDynamicEndOfCharge(val enabled: Boolean) : AapCommand()
|
||||
data class SetDeviceName(val name: String) : AapCommand()
|
||||
data class SetCustomEq(
|
||||
val mode: AapSetting.CustomEq.Mode,
|
||||
val low: Int,
|
||||
val mid: Int,
|
||||
val high: Int,
|
||||
) : AapCommand() {
|
||||
init {
|
||||
require(low in 0..100) { "low band out of range: $low" }
|
||||
require(mid in 0..100) { "mid band out of range: $mid" }
|
||||
require(high in 0..100) { "high band out of range: $high" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +101,10 @@ enum class AapMessageType(val value: Int, val wiresharkName: String) {
|
||||
|
||||
/**
|
||||
* PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment) —
|
||||
* hearing-aid configuration for the iOS 18.1+ hearing-aid feature on AirPods
|
||||
* Pro 2. Decoded as 4 × 8 Float32 values (see [AapSetting.PmeConfig]); see
|
||||
* the **Headphone Accommodations** configuration, an iOS Accessibility feature
|
||||
* with its own "Apply To: Phone / Media" toggles, which the iOS 18.1+ hearing-aid
|
||||
* feature reuses. Not exclusively the hearing-aid audiogram. Decoded as the two
|
||||
* apply-to flags plus 4 × 8 Float32 band gains (see [AapSetting.PmeConfig]); see
|
||||
* that data class for the layout rationale. "PME Config" is the label the
|
||||
* Wireshark AAP dissector uses for this opcode.
|
||||
*/
|
||||
@@ -125,6 +127,18 @@ enum class AapMessageType(val value: Int, val wiresharkName: String) {
|
||||
UNKNOWN_0X58(0x0058, "Unknown"),
|
||||
DYNAMIC_END_OF_CHARGE(0x0059, "Dynamic End Of Charge"),
|
||||
PERSONAL_TRANSLATION(0x0060, "Personal Translation"),
|
||||
|
||||
/**
|
||||
* Custom EQ — Apple's iOS 27 equalizer feature (announced WWDC 2026) for the H2 models
|
||||
* (AirPods Pro 3, AirPods Pro 2, AirPods 4). Three bands (low / mid / high) plus a
|
||||
* Recommended / Custom mode selector; see [AapSetting.CustomEq].
|
||||
*
|
||||
* The wire format is sourced from librepods commit `7341e41` and is **unverified on real
|
||||
* hardware** — no capture from any device we own has ever carried this opcode. Both the
|
||||
* decoder and the encoder are written to fail loudly rather than guess (see
|
||||
* [DefaultAapDeviceProfile]).
|
||||
*/
|
||||
CUSTOM_EQ(0x0063, "Custom EQ"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -183,25 +183,58 @@ sealed class AapSetting {
|
||||
|
||||
/**
|
||||
* Payload of message type 0x0053 — "PME Config" in the Wireshark AAP dissector.
|
||||
* PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment):
|
||||
* the hearing-aid configuration for Apple's iOS 18.1+ hearing-aid feature on
|
||||
* AirPods Pro 2.
|
||||
* PME = Personal Medical Equipment (cf. PPE = Personal Protective Equipment).
|
||||
*
|
||||
* Decoded as 4 × 8 Float32 values — consistent with per-ear × per-profile
|
||||
* audiogram band gains (e.g. L/R × two environment profiles, 8 frequency
|
||||
* bands). CAPod previously called this "EQ bands".
|
||||
* This is the **Headphone Accommodations** configuration — an iOS Accessibility
|
||||
* feature with its own "Apply To: Phone / Media" toggles, which the iOS 18.1+
|
||||
* hearing-aid feature reuses. It is not exclusively the hearing-aid audiogram.
|
||||
*
|
||||
* Callers should treat all-zero [sets] as "no hearing-aid profile configured"
|
||||
* — stock firmware reports zeros until the user runs Apple's Hearing Test /
|
||||
* hearing-aid setup.
|
||||
* [sets] is decoded as 4 × 8 Float32 values — consistent with per-ear × per-profile
|
||||
* band gains (e.g. L/R × two environment profiles, 8 frequency bands). CAPod
|
||||
* previously called this "EQ bands".
|
||||
*
|
||||
* [applyToMedia] and [applyToPhone] mirror the two "Apply To" checkboxes. They
|
||||
* describe **scope only** — which audio the accommodation is applied to. They say
|
||||
* nothing about whether a profile exists: both can be false while band data is
|
||||
* stored. [isAllZero] is likewise a pure band-data predicate; all-zero gains may
|
||||
* be flat values rather than an absent profile. Stock firmware does report zeros
|
||||
* before the user runs Apple's Hearing Test / Headphone Accommodations setup.
|
||||
*/
|
||||
data class PmeConfig(
|
||||
val sets: List<List<Float>>,
|
||||
val applyToMedia: Boolean,
|
||||
val applyToPhone: Boolean,
|
||||
) : AapSetting() {
|
||||
val isAllZero: Boolean
|
||||
get() = sets.all { set -> set.all { it == 0f } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload of message type 0x0063 — Apple's iOS 27 "Custom EQ" (see [AapMessageType.CUSTOM_EQ]).
|
||||
*
|
||||
* [low], [mid] and [high] are the three band gains, `0..100` with `50` as the neutral
|
||||
* (no-gain) position. [mode] selects between Apple's recommended curve and the user's
|
||||
* own band values.
|
||||
*
|
||||
* The layout comes from librepods commit `7341e41` and has never been seen on hardware
|
||||
* we own — treat any decoded value as unconfirmed.
|
||||
*/
|
||||
data class CustomEq(
|
||||
val mode: Mode,
|
||||
val low: Int,
|
||||
val mid: Int,
|
||||
val high: Int,
|
||||
) : AapSetting() {
|
||||
enum class Mode(val wireValue: Int) {
|
||||
RECOMMENDED(0x01),
|
||||
CUSTOM(0x02);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): Mode? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-pod placement reported by the device (command 0x06). */
|
||||
data class EarDetection(
|
||||
val primaryPod: PodPlacement,
|
||||
|
||||
@@ -100,6 +100,7 @@ class DefaultAapDeviceProfile(
|
||||
is AapCommand.SetSleepDetection -> buildSettingsMessage(AapControlId.SLEEP_DETECTION.value, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetDynamicEndOfCharge -> buildSettingsMessage(AapControlId.DYNAMIC_END_OF_CHARGE.value, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetDeviceName -> buildRenameMessage(command.name)
|
||||
is AapCommand.SetCustomEq -> buildCustomEqMessage(command.mode, command.low, command.mid, command.high)
|
||||
}
|
||||
|
||||
override fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? {
|
||||
@@ -159,13 +160,19 @@ class DefaultAapDeviceProfile(
|
||||
}
|
||||
|
||||
// 0x53 is "PME Config" per the Wireshark AAP dissector — Personal Medical
|
||||
// Equipment (cf. PPE), i.e. the iOS 18.1+ hearing-aid profile on AirPods
|
||||
// Pro 2. Decoded verbatim as 4 × 8 Float32 (per-ear × per-profile band
|
||||
// gains); stock firmware reports all-zero until the user runs Apple's
|
||||
// Hearing Test. 0x54 "Set Band Edges" is a neighbouring opcode with a
|
||||
// different payload — not decoded here.
|
||||
// Equipment (cf. PPE), i.e. the Headphone Accommodations configuration that
|
||||
// the iOS 18.1+ hearing-aid feature reuses. Payload bytes 4 and 5 are the two
|
||||
// "Apply To" scope flags (Media, Phone); the band gains follow at offset 6,
|
||||
// decoded verbatim as 4 × 8 Float32 (per-ear × per-profile). Stock firmware
|
||||
// reports all-zero gains until the user runs Apple's Hearing Test. 0x54
|
||||
// "Set Band Edges" is a neighbouring opcode with a different payload — not
|
||||
// decoded here.
|
||||
if (message.commandType == AapMessageType.PME_CONFIG.value) {
|
||||
if (message.payload.size < 6 + 128) return null
|
||||
// Plain 0x01 flags, NOT the Apple-bool 0x01/0x02 encoding used by the
|
||||
// 0x09 control settings — don't route these through decodeAppleBool.
|
||||
val applyToMedia = (message.payload[4].toInt() and 0xFF) == 0x01
|
||||
val applyToPhone = (message.payload[5].toInt() and 0xFF) == 0x01
|
||||
val sets = mutableListOf<List<Float>>()
|
||||
var offset = 6 // skip header
|
||||
for (s in 0 until 4) {
|
||||
@@ -180,7 +187,11 @@ class DefaultAapDeviceProfile(
|
||||
}
|
||||
sets.add(bands)
|
||||
}
|
||||
return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets)
|
||||
return AapSetting.PmeConfig::class to AapSetting.PmeConfig(
|
||||
sets = sets,
|
||||
applyToMedia = applyToMedia,
|
||||
applyToPhone = applyToPhone,
|
||||
)
|
||||
}
|
||||
|
||||
// Conversation Awareness State is a separate command type (push-only). Two payload shapes
|
||||
@@ -206,6 +217,33 @@ class DefaultAapDeviceProfile(
|
||||
AapSetting.ConversationalAwarenessState(speaking, rawValue = status)
|
||||
}
|
||||
|
||||
// Custom EQ (0x63, iOS 27 / H2 models). Payload — everything after the 4-byte AAP
|
||||
// packet-type/service header and the 2-byte opcode — is `05 00 01 <mode> <low> <mid> <high>`:
|
||||
// bytes 0-1 are a little-endian declared length (5), byte 2 is a marker whose purpose is
|
||||
// unidentified, byte 3 is the mode, bytes 4-6 are the three band values (0..100).
|
||||
//
|
||||
// Every field is validated and ANY mismatch returns null, which routes the frame to the
|
||||
// engine's existing unknown-message logging (that prints full hex). We have never seen this
|
||||
// opcode on hardware — the format is librepods `7341e41` hearsay — so on a firmware whose
|
||||
// 0x63 dialect differs, preserving the unrecognised frame verbatim in the log beats
|
||||
// mis-parsing it into plausible-looking values. The size check is deliberately an equality:
|
||||
// `>= 7` would silently swallow trailing unknown bytes, which is exactly the dialect
|
||||
// variation we want surfaced. Same shape-validation rationale as the 0x4B branch above.
|
||||
if (message.commandType == AapMessageType.CUSTOM_EQ.value) {
|
||||
val p = message.payload
|
||||
if (p.size < 2) return null
|
||||
val declaredLength = (p[0].toInt() and 0xFF) or ((p[1].toInt() and 0xFF) shl 8)
|
||||
if (declaredLength != 5) return null
|
||||
if (p.size != 2 + declaredLength) return null
|
||||
if ((p[2].toInt() and 0xFF) != 0x01) return null
|
||||
val mode = AapSetting.CustomEq.Mode.fromWire(p[3].toInt() and 0xFF) ?: return null
|
||||
val low = p[4].toInt() and 0xFF
|
||||
val mid = p[5].toInt() and 0xFF
|
||||
val high = p[6].toInt() and 0xFF
|
||||
if (low !in 0..100 || mid !in 0..100 || high !in 0..100) return null
|
||||
return AapSetting.CustomEq::class to AapSetting.CustomEq(mode, low, mid, high)
|
||||
}
|
||||
|
||||
if (message.commandType != AapMessageType.CONTROL.value) return null
|
||||
if (message.payload.size < 2) return null
|
||||
|
||||
@@ -569,6 +607,34 @@ class DefaultAapDeviceProfile(
|
||||
) + nameBytes
|
||||
}
|
||||
|
||||
private fun buildCustomEqMessage(
|
||||
mode: AapSetting.CustomEq.Mode,
|
||||
low: Int,
|
||||
mid: Int,
|
||||
high: Int,
|
||||
): ByteArray {
|
||||
// Uses the opcode 0x63 format from librepods commit 7341e41. Unlike the settings writes
|
||||
// above this is NOT a 0x09 control command — it carries its own message shape, so it can't
|
||||
// go through buildSettingsMessage.
|
||||
//
|
||||
// Layout: header `04 00 04 00`, opcode `63 00`, little-endian declared length `05 00`,
|
||||
// then `01` — a marker byte whose purpose is unidentified, carried verbatim because
|
||||
// librepods sends it — the mode, and the three band values (0..100, 50 neutral).
|
||||
//
|
||||
// On-device acceptance is UNVERIFIED at time of writing: no device we own has ever emitted
|
||||
// or acknowledged 0x63, and librepods itself never confirmed the format works. Nothing here
|
||||
// assumes the write lands — there is deliberately no optimistic update and no verification
|
||||
// predicate (see AapSettingsCoordinator).
|
||||
return byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00,
|
||||
0x63, 0x00,
|
||||
0x05, 0x00,
|
||||
0x01,
|
||||
mode.wireValue.toByte(),
|
||||
low.toByte(), mid.toByte(), high.toByte(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic binary-prefix skip: walk forward until we hit a printable byte.
|
||||
* The real header schema is `[02 XX 00 04 00]` in every capture to date, but
|
||||
|
||||
@@ -82,6 +82,14 @@ class ConversationReaction @Inject constructor(
|
||||
private sealed interface Kind {
|
||||
data object Paused : Kind
|
||||
data class Ducked(val priorVolume: Int, val appliedVolume: Int) : Kind
|
||||
|
||||
/**
|
||||
* Fallback for a volume write the system accepted but ignored: we hold
|
||||
* `AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK` and let the framework attenuate the other player.
|
||||
* Named for what is actually true — a granted request does not prove anything was
|
||||
* attenuated. [priorVolume] is only kept for the late-write guard at teardown.
|
||||
*/
|
||||
data class FocusHeld(val priorVolume: Int) : Kind
|
||||
}
|
||||
|
||||
private data class Active(
|
||||
@@ -155,6 +163,13 @@ class ConversationReaction @Inject constructor(
|
||||
// Duplicate START for the same speaker — don't re-act, just keep the session alive.
|
||||
// A START also cancels a pending wind-down fuse / settle: the wearer is speaking again.
|
||||
armTimer(current, TimerPhase.STALE_BACKSTOP)
|
||||
// Exception: a focus session that lost focus permanently mid-conversation has to be
|
||||
// re-requested here. Every later START is treated as a keep-alive, so this is the
|
||||
// only place that recovery can happen.
|
||||
if (current.kind is Kind.FocusHeld && !mediaControl.isDuckFocusHeld) {
|
||||
val regained = mediaControl.requestDuckFocus()
|
||||
log(TAG, INFO) { "START from $address — duck focus was lost, re-requested (granted=$regained)" }
|
||||
}
|
||||
log(TAG) { "START from $address — already active ($action), keep-alive" }
|
||||
return
|
||||
}
|
||||
@@ -185,20 +200,47 @@ class ConversationReaction @Inject constructor(
|
||||
ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION,
|
||||
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION,
|
||||
)
|
||||
val duck = mediaControl.duckMusicVolume(reduction)
|
||||
if (duck != null) {
|
||||
val record = Active(
|
||||
nextId(),
|
||||
address,
|
||||
Kind.Ducked(duck.priorVolume, duck.appliedVolume),
|
||||
timeSource.elapsedRealtime(),
|
||||
)
|
||||
active = record
|
||||
armTimer(record, TimerPhase.STALE_BACKSTOP)
|
||||
log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}→${duck.appliedVolume}" }
|
||||
} else {
|
||||
active = null
|
||||
log(TAG) { "START on $address → duck no-op" }
|
||||
when (val outcome = mediaControl.duckMusicVolume(reduction)) {
|
||||
is MediaControl.DuckOutcome.Ducked -> {
|
||||
val record = Active(
|
||||
nextId(),
|
||||
address,
|
||||
Kind.Ducked(outcome.priorVolume, outcome.appliedVolume),
|
||||
timeSource.elapsedRealtime(),
|
||||
)
|
||||
active = record
|
||||
armTimer(record, TimerPhase.STALE_BACKSTOP)
|
||||
log(TAG, INFO) {
|
||||
"START on $address → ducked volume ${outcome.priorVolume}→${outcome.appliedVolume}"
|
||||
}
|
||||
}
|
||||
|
||||
is MediaControl.DuckOutcome.Unchanged -> {
|
||||
// The write was accepted but the level never moved (ColorOS 16 refuses a
|
||||
// backgrounded app's write). Ask the framework to duck the other player
|
||||
// instead. A grant does not prove anything was attenuated: a player whose
|
||||
// content is marked speech (podcasts) may pause instead of duck, and a
|
||||
// player that never requested focus need not be attenuated at all.
|
||||
if (mediaControl.requestDuckFocus()) {
|
||||
val record = Active(
|
||||
nextId(),
|
||||
address,
|
||||
Kind.FocusHeld(outcome.priorVolume),
|
||||
timeSource.elapsedRealtime(),
|
||||
)
|
||||
active = record
|
||||
armTimer(record, TimerPhase.STALE_BACKSTOP)
|
||||
log(TAG, INFO) { "START on $address → volume write ignored, holding duck focus" }
|
||||
} else {
|
||||
active = null
|
||||
log(TAG) { "START on $address → duck no-op" }
|
||||
}
|
||||
}
|
||||
|
||||
MediaControl.DuckOutcome.Skipped -> {
|
||||
active = null
|
||||
log(TAG) { "START on $address → duck no-op" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +343,8 @@ class ConversationReaction @Inject constructor(
|
||||
}
|
||||
|
||||
is Kind.Ducked -> revertDuck(kind, reason)
|
||||
|
||||
is Kind.FocusHeld -> revertFocus(kind, reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +387,7 @@ class ConversationReaction @Inject constructor(
|
||||
private fun revert(record: Active, reason: String) {
|
||||
when (val kind = record.kind) {
|
||||
is Kind.Ducked -> revertDuck(kind, reason)
|
||||
is Kind.FocusHeld -> revertFocus(kind, reason)
|
||||
is Kind.Paused -> log(TAG) { "Clearing pause ($reason) — leaving playback as-is" }
|
||||
}
|
||||
}
|
||||
@@ -358,6 +403,19 @@ class ConversationReaction @Inject constructor(
|
||||
mediaControl.restoreMusicVolume(kind.priorVolume)
|
||||
}
|
||||
|
||||
private fun revertFocus(kind: Kind.FocusHeld, reason: String) {
|
||||
mediaControl.abandonDuckFocus()
|
||||
log(TAG, INFO) { "Released duck focus ($reason)" }
|
||||
// Late-write guard: a device may apply the volume write asynchronously. The read-back showed
|
||||
// equality, so we took the focus path — if the write landed afterwards, teardown would leave
|
||||
// the stream index permanently lowered. Restore ONLY when the level is below what we found.
|
||||
val current = mediaControl.currentMusicVolume()
|
||||
if (current < kind.priorVolume) {
|
||||
log(TAG, INFO) { "Restoring volume to ${kind.priorVolume} (late write landed at $current, $reason)" }
|
||||
mediaControl.restoreMusicVolume(kind.priorVolume)
|
||||
}
|
||||
}
|
||||
|
||||
/** Must be called under [mutex]. Clears the active slot and cancels its pending timer. */
|
||||
private fun clearActive() {
|
||||
disengageJob?.cancel()
|
||||
|
||||
@@ -577,6 +577,7 @@
|
||||
<string name="device_settings_rename_system_unavailable_bt_settings_action">Bluetooth Settings</string>
|
||||
<string name="device_settings_send_failed">Could not apply setting: %1$s</string>
|
||||
<string name="device_settings_anc_off_rejected_message">Off mode isn\'t enabled on this device. Enable \"Allow Off mode\" under Noise Control.</string>
|
||||
<string name="anc_mode_not_confirmed_message">The AirPods didn\'t confirm the listening mode change.</string>
|
||||
<string name="device_settings_category_battery_label">Battery</string>
|
||||
<string name="device_settings_charge_cap_label">Optimized Charge Limit</string>
|
||||
<string name="device_settings_charge_cap_description">Learn your routine and pause charging around 80%% to extend battery life, topping the pods off before you\'re likely to use them.</string>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioPlaybackConfiguration
|
||||
import android.os.Handler
|
||||
import android.view.KeyEvent
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.CapturingSlot
|
||||
import io.mockk.Runs
|
||||
import io.mockk.clearMocks
|
||||
@@ -34,6 +36,9 @@ class MediaControlTest : BaseTest() {
|
||||
private lateinit var handler: Handler
|
||||
private lateinit var playbackCallbackSlot: CapturingSlot<AudioManager.AudioPlaybackCallback>
|
||||
private lateinit var initRunnableSlot: CapturingSlot<Runnable>
|
||||
private lateinit var focusRequest: AudioFocusRequest
|
||||
private lateinit var focusRequestFactory: MediaControl.DuckFocusRequestFactory
|
||||
private lateinit var focusListenerSlot: CapturingSlot<AudioManager.OnAudioFocusChangeListener>
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
@@ -52,7 +57,13 @@ class MediaControlTest : BaseTest() {
|
||||
handler = mockk()
|
||||
initRunnableSlot = slot()
|
||||
every { handler.post(capture(initRunnableSlot)) } returns true
|
||||
mediaControl = MediaControl(audioManager, timeSource, handler)
|
||||
// AudioFocusRequest.Builder is an unmocked android.jar stub, so the request is handed to
|
||||
// MediaControl through the injected factory instead of being built inside it.
|
||||
focusRequest = mockk()
|
||||
focusListenerSlot = slot()
|
||||
focusRequestFactory = mockk()
|
||||
every { focusRequestFactory.create(capture(focusListenerSlot)) } returns focusRequest
|
||||
mediaControl = MediaControl(audioManager, timeSource, handler, focusRequestFactory)
|
||||
// Drain the init runnable so `playbackCallbackSlot` is populated for `fireCallback()`.
|
||||
initRunnableSlot.captured.run()
|
||||
// Everything posted after init (the pause arm) runs inline and synchronously, which keeps
|
||||
@@ -456,7 +467,7 @@ class MediaControlTest : BaseTest() {
|
||||
val freshHandler = mockk<Handler>()
|
||||
every { freshHandler.post(any()) } returns true
|
||||
|
||||
MediaControl(freshAudioManager, timeSource, freshHandler)
|
||||
MediaControl(freshAudioManager, timeSource, freshHandler, focusRequestFactory)
|
||||
|
||||
verify(exactly = 0) { freshAudioManager.isMusicActive }
|
||||
verify(exactly = 0) { freshAudioManager.registerAudioPlaybackCallback(any(), any()) }
|
||||
@@ -474,7 +485,7 @@ class MediaControlTest : BaseTest() {
|
||||
val runnableSlot = slot<Runnable>()
|
||||
every { freshHandler.post(capture(runnableSlot)) } returns true
|
||||
|
||||
MediaControl(freshAudioManager, timeSource, freshHandler)
|
||||
MediaControl(freshAudioManager, timeSource, freshHandler, focusRequestFactory)
|
||||
runnableSlot.captured.run()
|
||||
|
||||
verifyOrder {
|
||||
@@ -501,7 +512,7 @@ class MediaControlTest : BaseTest() {
|
||||
true
|
||||
}
|
||||
|
||||
val undrained = MediaControl(freshAudioManager, timeSource, freshHandler)
|
||||
val undrained = MediaControl(freshAudioManager, timeSource, freshHandler, focusRequestFactory)
|
||||
|
||||
assertFalse(undrained.wasRecentlyPausedByCap)
|
||||
|
||||
@@ -515,4 +526,154 @@ class MediaControlTest : BaseTest() {
|
||||
assertFalse(undrained.wasRecentlyPausedByCap)
|
||||
verify(exactly = 2) { freshAudioManager.dispatchMediaKeyEvent(any()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The read-back deliberately differs from the requested target: Bluetooth absolute-volume routes
|
||||
* quantize, and the caller has to restore against what actually landed, not what was asked for.
|
||||
*/
|
||||
@Test
|
||||
fun `duckMusicVolume reports the level the system actually applied`() {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
every { audioManager.isVolumeFixed } returns false
|
||||
every { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } returns 100
|
||||
every { audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) } returnsMany listOf(40, 22)
|
||||
|
||||
mediaControl.duckMusicVolume(50) shouldBe MediaControl.DuckOutcome.Ducked(priorVolume = 40, appliedVolume = 22)
|
||||
|
||||
verify { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, 20, 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A volume that came back *higher* (user raised it between the two reads) is not a refusal: it
|
||||
* must map to [MediaControl.DuckOutcome.Skipped], not `Unchanged`, or the caller would chase the
|
||||
* audio-focus fallback on a device whose volume writes work fine.
|
||||
*/
|
||||
@Test
|
||||
fun `duckMusicVolume treats a raised volume as skipped, not a refusal`() {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
every { audioManager.isVolumeFixed } returns false
|
||||
every { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } returns 100
|
||||
every { audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) } returnsMany listOf(40, 55)
|
||||
|
||||
mediaControl.duckMusicVolume(50) shouldBe MediaControl.DuckOutcome.Skipped
|
||||
}
|
||||
|
||||
/**
|
||||
* ColorOS 16 accepts `setStreamVolume` from a backgrounded app, raises nothing, and leaves the
|
||||
* volume where it was. That is the case the audio-focus fallback exists for, so it reports
|
||||
* [MediaControl.DuckOutcome.Unchanged] rather than a duck the caller would later "restore".
|
||||
*/
|
||||
@Test
|
||||
fun `duckMusicVolume reports a silently ignored write as unchanged`() {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
every { audioManager.isVolumeFixed } returns false
|
||||
every { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } returns 100
|
||||
every { audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) } returnsMany listOf(40, 40)
|
||||
|
||||
mediaControl.duckMusicVolume(100) shouldBe MediaControl.DuckOutcome.Unchanged(priorVolume = 40)
|
||||
|
||||
verify { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, 0, 0) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duckMusicVolume skips when nothing is playing`() {
|
||||
every { audioManager.isMusicActive } returns false
|
||||
|
||||
mediaControl.duckMusicVolume(50) shouldBe MediaControl.DuckOutcome.Skipped
|
||||
|
||||
verify(exactly = 0) { audioManager.setStreamVolume(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duckMusicVolume skips on fixed-volume devices`() {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
every { audioManager.isVolumeFixed } returns true
|
||||
|
||||
mediaControl.duckMusicVolume(50) shouldBe MediaControl.DuckOutcome.Skipped
|
||||
|
||||
verify(exactly = 0) { audioManager.setStreamVolume(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duckMusicVolume skips when the reduction leaves no headroom`() {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
every { audioManager.isVolumeFixed } returns false
|
||||
every { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } returns 100
|
||||
every { audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) } returns 0
|
||||
|
||||
mediaControl.duckMusicVolume(50) shouldBe MediaControl.DuckOutcome.Skipped
|
||||
|
||||
verify(exactly = 0) { audioManager.setStreamVolume(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestDuckFocus reports a granted request as held`() {
|
||||
every { audioManager.requestAudioFocus(focusRequest) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
|
||||
mediaControl.requestDuckFocus() shouldBe true
|
||||
mediaControl.isDuckFocusHeld shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestDuckFocus reports a denied request and retries on the next call`() {
|
||||
every { audioManager.requestAudioFocus(focusRequest) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED
|
||||
|
||||
mediaControl.requestDuckFocus() shouldBe false
|
||||
mediaControl.isDuckFocusHeld shouldBe false
|
||||
|
||||
// A denial leaves nothing held, so the next attempt must issue a fresh request.
|
||||
every { audioManager.requestAudioFocus(focusRequest) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
mediaControl.requestDuckFocus() shouldBe true
|
||||
mediaControl.isDuckFocusHeld shouldBe true
|
||||
|
||||
verify(exactly = 2) { audioManager.requestAudioFocus(focusRequest) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requestDuckFocus is idempotent while focus is held`() {
|
||||
every { audioManager.requestAudioFocus(focusRequest) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
|
||||
mediaControl.requestDuckFocus() shouldBe true
|
||||
mediaControl.requestDuckFocus() shouldBe true
|
||||
|
||||
verify(exactly = 1) { audioManager.requestAudioFocus(focusRequest) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `abandonDuckFocus only abandons what is actually held`() {
|
||||
// Not held: abandoning must not touch the audio system at all.
|
||||
mediaControl.abandonDuckFocus()
|
||||
verify(exactly = 0) { audioManager.abandonAudioFocusRequest(any()) }
|
||||
|
||||
every { audioManager.requestAudioFocus(focusRequest) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
mediaControl.requestDuckFocus() shouldBe true
|
||||
|
||||
mediaControl.abandonDuckFocus()
|
||||
mediaControl.abandonDuckFocus()
|
||||
mediaControl.isDuckFocusHeld shouldBe false
|
||||
verify(exactly = 1) { audioManager.abandonAudioFocusRequest(focusRequest) }
|
||||
|
||||
// Re-requesting after an abandon starts a new request rather than reusing the stale state.
|
||||
mediaControl.requestDuckFocus() shouldBe true
|
||||
mediaControl.isDuckFocusHeld shouldBe true
|
||||
verify(exactly = 2) { audioManager.requestAudioFocus(focusRequest) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the focus listener drops the held state on a permanent loss only`() {
|
||||
every { audioManager.requestAudioFocus(focusRequest) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
mediaControl.requestDuckFocus() shouldBe true
|
||||
val listener = focusListenerSlot.captured
|
||||
|
||||
// A transient loss is temporary — the request stays valid and we still hold it.
|
||||
listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)
|
||||
mediaControl.isDuckFocusHeld shouldBe true
|
||||
|
||||
listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS)
|
||||
mediaControl.isDuckFocusHeld shouldBe false
|
||||
|
||||
// Nothing left to release: the system already took it.
|
||||
mediaControl.abandonDuckFocus()
|
||||
verify(exactly = 0) { audioManager.abandonAudioFocusRequest(any()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import eu.darken.capod.monitor.core.battery.DrainProfile
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
@@ -307,6 +308,23 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
coVerify { aapManager.sendCommand(testAddress, AapCommand.SetDeviceName("NewName")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setCustomEq sends exactly one SetCustomEq carrying the drafted tuple`() = runVmTest {
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
vm.setCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 55, high = 90)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
aapManager.sendCommand(
|
||||
testAddress,
|
||||
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 55, high = 90),
|
||||
)
|
||||
}
|
||||
coVerify(exactly = 1) { aapManager.sendCommand(any(), any<AapCommand.SetCustomEq>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setDeviceName when no target address is a no-op`() = runVmTest {
|
||||
val vm = createViewModel()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package eu.darken.capod.main.ui.devicesettings.cards
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
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 org.junit.Test
|
||||
import testhelpers.compose.BaseComposeRobolectricTest
|
||||
|
||||
/**
|
||||
* AapOutboundController queues every ear-gated command while no pod is worn, and the settings
|
||||
* coordinator collapses repeated ones to the latest tuple, so a tap made with the pods out would
|
||||
* emit its packet at some unrelated later moment. That destroys the logcat observation window this
|
||||
* debug card exists to produce, hence Apply has to be unavailable while a write would be queued.
|
||||
*
|
||||
* The controller only gates when the AAP EarDetection setting is present, so with no such report
|
||||
* the write goes out immediately and Apply must stay available.
|
||||
*/
|
||||
class CustomEqDebugCardTest : BaseComposeRobolectricTest() {
|
||||
|
||||
private val applyButton = hasText("Apply") and hasClickAction()
|
||||
|
||||
private var applies = 0
|
||||
|
||||
private fun device(earDetection: AapSetting.EarDetection?) = PodDevice(
|
||||
profileId = "test",
|
||||
ble = null,
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
settings = earDetection?.let { mapOf(AapSetting.EarDetection::class to it) } ?: emptyMap(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun setContent(earDetection: AapSetting.EarDetection?) {
|
||||
composeRule.setContent {
|
||||
PreviewWrapper {
|
||||
// Scrollable, because the card is taller than the test window and the Apply row
|
||||
// sits at its bottom. Without a scroll the taps would land off-screen and every
|
||||
// "no command was sent" assertion would pass for the wrong reason.
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
CustomEqDebugCard(
|
||||
device = device(earDetection),
|
||||
enabled = true,
|
||||
onApply = { _, _, _, _ -> applies++ },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clickApply() {
|
||||
composeRule.onNode(applyButton).performScrollTo().performClick()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply is available while a pod is in ear`() {
|
||||
setContent(
|
||||
AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||
),
|
||||
)
|
||||
|
||||
composeRule.onNode(applyButton).assertIsEnabled()
|
||||
composeRule.onNodeWithText(NOT_IN_EAR_NOTICE).assertDoesNotExist()
|
||||
|
||||
clickApply()
|
||||
composeRule.runOnIdle { applies shouldBe 1 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply is unavailable with both pods out, with a notice next to the button`() {
|
||||
setContent(
|
||||
AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
|
||||
),
|
||||
)
|
||||
|
||||
composeRule.onNode(applyButton).assertIsNotEnabled()
|
||||
composeRule.onNodeWithText(NOT_IN_EAR_NOTICE).assertExists()
|
||||
|
||||
clickApply()
|
||||
composeRule.runOnIdle { applies shouldBe 0 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply is available while no ear detection is reported at all`() {
|
||||
// No 0x06 report yet, so AapOutboundController's ear gate does not engage and the write
|
||||
// is sent right away. Blocking here would be both pointless and factually wrong.
|
||||
setContent(null)
|
||||
|
||||
composeRule.onNode(applyButton).assertIsEnabled()
|
||||
composeRule.onNodeWithText(NOT_IN_EAR_NOTICE).assertDoesNotExist()
|
||||
|
||||
clickApply()
|
||||
composeRule.runOnIdle { applies shouldBe 1 }
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ class PodDeviceAncModeTest : BaseTest() {
|
||||
fun `cycle mask hides OFF when OFF is not allowed`() {
|
||||
visibleAncModes(
|
||||
supportedModes = allModes,
|
||||
currentMode = AapSetting.AncMode.Value.ON,
|
||||
cycleMask = 0x0E,
|
||||
allowOffEnabled = false,
|
||||
) shouldContainExactly listOf(
|
||||
@@ -35,27 +34,30 @@ class PodDeviceAncModeTest : BaseTest() {
|
||||
fun `allow off keeps OFF visible even when cycle mask excludes it`() {
|
||||
visibleAncModes(
|
||||
supportedModes = allModes,
|
||||
currentMode = AapSetting.AncMode.Value.ON,
|
||||
cycleMask = 0x0E,
|
||||
allowOffEnabled = true,
|
||||
) shouldContainExactly allModes
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `current OFF stays visible even when OFF is otherwise hidden`() {
|
||||
fun `current OFF is NOT re-admitted when OFF is otherwise hidden`() {
|
||||
// Regression: a device reporting a mode outside its own cycle used to conjure an extra
|
||||
// selector button. AirPods Pro 3 do exactly that, answering an Adaptive write with OFF.
|
||||
visibleAncModes(
|
||||
supportedModes = allModes,
|
||||
currentMode = AapSetting.AncMode.Value.OFF,
|
||||
cycleMask = 0x0E,
|
||||
allowOffEnabled = false,
|
||||
) shouldContainExactly allModes
|
||||
) shouldContainExactly listOf(
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null cycle mask shows all supported modes`() {
|
||||
visibleAncModes(
|
||||
supportedModes = allModes,
|
||||
currentMode = AapSetting.AncMode.Value.ON,
|
||||
cycleMask = null,
|
||||
allowOffEnabled = false,
|
||||
) shouldContainExactly allModes
|
||||
@@ -65,7 +67,6 @@ class PodDeviceAncModeTest : BaseTest() {
|
||||
fun `cycle mask with OFF bit set includes OFF`() {
|
||||
visibleAncModes(
|
||||
supportedModes = allModes,
|
||||
currentMode = AapSetting.AncMode.Value.ON,
|
||||
cycleMask = 0x0F,
|
||||
allowOffEnabled = false,
|
||||
) shouldContainExactly allModes
|
||||
|
||||
@@ -186,6 +186,198 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── PME Config / Headphone Accommodations (0x53) ────────
|
||||
|
||||
@Nested
|
||||
inner class PmeConfigTests {
|
||||
|
||||
/**
|
||||
* Build a 0x53 frame: 4 unknown header bytes, the two apply-to flags at
|
||||
* offsets 4 and 5, then 4 × 8 little-endian Float32 band gains.
|
||||
*/
|
||||
private fun pmeMessage(
|
||||
applyToMediaByte: Int,
|
||||
applyToPhoneByte: Int,
|
||||
sets: List<List<Float>>,
|
||||
): AapMessage {
|
||||
val payload = mutableListOf<Byte>(0x00, 0x00, 0x00, 0x00)
|
||||
payload.add(applyToMediaByte.toByte())
|
||||
payload.add(applyToPhoneByte.toByte())
|
||||
for (set in sets) {
|
||||
for (band in set) {
|
||||
val bits = band.toRawBits()
|
||||
for (shift in 0..3) payload.add(((bits shr (shift * 8)) and 0xFF).toByte())
|
||||
}
|
||||
}
|
||||
val header = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x53, 0x00)
|
||||
return AapMessage.parse(header + payload.toByteArray())!!
|
||||
}
|
||||
|
||||
private val zeroSets = List(4) { List(8) { 0f } }
|
||||
|
||||
@Test
|
||||
fun `decode both apply-to flags set`() {
|
||||
val config = decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x01, 0x01, zeroSets))
|
||||
config.applyToMedia shouldBe true
|
||||
config.applyToPhone shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode media only`() {
|
||||
val config = decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x01, 0x00, zeroSets))
|
||||
config.applyToMedia shouldBe true
|
||||
config.applyToPhone shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode phone only`() {
|
||||
val config = decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x00, 0x01, zeroSets))
|
||||
config.applyToMedia shouldBe false
|
||||
config.applyToPhone shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode neither flag set`() {
|
||||
val config = decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x00, 0x00, zeroSets))
|
||||
config.applyToMedia shouldBe false
|
||||
config.applyToPhone shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flags are plain 0x01 flags, not Apple-bool`() {
|
||||
// Apple-bool would read 0x02 as "false" too, but so does a plain flag check —
|
||||
// what matters is that anything other than 0x01 is false, including 0x02.
|
||||
val config = decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x02, 0x02, zeroSets))
|
||||
config.applyToMedia shouldBe false
|
||||
config.applyToPhone shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `band data still decodes from offset 6`() {
|
||||
val sets = List(4) { setIndex -> List(8) { band -> (setIndex * 8 + band).toFloat() + 0.5f } }
|
||||
val config = decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x01, 0x00, sets))
|
||||
config.sets shouldBe sets
|
||||
config.isAllZero shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-zero band data reports isAllZero regardless of flags`() {
|
||||
decodeSetting<AapSetting.PmeConfig>(pmeMessage(0x01, 0x01, zeroSets)).isAllZero shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode rejects truncated payload`() {
|
||||
val header = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x53, 0x00)
|
||||
val short = AapMessage.parse(header + ByteArray(6 + 127))!!
|
||||
profile.decodeSetting(short).shouldBeNull()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom EQ (0x63) ────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class CustomEqTests {
|
||||
@Test
|
||||
fun `decode custom mode frame`() {
|
||||
val eq = decodeSetting<AapSetting.CustomEq>(aapMessage("04 00 04 00 63 00 05 00 01 02 0A 32 64"))
|
||||
eq.mode shouldBe AapSetting.CustomEq.Mode.CUSTOM
|
||||
eq.low shouldBe 10
|
||||
eq.mid shouldBe 50
|
||||
eq.high shouldBe 100
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode recommended mode frame`() {
|
||||
val eq = decodeSetting<AapSetting.CustomEq>(aapMessage("04 00 04 00 63 00 05 00 01 01 32 32 32"))
|
||||
eq.mode shouldBe AapSetting.CustomEq.Mode.RECOMMENDED
|
||||
eq.low shouldBe 50
|
||||
eq.mid shouldBe 50
|
||||
eq.high shouldBe 50
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode rejects wrong declared length`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 63 00 04 00 01 02 0A 32 64")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode rejects unknown marker byte`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 02 02 0A 32 64")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode rejects unknown mode`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 03 0A 32 64")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode rejects band above 100`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 02 0A 65 64")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode rejects truncated payload`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 02 0A 32")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode rejects trailing extra bytes`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 63 00 05 00 01 02 0A 32 64 00")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode locks in the full wire format`() {
|
||||
val bytes = profile.encodeCommand(
|
||||
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 50, high = 100),
|
||||
)
|
||||
val expected = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00,
|
||||
0x63, 0x00,
|
||||
0x05, 0x00,
|
||||
0x01,
|
||||
0x02,
|
||||
0x0A, 0x32, 0x64,
|
||||
)
|
||||
bytes shouldBe expected
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode recommended mode`() {
|
||||
val bytes = profile.encodeCommand(
|
||||
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.RECOMMENDED, low = 0, mid = 0, high = 0),
|
||||
)
|
||||
bytes shouldBe byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00,
|
||||
0x63, 0x00,
|
||||
0x05, 0x00,
|
||||
0x01,
|
||||
0x01,
|
||||
0x00, 0x00, 0x00,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `command rejects band below range`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = -1, mid = 50, high = 50)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `command rejects band above range`() {
|
||||
assertThrows<IllegalArgumentException> {
|
||||
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 50, mid = 101, high = 50)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `command accepts range boundaries`() {
|
||||
val command = AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 0, mid = 100, high = 0)
|
||||
command.low shouldBe 0
|
||||
command.mid shouldBe 100
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stem Press Events (0x19) ────────────────────────────
|
||||
|
||||
@Nested
|
||||
|
||||
@@ -412,7 +412,7 @@ class AapSessionEngineTest : BaseTest() {
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
|
||||
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||
|
||||
advanceTimeBy(1100L)
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS + 100L)
|
||||
|
||||
sentCommands.size shouldBe 3
|
||||
sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
|
||||
@@ -724,6 +724,140 @@ class AapSessionEngineTest : BaseTest() {
|
||||
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contradicting OFF report during a pending ANC request blocks AllowOff inference`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { }
|
||||
|
||||
// AirPods Pro 3 firmware answering an ADAPTIVE write with OFF. Must not be taken as
|
||||
// evidence that OFF is a permitted mode.
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(1600L)
|
||||
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsolicited OFF after a contradicted one still infers AllowOffOption true`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { }
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
// Let the request finish failing, so nothing of ours is outstanding any more.
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
engine.state.value.pendingAncMode.shouldBeNull()
|
||||
|
||||
// Now a genuine switch into OFF (stem press / another phone) must still train it.
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(current = AapSetting.AncMode.Value.OFF, supported = supportedModes)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(1600L)
|
||||
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated setting report does not prematurely confirm a non-ANC command`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
val rejected = mutableListOf<AapCommand>()
|
||||
val collectJob = launch { engine.settingRejected.collect { rejected += it } }
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
nextSetting = settingPair(AapSetting.ConversationalAwareness(enabled = false))
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
engine.send(AapCommand.SetConversationalAwareness(true)) { }
|
||||
|
||||
// An unrelated frame must not settle the outstanding verification: the optimistic
|
||||
// write already satisfies its predicate, so doing so would swallow the rejection.
|
||||
nextSetting = settingPair(
|
||||
AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
// The device then contradicts the write.
|
||||
nextSetting = settingPair(AapSetting.ConversationalAwareness(enabled = false))
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
rejected shouldBe listOf(AapCommand.SetConversationalAwareness(true))
|
||||
collectJob.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected OFF command infers AllowOffOption false`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
@@ -768,7 +902,7 @@ class AapSessionEngineTest : BaseTest() {
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(2100L)
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
engine.state.value.pendingAncMode.shouldBeNull()
|
||||
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe false
|
||||
sentCommands shouldBe listOf(
|
||||
@@ -812,7 +946,7 @@ class AapSessionEngineTest : BaseTest() {
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(2100L)
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
rejected.size shouldBe 1
|
||||
collectJob.cancel()
|
||||
}
|
||||
@@ -852,7 +986,7 @@ class AapSessionEngineTest : BaseTest() {
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(2100L)
|
||||
advanceTimeBy(AapOutboundController.VERIFICATION_TIMEOUT_MS * 2 + 100L)
|
||||
rejected shouldBe emptyList()
|
||||
collectJob.cancel()
|
||||
}
|
||||
|
||||
@@ -259,6 +259,26 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
||||
result.deviceInfo!!.name shouldBe "New Name"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SetCustomEq produces no optimistic update`() {
|
||||
val coord = createCoordinator()
|
||||
val state = stateWithSetting(
|
||||
AapSetting.CustomEq::class to AapSetting.CustomEq(
|
||||
mode = AapSetting.CustomEq.Mode.RECOMMENDED,
|
||||
low = 50,
|
||||
mid = 50,
|
||||
high = 50,
|
||||
),
|
||||
)
|
||||
|
||||
val result = coord.optimisticUpdate(
|
||||
state,
|
||||
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 20, high = 30),
|
||||
)
|
||||
|
||||
result.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not mutate input state`() {
|
||||
val coord = createCoordinator()
|
||||
@@ -281,6 +301,14 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
||||
coord.verificationFor(AapCommand.SetDeviceName("test")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verificationFor returns null for SetCustomEq`() {
|
||||
val coord = createCoordinator()
|
||||
coord.verificationFor(
|
||||
AapCommand.SetCustomEq(AapSetting.CustomEq.Mode.CUSTOM, low = 10, mid = 20, high = 30)
|
||||
).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verificationFor returns correct check for ANC mode`() {
|
||||
val coord = createCoordinator()
|
||||
|
||||
@@ -85,7 +85,7 @@ class ConversationReactionTest : BaseTest() {
|
||||
mediaControl = mockk(relaxed = true) {
|
||||
coEvery { sendPause(any()) } returns true
|
||||
every { isPlaying } returns false
|
||||
every { duckMusicVolume(any()) } returns MediaControl.VolumeDuck(priorVolume = 10, appliedVolume = 5)
|
||||
every { duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Ducked(priorVolume = 10, appliedVolume = 5)
|
||||
every { currentMusicVolume() } returns 5
|
||||
}
|
||||
timeSource = TestTimeSource()
|
||||
@@ -180,6 +180,171 @@ class ConversationReactionTest : BaseTest() {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* A duck that never happened (nothing playing, fixed volume, no headroom, or a volume that came
|
||||
* back higher — every reason maps to `Skipped`, pinned per reason in `MediaControlTest`) must
|
||||
* leave no session behind: nothing to restore on the terminal, and no armed backstop that would
|
||||
* restore a level that was never left. A repeat START retries the duck rather than treating the
|
||||
* dead session as a keep-alive. It must also NOT reach for the audio-focus fallback — that is
|
||||
* only for a write the ROM accepted and ignored.
|
||||
*/
|
||||
@Test
|
||||
fun `LOWER_VOLUME skipped duck arms nothing and never restores`() = runTest(UnconfinedTestDispatcher()) {
|
||||
every { mediaControl.duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Skipped
|
||||
val job = launchReaction()
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
verify(exactly = 1) { mediaControl.duckMusicVolume(50) }
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
verify(exactly = 2) { mediaControl.duckMusicVolume(50) }
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.STOP)
|
||||
advanceBoth(stopSettleMs + 50)
|
||||
advanceBoth(staleTimeoutMs + 500)
|
||||
|
||||
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) }
|
||||
verify(exactly = 0) { mediaControl.requestDuckFocus() }
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of the focus fallback: a working duck must never request audio focus. The
|
||||
* [mediaControl] mock is relaxed, so an accidental request would otherwise pass silently.
|
||||
*/
|
||||
@Test
|
||||
fun `LOWER_VOLUME successful duck never requests audio focus`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val job = launchReaction()
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
emit(primaryAddress, ConversationAwarenessEvent.STOP)
|
||||
advanceBoth(stopSettleMs + 50)
|
||||
|
||||
verify(exactly = 1) { mediaControl.restoreMusicVolume(10) }
|
||||
verify(exactly = 0) { mediaControl.requestDuckFocus() }
|
||||
verify(exactly = 0) { mediaControl.abandonDuckFocus() }
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* ColorOS 16 accepts `setStreamVolume` from a backgrounded app and leaves the level untouched.
|
||||
* The reaction then holds ducking audio focus for the conversation and releases it at the end.
|
||||
*/
|
||||
@Test
|
||||
fun `LOWER_VOLUME unchanged duck falls back to audio focus`() = runTest(UnconfinedTestDispatcher()) {
|
||||
every { mediaControl.duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Unchanged(priorVolume = 10)
|
||||
every { mediaControl.requestDuckFocus() } returns true
|
||||
every { mediaControl.currentMusicVolume() } returns 10 // the write really never landed
|
||||
val job = launchReaction()
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
verify(exactly = 1) { mediaControl.requestDuckFocus() }
|
||||
verify(exactly = 0) { mediaControl.abandonDuckFocus() }
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.STOP) // cold terminal → settles briefly
|
||||
advanceBoth(stopSettleMs + 50)
|
||||
|
||||
verify(exactly = 1) { mediaControl.abandonDuckFocus() }
|
||||
// Nothing was ever lowered, so there is nothing to restore.
|
||||
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) }
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus denied on top of an ignored volume write: nothing was attenuated, so no session may be
|
||||
* recorded — otherwise the next START would be a keep-alive on a dead session and the backstop
|
||||
* would later "release" focus we never held.
|
||||
*/
|
||||
@Test
|
||||
fun `LOWER_VOLUME unchanged duck with denied focus arms nothing`() = runTest(UnconfinedTestDispatcher()) {
|
||||
every { mediaControl.duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Unchanged(priorVolume = 10)
|
||||
every { mediaControl.requestDuckFocus() } returns false
|
||||
val job = launchReaction()
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
verify(exactly = 1) { mediaControl.requestDuckFocus() }
|
||||
|
||||
// Retried from scratch rather than treated as a keep-alive.
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
verify(exactly = 2) { mediaControl.duckMusicVolume(50) }
|
||||
verify(exactly = 2) { mediaControl.requestDuckFocus() }
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.STOP)
|
||||
advanceBoth(stopSettleMs + 50)
|
||||
advanceBoth(staleTimeoutMs + 500)
|
||||
|
||||
verify(exactly = 0) { mediaControl.abandonDuckFocus() }
|
||||
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) }
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LOWER_VOLUME focus session releases focus when the owner disappears`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
every { mediaControl.duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Unchanged(priorVolume = 10)
|
||||
every { mediaControl.requestDuckFocus() } returns true
|
||||
every { mediaControl.currentMusicVolume() } returns 10
|
||||
val job = launchReaction()
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
statesFlow.value = emptyMap() // device disconnected before STOP arrived
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { mediaControl.abandonDuckFocus() }
|
||||
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) }
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Late-write guard: a device may apply the volume write asynchronously, after the read-back that
|
||||
* showed equality and sent us down the focus path. Teardown would otherwise leave the stream
|
||||
* index permanently lowered, so a level below the pre-duck one is restored.
|
||||
*/
|
||||
@Test
|
||||
fun `LOWER_VOLUME focus session restores a volume write that landed late`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
every { mediaControl.duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Unchanged(priorVolume = 10)
|
||||
every { mediaControl.requestDuckFocus() } returns true
|
||||
every { mediaControl.currentMusicVolume() } returns 5 // the write landed after all
|
||||
val job = launchReaction()
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
emit(primaryAddress, ConversationAwarenessEvent.STOP)
|
||||
advanceBoth(stopSettleMs + 50)
|
||||
|
||||
verify(exactly = 1) { mediaControl.abandonDuckFocus() }
|
||||
verify(exactly = 1) { mediaControl.restoreMusicVolume(10) }
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* A permanent focus loss mid-conversation is only recoverable on a later START — every one of
|
||||
* them is a keep-alive for the running session, so the keep-alive path has to re-request.
|
||||
*/
|
||||
@Test
|
||||
fun `LOWER_VOLUME keep-alive re-requests focus that was permanently lost`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
every { mediaControl.duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Unchanged(priorVolume = 10)
|
||||
every { mediaControl.requestDuckFocus() } returns true
|
||||
every { mediaControl.currentMusicVolume() } returns 10
|
||||
every { mediaControl.isDuckFocusHeld } returns true
|
||||
val job = launchReaction()
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
verify(exactly = 1) { mediaControl.requestDuckFocus() }
|
||||
|
||||
// Still held → the keep-alive must not re-request.
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
verify(exactly = 1) { mediaControl.requestDuckFocus() }
|
||||
|
||||
every { mediaControl.isDuckFocusHeld } returns false
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
verify(exactly = 2) { mediaControl.requestDuckFocus() }
|
||||
// Still the same session — no second duck attempt.
|
||||
verify(exactly = 1) { mediaControl.duckMusicVolume(50) }
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LOWER_VOLUME missed STOP restores via stale timeout`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val job = launchReaction()
|
||||
|
||||
@@ -13,6 +13,7 @@ import eu.darken.capod.common.upgrade.core.billing.ItemAlreadyOwnedBillingExcept
|
||||
import eu.darken.capod.common.upgrade.core.billing.PendingPurchaseBillingException
|
||||
import eu.darken.capod.common.upgrade.core.billing.PurchasedSku
|
||||
import eu.darken.capod.common.upgrade.core.billing.UserCanceledBillingException
|
||||
import eu.darken.capod.common.upgrade.core.billing.work.PurchaseAckScheduler
|
||||
import eu.darken.capod.main.core.CurriculumVitae
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.shouldBe
|
||||
@@ -55,6 +56,7 @@ class UpgradeRepoGplayTest : BaseTest() {
|
||||
private val billingManager = mockk<BillingManager>()
|
||||
private val billingCache = mockk<BillingCache>()
|
||||
private val curriculumVitae = mockk<CurriculumVitae>(relaxed = true)
|
||||
private val ackScheduler = mockk<PurchaseAckScheduler>(relaxed = true)
|
||||
private lateinit var lastProAtMock: DataStoreValue<Long>
|
||||
private lateinit var lastProSkuMock: DataStoreValue<String>
|
||||
private lateinit var proUnconfirmedMock: DataStoreValue<Long>
|
||||
@@ -102,7 +104,7 @@ class UpgradeRepoGplayTest : BaseTest() {
|
||||
}
|
||||
every { billingCache.proUnconfirmedSince } returns proUnconfirmedMock
|
||||
coJustRun { billingCache.stampLastProState(any(), any()) }
|
||||
return UpgradeRepoGplay(scope, billingManager, billingCache, curriculumVitae)
|
||||
return UpgradeRepoGplay(scope, billingManager, billingCache, curriculumVitae, ackScheduler)
|
||||
}
|
||||
|
||||
private fun result(code: Int): BillingResult = BillingResult.newBuilder().setResponseCode(code).build()
|
||||
@@ -1024,5 +1026,34 @@ class UpgradeRepoGplayTest : BaseTest() {
|
||||
repo.autoRestoreBusy.first() shouldBe false
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region ack safety net
|
||||
|
||||
@Test fun `launching a billing flow arms the persistent ack safety net first`() = runTest2 {
|
||||
val order = mutableListOf<String>()
|
||||
coEvery { ackScheduler.armForBillingFlowLaunch() } coAnswers { order.add("arm") }
|
||||
coEvery { billingManager.startIapFlow(any(), any(), null) } coAnswers { order.add("launch") }
|
||||
|
||||
repo(lastProAt = 0L).startLaunch()
|
||||
|
||||
// Armed (and awaited) BEFORE the Play sheet can open: the process may die around the sheet,
|
||||
// and the WorkManager transaction has to land first to be worth anything.
|
||||
order shouldBe listOf("arm", "launch")
|
||||
}
|
||||
|
||||
@Test fun `a failing safety net arm never blocks the purchase flow`() = runTest2 {
|
||||
coEvery { ackScheduler.armForBillingFlowLaunch() } throws RuntimeException("workmanager broken")
|
||||
coJustRun { billingManager.startIapFlow(any(), any(), null) }
|
||||
|
||||
val errors = mutableListOf<Throwable>()
|
||||
repo(lastProAt = 0L).startLaunch { errors.add(it) }
|
||||
|
||||
// The net is best-effort: the foreground ack path still exists, the purchase must proceed.
|
||||
errors shouldBe emptyList()
|
||||
coVerify { billingManager.startIapFlow(any(), any(), null) }
|
||||
}
|
||||
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ import eu.darken.capod.common.upgrade.core.OurSku
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.BillingClientException
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.BillingConnection
|
||||
import eu.darken.capod.common.upgrade.core.billing.client.BillingConnectionProvider
|
||||
import eu.darken.capod.common.upgrade.core.billing.work.PurchaseAckScheduler
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.collections.shouldNotContain
|
||||
import io.kotest.matchers.ints.shouldBeGreaterThan
|
||||
import io.kotest.matchers.longs.shouldBeLessThan
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.coEvery
|
||||
@@ -52,6 +54,10 @@ import testhelpers.coroutine.runTest2
|
||||
|
||||
class BillingManagerTest : BaseTest() {
|
||||
|
||||
// Relaxed: the safety net is fail-open plumbing around the ack pass — only the dedicated
|
||||
// tests below assert on it.
|
||||
private val ackScheduler = mockk<PurchaseAckScheduler>(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkObject(Bugs)
|
||||
@@ -158,10 +164,10 @@ class BillingManagerTest : BaseTest() {
|
||||
}
|
||||
|
||||
private fun TestScope.manager(connection: BillingConnection): BillingManager =
|
||||
BillingManager(backgroundScope, providerOf(connection))
|
||||
BillingManager(backgroundScope, providerOf(connection), ackScheduler)
|
||||
|
||||
private fun TestScope.manager(provider: BillingConnectionProvider): BillingManager =
|
||||
BillingManager(backgroundScope, provider)
|
||||
BillingManager(backgroundScope, provider, ackScheduler)
|
||||
|
||||
// region launch failure mapping
|
||||
|
||||
@@ -1249,7 +1255,7 @@ class BillingManagerTest : BaseTest() {
|
||||
val conn = connection(purchasesFlow = purchases)
|
||||
val acks = conn.scriptAck { _, _ -> awaitCancellation() }
|
||||
val ackScope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
BillingManager(ackScope, providerOf(conn))
|
||||
BillingManager(ackScope, providerOf(conn), ackScheduler)
|
||||
runCurrent()
|
||||
|
||||
purchases.tryEmit(listOf(unacked))
|
||||
@@ -1360,4 +1366,106 @@ class BillingManagerTest : BaseTest() {
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region ack safety net sweep (ensureAllAcknowledged)
|
||||
|
||||
@Test fun `sweep acknowledges what its refresh returned and reports COMPLETE`() = runTest2 {
|
||||
val unacked = unackedPurchase("token-sweep")
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), completeRefresh(listOf(unacked))))
|
||||
val acks = conn.scriptAck { _, _ -> result(BillingResponseCode.OK) }
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
// The ack happens IN this call, not via the async collector: the worker needs the
|
||||
// happens-before to report success.
|
||||
manager.ensureAllAcknowledged() shouldBe BillingManager.AckSweepResult.COMPLETE
|
||||
acks.map { it.purchaseToken } shouldBe listOf("token-sweep")
|
||||
}
|
||||
|
||||
@Test fun `sweep with nothing to acknowledge reports COMPLETE`() = runTest2 {
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), completeRefresh(listOf(purchase()))))
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
manager.ensureAllAcknowledged() shouldBe BillingManager.AckSweepResult.COMPLETE
|
||||
}
|
||||
|
||||
@Test fun `sweep reports RETRY when acks keep failing transiently`() = runTest2 {
|
||||
val unacked = unackedPurchase("token-sweep")
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), completeRefresh(listOf(unacked))))
|
||||
conn.scriptAck { _, _ -> transientAckFailure() }
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
manager.ensureAllAcknowledged() shouldBe BillingManager.AckSweepResult.RETRY
|
||||
}
|
||||
|
||||
@Test fun `sweep reports PERMANENT_FAILURE on a permanently rejected ack`() = runTest2 {
|
||||
val unacked = unackedPurchase("token-sweep")
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), completeRefresh(listOf(unacked))))
|
||||
conn.scriptAck { _, _ -> throw BillingClientException(result(BillingResponseCode.DEVELOPER_ERROR)) }
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
manager.ensureAllAcknowledged() shouldBe BillingManager.AckSweepResult.PERMANENT_FAILURE
|
||||
}
|
||||
|
||||
@Test fun `sweep reports RETRY on an incomplete refresh even with nothing to ack`() = runTest2 {
|
||||
val conn = connection(refreshes = listOf(completeRefresh(), partialRefresh()))
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
|
||||
// A failed product-type query may be hiding an unacknowledged purchase of that type: the
|
||||
// worker must come back instead of reporting the net complete.
|
||||
manager.ensureAllAcknowledged() shouldBe BillingManager.AckSweepResult.RETRY
|
||||
}
|
||||
|
||||
@Test fun `sweep reports RETRY when the refresh itself fails`() = runTest2 {
|
||||
val conn = connection(refreshes = listOf(completeRefresh()))
|
||||
val manager = manager(conn)
|
||||
runCurrent()
|
||||
coEvery { conn.refreshPurchases() } throws BillingException("Play down")
|
||||
|
||||
manager.ensureAllAcknowledged() shouldBe BillingManager.AckSweepResult.RETRY
|
||||
}
|
||||
|
||||
@Test fun `an ack pass arms the safety net before attempting, with the newest refund deadline`() = runTest2 {
|
||||
val order = mutableListOf<String>()
|
||||
coEvery { ackScheduler.armForUnackedPurchases(any()) } coAnswers { order.add("arm:${firstArg<Long>()}") }
|
||||
val purchases = purchasesFlow()
|
||||
val conn = connection(purchasesFlow = purchases)
|
||||
conn.scriptAck { _, _ ->
|
||||
order.add("ack")
|
||||
transientAckFailure()
|
||||
}
|
||||
manager(conn)
|
||||
runCurrent()
|
||||
|
||||
purchases.tryEmit(listOf(unackedPurchase("token-1", time = 5_000L), unackedPurchase("token-2", time = 9_000L)))
|
||||
// runCurrent, NOT advanceUntilIdle: the scripted ack keeps failing, so idle-advancing would
|
||||
// spin through the 5-minute re-drive cycles forever. The first attempt runs undelayed.
|
||||
runCurrent()
|
||||
|
||||
// Armed (and awaited) BEFORE the first attempt: a process death during the inline retries
|
||||
// must still leave the persistent net in place. Deadline derives from the NEWEST purchase.
|
||||
order.first() shouldBe "arm:${9_000L + BillingManager.ACK_SAFETY_NET_DEADLINE_MS}"
|
||||
order.count { it == "ack" } shouldBeGreaterThan 0
|
||||
}
|
||||
|
||||
@Test fun `a failing safety net arm never blocks the ack pass`() = runTest2 {
|
||||
coEvery { ackScheduler.armForUnackedPurchases(any()) } throws RuntimeException("workmanager broken")
|
||||
val purchases = purchasesFlow()
|
||||
val conn = connection(purchasesFlow = purchases)
|
||||
val acks = conn.scriptAck { _, _ -> result(BillingResponseCode.OK) }
|
||||
manager(conn)
|
||||
runCurrent()
|
||||
|
||||
purchases.tryEmit(listOf(unackedPurchase("token-1")))
|
||||
runCurrent()
|
||||
|
||||
// The net is an extra layer: WorkManager being broken must never stop the ack itself.
|
||||
acks.map { it.purchaseToken } shouldBe listOf("token-1")
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package eu.darken.capod.common.upgrade.core.billing.work
|
||||
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.Operation
|
||||
import androidx.work.WorkManager
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldEndWith
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import testhelpers.coroutine.runTest2
|
||||
import javax.inject.Provider
|
||||
|
||||
class PurchaseAckSchedulerTest : BaseTest() {
|
||||
|
||||
// The enqueue is awaited: hand back an already-settled future so await() takes its fast path.
|
||||
private val enqueueFuture = mockk<ListenableFuture<Operation.State.SUCCESS>>().apply {
|
||||
every { isDone } returns true
|
||||
every { get() } returns mockk()
|
||||
}
|
||||
private val operation = mockk<Operation>().apply {
|
||||
every { result } returns enqueueFuture
|
||||
}
|
||||
private val workManager = mockk<WorkManager>().apply {
|
||||
every {
|
||||
enqueueUniqueWork(any<String>(), any<ExistingWorkPolicy>(), any<OneTimeWorkRequest>())
|
||||
} returns operation
|
||||
}
|
||||
|
||||
private fun create() = PurchaseAckScheduler(
|
||||
workManager = Provider { workManager },
|
||||
)
|
||||
|
||||
@Test fun `a billing flow launch arms the launch watch`() = runTest2 {
|
||||
create().armForBillingFlowLaunch()
|
||||
|
||||
val name = slot<String>()
|
||||
val policy = slot<ExistingWorkPolicy>()
|
||||
verify(exactly = 1) {
|
||||
workManager.enqueueUniqueWork(capture(name), capture(policy), any<OneTimeWorkRequest>())
|
||||
}
|
||||
name.captured shouldEndWith ".gplay.purchase-ack.launch.v1"
|
||||
policy.captured shouldBe ExistingWorkPolicy.REPLACE
|
||||
}
|
||||
|
||||
@Test fun `discovered unacknowledged purchases arm the rescue lane`() = runTest2 {
|
||||
create().armForUnackedPurchases(expiresAt = System.currentTimeMillis() + 60 * 1000L)
|
||||
|
||||
val name = slot<String>()
|
||||
val policy = slot<ExistingWorkPolicy>()
|
||||
verify(exactly = 1) {
|
||||
workManager.enqueueUniqueWork(capture(name), capture(policy), any<OneTimeWorkRequest>())
|
||||
}
|
||||
// A separate identity from the launch watch: a new purchase flow must not displace a
|
||||
// pending rescue for a purchase that already exists.
|
||||
name.captured shouldEndWith ".gplay.purchase-ack.rescue.v1"
|
||||
policy.captured shouldBe ExistingWorkPolicy.KEEP
|
||||
}
|
||||
|
||||
@Test fun `a passed deadline schedules nothing`() = runTest2 {
|
||||
create().armForUnackedPurchases(expiresAt = System.currentTimeMillis() - 60 * 1000L)
|
||||
|
||||
verify(exactly = 0) {
|
||||
workManager.enqueueUniqueWork(any<String>(), any<ExistingWorkPolicy>(), any<OneTimeWorkRequest>())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package eu.darken.capod.common.upgrade.core.billing.work
|
||||
|
||||
import androidx.work.ListenableWorker.Result
|
||||
import eu.darken.capod.common.upgrade.core.billing.BillingManager.AckSweepResult
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class PurchaseAckWorkerTest : BaseTest() {
|
||||
|
||||
@Test fun `sweeping is only worth it before the refund deadline`() {
|
||||
PurchaseAckWorker.isWorthSweeping(now = 100L, expiresAt = 101L) shouldBe true
|
||||
PurchaseAckWorker.isWorthSweeping(now = 100L, expiresAt = 100L) shouldBe false
|
||||
PurchaseAckWorker.isWorthSweeping(now = 100L, expiresAt = 99L) shouldBe false
|
||||
// Malformed input data (missing/zero deadline) must not retry forever.
|
||||
PurchaseAckWorker.isWorthSweeping(now = 100L, expiresAt = 0L) shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `a complete sweep succeeds`() {
|
||||
PurchaseAckWorker.mapSweep(AckSweepResult.COMPLETE, now = 100L, expiresAt = 200L)
|
||||
.shouldBeInstanceOf<Result.Success>()
|
||||
}
|
||||
|
||||
@Test fun `a permanently rejected ack stops the retries`() {
|
||||
PurchaseAckWorker.mapSweep(AckSweepResult.PERMANENT_FAILURE, now = 100L, expiresAt = 200L)
|
||||
.shouldBeInstanceOf<Result.Failure>()
|
||||
}
|
||||
|
||||
@Test fun `transient outcomes retry until the deadline`() {
|
||||
PurchaseAckWorker.mapSweep(AckSweepResult.RETRY, now = 100L, expiresAt = 200L)
|
||||
.shouldBeInstanceOf<Result.Retry>()
|
||||
// null = the sweep timed out: same transient treatment.
|
||||
PurchaseAckWorker.mapSweep(null, now = 100L, expiresAt = 200L)
|
||||
.shouldBeInstanceOf<Result.Retry>()
|
||||
// Past the deadline Play has already refunded: give up visibly.
|
||||
PurchaseAckWorker.mapSweep(AckSweepResult.RETRY, now = 200L, expiresAt = 200L)
|
||||
.shouldBeInstanceOf<Result.Failure>()
|
||||
PurchaseAckWorker.mapSweep(null, now = 200L, expiresAt = 200L)
|
||||
.shouldBeInstanceOf<Result.Failure>()
|
||||
}
|
||||
}
|
||||
@@ -44,13 +44,13 @@ fun DependencyHandlerScope.addBaseKotlin() {
|
||||
fun DependencyHandlerScope.addDagger() {
|
||||
implementation("com.google.dagger:dagger:${Versions.Dagger.core}")
|
||||
implementation("com.google.dagger:dagger-android:${Versions.Dagger.core}")
|
||||
implementation("androidx.hilt:hilt-common:1.0.0")
|
||||
implementation("androidx.hilt:hilt-common:${Versions.AndroidX.Hilt.core}")
|
||||
|
||||
ksp("com.google.dagger:dagger-compiler:${Versions.Dagger.core}")
|
||||
ksp("com.google.dagger:dagger-android-processor:${Versions.Dagger.core}")
|
||||
|
||||
implementation("com.google.dagger:hilt-android:${Versions.Dagger.core}")
|
||||
ksp("androidx.hilt:hilt-compiler:1.0.0")
|
||||
ksp("androidx.hilt:hilt-compiler:${Versions.AndroidX.Hilt.core}")
|
||||
ksp("com.google.dagger:hilt-android-compiler:${Versions.Dagger.core}")
|
||||
|
||||
testImplementation("com.google.dagger:hilt-android-testing:${Versions.Dagger.core}")
|
||||
@@ -130,6 +130,20 @@ fun DependencyHandlerScope.addDataStore() {
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.4")
|
||||
}
|
||||
|
||||
fun DependencyHandlerScope.addWorkerManager() {
|
||||
// Resolved transitively via Glance today; declared explicitly so the safety-net worker does not
|
||||
// depend on Glance's choice. work-runtime-ktx is NOT an empty shell at this version: at 2.7.1
|
||||
// CoroutineWorker, OperationKt.await, OneTimeWorkRequestBuilder and workDataOf all live in the
|
||||
// ktx artifact (they only moved into work-runtime on later releases).
|
||||
val version = "2.7.1"
|
||||
implementation("androidx.work:work-runtime:$version")
|
||||
implementation("androidx.work:work-runtime-ktx:$version")
|
||||
testImplementation("androidx.work:work-testing:$version")
|
||||
|
||||
// @HiltWorker is processed by the androidx.hilt compiler that addDagger() already registers.
|
||||
implementation("androidx.hilt:hilt-work:${Versions.AndroidX.Hilt.core}")
|
||||
}
|
||||
|
||||
fun DependencyHandlerScope.addGlance() {
|
||||
implementation("androidx.glance:glance-appwidget:${Versions.Glance.core}")
|
||||
implementation("androidx.glance:glance-material3:${Versions.Glance.core}")
|
||||
|
||||
@@ -9,6 +9,13 @@ object Versions {
|
||||
}
|
||||
|
||||
object AndroidX {
|
||||
// 1.2.0 is a floor, not a preference: androidx.hilt's 1.0.0 compiler ships only a
|
||||
// javax.annotation.processing.Processor, no KSP SymbolProcessorProvider, so under this
|
||||
// project's KSP setup it generates nothing at all (e.g. for @HiltWorker).
|
||||
object Hilt {
|
||||
const val core = "1.2.0"
|
||||
}
|
||||
|
||||
object Navigation {
|
||||
const val core = "2.9.3"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 181 KiB |