Compare commits

...
Author SHA1 Message Date
darken bd4d87c06a feat(aap): Add Custom EQ protocol support
Adds decode and encode for AAP opcode 0x63, Apple's iOS 27 "Custom EQ"
(three bands plus a Recommended/Custom mode selector, H2 models).

The wire format comes from librepods commit 7341e41 and has never been
confirmed on hardware, so the decoder validates every field and returns
null on any mismatch -- including a strict payload-length equality, so a
frame carrying trailing unknown bytes is surfaced verbatim in the
unknown-message hex log rather than mis-parsed into plausible values.
Nothing optimistically assumes the write lands: SetCustomEq deliberately
has no optimistic state update and no verification predicate, because an
unacknowledged write must not render as success, and the device is not
known to report this setting at all.

Also recovers two bytes the 0x53 decoder was discarding. Opcode 0x53 is
the Headphone Accommodations configuration -- an iOS Accessibility
feature that the iOS 18.1+ hearing-aid feature reuses -- and payload
bytes 4 and 5 are its "Apply To: Phone / Media" flags. They are plain
0x01 flags, not the Apple-bool 0x01/0x02 encoding used by the 0x09
control settings. They describe scope only and say nothing about whether
a profile exists, so isAllZero stays a pure band-data predicate.

A debug-only card (BuildConfig.DEBUG, mirroring the existing PME chart)
drives the opcode for hardware evaluation. It edits local draft state and
sends exactly one packet per Apply tap; slider-driven sends would flood
the link and make a logcat observation window unreadable. Apply is
disabled while no pod is worn, because AapOutboundController queues
non-exempt commands in that state and the queue collapses repeats,
surfacing them later at an unrelated moment. That guard reads the AAP
EarDetection setting rather than PodDevice's BLE-fallback property, so it
matches the controller's gate in every state -- notably, neither gates
when the AAP setting is absent.

On-device evaluation, both legs negative: AirPods Pro 2 USB-C (A3048,
fw 81.2675000075000000.6814) and AirPods Pro 3 (A3064, fw ...6503)
never advertise 0x63 across settings bursts of 33 and 26 frames, never
echo a write, never persist one across a case-cycle reconnect, and
produce no audible change. Both accept the write at the socket and
discard it. This does not establish that the byte format is wrong -- a
device ignoring an unimplemented opcode is indistinguishable from one
rejecting a malformed frame -- and it is not evidence that privileged
access is required, since neither device advertises the feature at all.
2026-08-21 15:23:32 +02:00
Matthias Urhahn b07428f894 Merge pull request #686 from d4rken-org/worktree-anc-adaptive-misreport
Device: Fix duplicate listening mode commands and silent failures
2026-08-20 18:16:20 +02:00
darken dbd1d422c6 refactor(aap): Split the echo classifier out of this change
Everything that reinterprets what the device reported moves to its own branch
(anc-echo-classifier), leaving only changes that stand on their own.

The classifier addresses a fault that has never been observed being handled: it
reproduced on two of four sessions and none since it was written. It also adds a
failure mode that did not exist before, where a report misattributed to our own
write makes CAPod show a mode the device is not in. That is a poor trade to carry
into main on the strength of tests alone, so it waits until it can be seen working
against a live fault.

What remains does not depend on the misreport:

- the verification deadline was 1000ms while the device answers in 833-1008ms, so
  a healthy reply could land just after the timer and trigger a bogus divergence
  plus a redundant re-send; this was captured live
- a listening mode request the device did not confirm produced no feedback at all
  for any mode except Off, which was a gap in the event plumbing rather than a
  timing artifact
- a mode outside the device's listening mode cycle was rendered as an ordinary
  selectable button whenever it happened to be the current mode
- an Off report arriving while a different mode was requested could teach the Allow
  Off inference, persisting "Off is permitted" into the device profile

Also drops effectiveAncMode, which only had an effect while the classifier was
present.
2026-08-20 12:38:30 +02:00
darken 0b83e86c5e fix(aap): Correct an overstated safety claim and a test that proved nothing
Review follow-ups on the previous commit.

Its claim that "nothing is learned or persisted" was wrong. BatteryEstimator
buckets drain samples by the current listening mode and force-persists the
accumulated window whenever that mode changes, so a misattributed mode can write
a drain rate to disk under the wrong bucket, and the corrective report does not
remove it. This is not new: the misreport being fixed here already mis-buckets in
the same way, and more often, since the device claims OFF while the pods play
Adaptive. Classifying corrects the common case and only gets it wrong on the rarer
misattribution. The design stands, the claim does not.

Two other claims were also too strong. The recorded echo is the first report after
the verification was installed, which is not exactly the wire write, so write
contention above the latency boundary can still inflate a fast refusal. And a
superseded write is not left alone entirely: classification is skipped, but it
still falls through to the ordinary retry path.

The supersession regression test asserted nothing: it fed a fresh ADAPTIVE report
in before its only assertion, overwriting either outcome, so it passed whether or
not the guard existed. It now asserts on the state left by the delayed echo, and
fails with the guard removed.

Also drops an unused import and restores an indent lost when the remap argument
was removed.
2026-08-20 00:06:03 +02:00
darken b396ce457d fix(aap): Attribute a listening mode echo before drawing conclusions from it
Extends the previous commit's classifier so it only judges evidence it can
actually attribute to our own write.

The classifier judged whatever mode happened to be current when the deadline
fired, and never looked at timing, despite the refusal-versus-misreport
distinction resting on it. A delayed answer to an earlier write, or a mode change
made on the pods themselves mid-request, could be taken for the answer to the
outstanding write.

Now the first report after a write is recorded with its latency, and that
recorded frame is what gets classified. A write is left alone entirely when it
was superseded by another listening mode write or by a stem press, since its
echoes can no longer be attributed. An answer arriving faster than 500ms is a
refusal, never a change: captures put refusals at 25-267ms and real changes at
815-1010ms. A re-send restamps its own send time and drops the previous attempt's
echo, so a retry is never judged on stale evidence. Latency is measured with the
monotonic clock, so a wall clock correction cannot turn a fast refusal into an
apparent change.

A misattribution is still possible, because AAP reports carry no correlation id
and a change made from iOS or another paired phone is invisible here. That is why
nothing is learned or persisted from this: the worst case is one wrong reading
that the device's next report corrects.

An earlier version of this work also carried a session-scoped remap, so that a
stem-initiated switch could be read correctly after our own write had proven the
device mislabels a value (issue #594). It is not included. The same
unattributability that bounds the classifier to a single wrong reading would have
let one misattribution rewrite every later report in the session, and a wrongly
resolved Off could persist AllowOffOption into the device profile, outliving the
session that produced it. Stem-initiated switches on an affected session are
therefore still not shown correctly.

Refs #594
2026-08-19 23:52:52 +02:00
darken 734e15c94d fix(aap): Tell a listening mode refusal apart from an unusable echo
Follow-up to the previous commit, which left the wrong half of this in place.
Distrusting the report only while the request was outstanding meant that four
seconds later the rejection cleared the pending mode, the bogus value came back,
and the user was shown an error for a mode change that had actually worked.

The two cases have different signatures, both readable from state the engine
already holds:

- A refusal echoes the mode the device is staying in, quickly. Captured Off
  refusals answer in 25-267ms with the previous mode.
- The Pro 3 misreport answers with a third mode, neither the one requested nor
  the one it was in, at normal change latency (815-1010ms).

So an echo that is neither the requested nor the previous mode is treated as an
unusable report rather than a refusal: no re-send of a write that already took
effect, no rejection, no error, and the requested mode is recorded as current.
Refusals still work, which is what the Off rejection message and the Allow Off
learning depend on.

This is deliberately engine-local. Seeding the cycle mask and Allow Off belief
from the device profile into the session would have encoded the rule directly,
but it inverts the current engine-to-profile data flow and creates a belief that
no device report can ever correct, since AirPods never report 0x1A or 0x34.
librepods keeps the same knowledge in its service layer and preferences, not in
its protocol manager.

The fault is per-session rather than per-request: across four sessions today the
pods either misreported every Adaptive write or none of them. The heuristic is
covered by unit tests but has not yet been observed handling a live bad session.
2026-08-19 20:33:32 +02:00
darken edf84248ab fix(anc): Don't show a phantom Off mode when AirPods misreport the listening mode
AirPods Pro 3 can answer a listening mode write with 0x0D 0x01 (Off) while
audibly switching to the requested mode. Seen on firmware
81.2675000075000000.6503, intermittently, and not reproducible on demand.

CAPod took that report at face value: it surfaced an Off button that isn't even
in the device's listening mode cycle, selected it, and said nothing about the
request not having been confirmed.

- visibleAncModes no longer re-admits a mode purely because it is the current
  one. That escape clause was what conjured the extra button.
- effectiveAncMode keeps showing the requested mode while our own request is
  outstanding and the device reports a mode it should not be able to reach.
- A rejected listening mode request now surfaces a message for every mode, not
  only Off. Other modes were dropped silently.
- The Allow Off inference ignores an Off report that arrived while a different
  mode was pending, so a single glitch cannot permanently persist "Off is
  allowed" into the device profile. An unsolicited Off still trains it, which
  is what keeps the option discoverable after it is enabled elsewhere.
- Verification deadline moved from a hardcoded 1000ms to 2000ms, and a matching
  device report now settles the verification when it arrives. Measured reply
  latency is 833-1008ms, so the old deadline sat inside the device's normal
  spread and could fire a bogus divergence plus a redundant re-send.

Settling verification on arrival is limited to SetAncMode deliberately. Every
other verified command is optimistically written into state when it is queued,
so its predicate is satisfied immediately and only the device's contradicting
echo makes it fail. Settling those early would swallow the rejection.

Note that AirPods never report AllowOffOption (0x34) or ListeningModeCycle
(0x1A), so which modes are permitted is always inferred, never device truth.
2026-08-19 19:09:20 +02:00
Matthias Urhahn 83dfdb9219 Merge pull request #682 from d4rken-org/feat/focus-duck-fallback
Reaction: Lower conversation volume even on phones that block it
2026-08-19 09:05:16 +02:00
darken 70b9c2981e fix(upgrade): Align the androidx.hilt declarations with the resolved 1.2.0
addWorkerManager() needed androidx.hilt 1.2.0 for its KSP compiler, which left
addDagger() asking for 1.0.0 while conflict resolution silently handed it 1.2.0,
and hilt-compiler declared twice at two different versions.

The version moves to Versions.AndroidX.Hilt.core, addDagger() stays the single
place that registers hilt-common and the hilt-compiler, and addWorkerManager()
declares only hilt-work. Declared and resolved versions now match, so the
dependency report no longer shows a 1.0.0 -> 1.2.0 substitution.

No dependency graph change: 1.2.0 is what already resolved.
2026-08-18 17:55:19 +02:00
darken 971fcbd34c fix(upgrade): Add a persistent acknowledgement safety net for Play purchases
Play auto-refunds (and revokes) purchases not acknowledged within 3 days.
The in-process ack machinery covers every case where the process lives
long enough; what it cannot cover is a process death around the Play
sheet (aggressive OEM task killers) followed by the user not reopening
the app before the deadline.

Add a gplay-only WorkManager safety net:
- PurchaseAckWorker: self-completing sweep via a new bounded
  BillingManager.ensureAllAcknowledged() that refreshes and acknowledges
  in the same coroutine (the reactive ack collector is async, so a worker
  cannot prove its acks happened through it). Retries with exponential
  backoff until the purchase's refund deadline, then gives up visibly.
- PurchaseAckScheduler: two unique work identities. A launch watch
  (REPLACE, armed and awaited before launchBillingFlow with a 30min delay
  so it cannot complete while the user is still in the sheet) and a
  discovered-purchase rescue (KEEP, 1min delay, armed directly from an
  ack pass that finds unacknowledged purchases, pre-attempt). Separate
  identities so a new purchase flow can never displace a pending rescue.
  Both triggers are fail-open: a broken WorkManager never blocks a
  purchase or an ack. WorkManager resolves via Provider at first arm so
  eager Application-time construction of the billing stack cannot
  trigger WorkManager's on-demand initialization prematurely.
- Nothing cancels the work from the foreground path: an ack pass can see
  zero unacked purchases while the sheet is still open, so the worker
  completes itself after its own reconciliation instead.

The ack pass now runs under a mutex (the worker sweep and the reactive
collector would otherwise race the token bookkeeping) and reports
per-outcome counts for the sweep result mapping.

This is a port of d4rken-org/sdmaid-se#2685; the ported sources are
byte-identical to the donor apart from the package rename.

CAPod had no explicit WorkManager wiring at all (work-runtime only
arrived transitively through Glance), so this also adds it:
- addWorkerManager() pinning androidx.work 2.7.1, the version already
  resolved via Glance, plus androidx.hilt:hilt-work and its KSP
  compiler. work-runtime-ktx is required at 2.7.1: CoroutineWorker,
  Operation.await, OneTimeWorkRequestBuilder and workDataOf all still
  live in the ktx artifact at that version. androidx.hilt moves 1.0.0 ->
  1.2.0 (by conflict resolution) because 1.0.0's hilt-compiler ships no
  KSP SymbolProcessorProvider, so @HiltWorker would generate nothing.
- WorkManagerModule providing the singleton WorkManager.
- App implements Configuration.Provider with the injected
  HiltWorkerFactory. WorkManager 2.7.1 still declares that interface as
  getWorkManagerConfiguration(), not the later property form.
- The manifest removes androidx.work's startup initializer so the
  on-demand configuration is the one that takes effect.

FOSS stays untouched behaviour-wise: all new billing types live in
src/gplay, workers need no manifest entry, and the worker factory
resolves the worker only in gplay variants.
2026-08-18 17:55:19 +02:00
Matthias Urhahn cebed0a60d Merge pull request #683 from d4rken-org/worktree-anc-selector-redesign
General: Redesign the noise control mode selector
2026-08-18 15:18:38 +02:00
darken 8817da309f ui(anc): Redesign the listening mode selector
Replaces the outlined segmented row with a filled track carrying a single
sliding thumb. The old control stacked three signals for one state (container
fill, bold, underline) in an outline treatment nothing else in the card uses,
which is what made it read as bolted on.

The track now sits at the same tonal step as the battery panel above it and
reuses the card's 16dp/12dp radii, so selection is carried by thumb position
and fill alone. Only the active mode is named and its slot expands to fit,
which keeps long translations off the width budget entirely.

Slot widths, label reveal and content tint are all derived from one clamped
fractional position, so geometry and content stay in lockstep. The thumb's
leading edge is that position in collapsed-slot units rather than a sum of
the animating widths, which would overshoot the target and crawl back.

Also fixes an invisible pending state: the in-flight branch set
activeContainerColor to secondaryContainer, which is exactly what
SegmentedButtonDefaults already used for the selected segment, so a requested
mode was indistinguishable from a confirmed one. An unconfirmed request now
draws a hollow pulsing thumb and tints the mode the pods are still in.

Transparency moves off Icons.TwoTone.Hearing, which is already the "In ear"
status chip and rendered identically two rows below it in the same card.
2026-08-18 15:04:50 +02:00
darken 27cc0e8d00 test(reaction): Cover the audio-focus duck fallback
Pins the outcome mapping of duckMusicVolume (a higher read-back stays a skip,
an unmoved one is a refusal), the focus request lifecycle (idempotent grant,
retry after denial, abandon only what is held, permanent loss clears the state
but a transient one does not), and the reaction side: focus is requested only
for an ignored write, released at teardown, re-requested by a keep-alive after
a permanent loss, and a late-landing volume write is restored.

The negative assertions are load-bearing: the MediaControl mock is relaxed, so
an accidental focus request on a healthy duck would pass silently.
2026-08-18 12:37:33 +02:00
darken cecd3088be feat(reaction): Duck via audio focus when the volume write is ignored
ColorOS 16 accepts setStreamVolume from a backgrounded app and leaves the
level where it was, so the conversation reaction ducked nothing. duckMusicVolume
now classifies the outcome (Ducked / Unchanged / Skipped) instead of collapsing
everything into a nullable duck, and a level that came back *higher* stays a
skip: that is the user raising the volume between the two reads, not a refusal.

On Unchanged the reaction requests AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK and lets
the framework attenuate the other player. Teardown abandons the focus and, if
the volume ended up below the pre-duck level anyway, restores it (guard for a
device that applies the write asynchronously). The focus request is built by an
injected factory because AudioFocusRequest.Builder is an unmocked stub in plain
JVM unit tests.
2026-08-18 12:36:30 +02:00
Matthias Urhahn 412ea369f1 Merge pull request #681 from d4rken-org/worktree-ca-duck-noop-diag
Reaction: Retry conversation volume lowering when the system ignores it
2026-08-17 18:40:57 +02:00
darken 22ca3a2a46 fix(bluetooth): Fix bogus scan-gap delays in debug logs
lastScanAt was read and written inside the log lambdas, which only run while
a logger is attached (log() checks Logging.hasReceivers first). In a release
build with recording off, the bookkeeping therefore never happened, so the
first delay of every debug recording reported the time since the *previous*
recording ended. Two logs from a support case opened with delay=878453ms and
delay=359983ms, which read as 14 and 6 minutes of suppressed scanning but
were just the gap between recordings.

The bookkeeping moves out of the lambdas, and the clock changes from
currentTimeMillis to elapsedRealtime so a wall-clock correction cannot
fabricate a gap either. That also puts the delay in the same boot-clock
domain as ScanResult.timestampNanos.
2026-08-17 13:00:02 +02:00
darken 92feea8a10 fix(reaction): Don't report a volume duck the system ignored
On ColorOS 16, setStreamVolume from a backgrounded app is accepted without
an exception and leaves the volume untouched: a debug log from an OPPO
PME110 shows "duckMusicVolume(100%): 40 -> 40 (requested 0)" while the same
call succeeded in the foreground.

duckMusicVolume returned a VolumeDuck for that, so ConversationReaction
recorded an Active session, armed the stale backstop, and later "restored" a
level that was never left. It now returns null when the read-back shows no
decrease, which routes the caller into its existing duck-no-op path: nothing
armed, nothing to restore, and a repeat START retries the duck instead of
treating the dead session as a keep-alive.

The predicate is applied >= prior rather than == prior. A route that
quantizes the target back up to the starting index attenuated nothing
either, and a volume that came back higher (user raised it between the two
reads) must not produce a duck whose restore would undo their change. The
WARN says "volume did not decrease" rather than blaming the ROM, since the
read-back alone cannot distinguish those three causes.
2026-08-17 13:00:02 +02:00
52 changed files with 2483 additions and 177 deletions
+1
View File
@@ -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")
}
}
+13
View File
@@ -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"
+21 -1
View File
@@ -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()
+1
View File
@@ -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>()
}
}
+16 -2
View File
@@ -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}")
+7
View File
@@ -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"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 181 KiB