Commit Graph
1356 Commits
Author SHA1 Message Date
darken 7a64a23d1e docs: Record that translator context lives on Crowdin
Every string in the three Android source files now carries a custom
context written on Crowdin. A custom value is sticky per string: Crowdin
stops re-deriving that string's context from its XML comment on later
source pushes, so editing the comment in values/strings.xml silently
reaches no one.

Nothing in the tree recorded that, which makes the next comment edit a
trap. The rule file is where a reader lands when they touch strings.xml,
so it says where context is edited now.
2026-09-03 11:02:58 +02:00
Matthias Urhahn e2fcbbfdfd Merge pull request #702 from d4rken-org/worktree-remove-dead-strings
General: Stop asking translators for strings the app no longer uses
2026-09-03 10:59:33 +02:00
darken 29639cdcd1 chore(strings): Remove 49 unused string resources
None of these are referenced from any source set: a fixed-string grep over
app/src (Kotlin, Java, XML including the manifest, excluding res/values*)
finds zero hits for each, and the app has no getIdentifier-style dynamic
resource lookup, so nothing can reach them by name at runtime.

43 lost their last usage in an identifiable commit, mostly the Compose
migration (63692595), the upgrade/billing rework (c072b908, 3651bb3d,
e35bce4d, abb189f4) and the device-settings card grouping (65a74e9b,
32ff7e5b, 8a3a6cad). The other 6 were never referenced at all after the
commit that added them: debug_debuglog_recording_progress,
troubleshooter_ble_result_failure_body,
profilessettings_maindevice_identitykey_description,
profiles_maindevice_encryptionkey_description, pods_dual_left_short_label
and pods_dual_right_short_label.

Removed from the three base files and from all 75 locale files each, so a
Crowdin pull does not reintroduce them.
2026-09-03 00:22:11 +02:00
darken d8a6ef2286 feat(monitor): Show remaining case charges in the notification
The case row in the expanded notification carried a percentage alone, which
doesn't answer whether the case can still top the earbuds up. It now appends
the count the overview card already computes, in the same " . <value>" shape
the earbud rows use for their time estimates.

The figure is shown as a fraction rather than the card's integer because the
integer barely moves on a small case: AirPods Pro 3 publishes a 2.0 charge
case, so the count only ever reads 0, 1 or 2, and everything below 50% reads
0. A notification suffix makes no adequacy claim, so it can report the number
without the interval logic the coloured card line needs.

The decimal is cut rather than rounded. A 4.0 case at 24% covers [0.96, 1.00),
which the card calls short of a full charge, and rounding to the nearest tenth
would print "1.0" beside that. Cutting also keeps the lower-bound wording
honest, since 3.8 at 20% is 0.76 and may not claim "0.8+". The epsilon before
the cut absorbs binary float error only: 2.0 at 35% lands on 0.69999999 and
still has to read 0.7.

An empty case drops the "+" even on a lower-bound spec, matching the card's
treatment of zero as exact rather than as an underestimate.

Apple's Optimized Charge Limit is deliberately not modelled. It pauses around
80% only when the routine predicts you won't need the pods yet, so it applies
to some top-ups and not others, and the unit stays whole pair charges.

The case label gets a width cap because the row's progress bar is its only
weighted child. The added word is translatable, and a long rendering at a
large font scale would otherwise take the row and leave the bar at zero width.
2026-09-02 22:05:16 +02:00
darken e68927595c chore: Update default Play Store release notes and translations 2026-09-02 20:25:41 +02:00
darken aa928de265 General: Update app translations from Crowdin 2026-09-02 10:31:46 +02:00
darken 3662904d75 fix(debug): Make the recorder the only writer of the debug flag
The module's flag collector was pure redundancy: State.recorder is only
ever written right after a Recorder.start()/stop() that already published
the same value. Its one added behaviour was a hazard - a late-delivered
committed stop could overwrite the flag a newer session's start() had just
set, blanking diagnostics for up to the five seconds the header read is
bounded at.

Recorder.start()/stop() stay the single writers, next to the file logger
they install.

The collector's test goes with it: it hard-coded a two-collector queue
depth that can no longer be reached. In its place, a test asserting the
flag against the recorder itself, and one that forces the late-stop
delivery through a hand-stepped app-scope dispatcher.

Fixes review findings F2, F3, F4
2026-09-01 10:11:01 +02:00
darken cec36268e0 fix(debug): Keep the flag collector off uncommitted recorder states
The collector mirrored isRecording from every state emission, including the
start and stop requests, whose value the recorder has already moved past. On a
loaded device that write can land after Recorder.start()'s, leaving isDebug
false for the whole header-read window (5s) while the file logger is live.

distinctUntilChangedBy collapses the initial state and the start request into
one emission, drop(1) removes it, so only committed transitions are published.
The operator order matters: drop(1) first would let the start request through.

Fixes review finding F1.
2026-09-01 10:11:01 +02:00
darken 5895ccbc27 fix(tests): Match only power snapshot lines in the log filter 2026-09-01 10:11:01 +02:00
darken 798ac32ec9 fix(tests): Pin the new Robolectric tests to SDK 33 2026-09-01 10:11:01 +02:00
darken 6ed0bf8257 feat(debug): Log the device power state while recording
A reception blackout in a capture is as easily the display going off as
a broken scan, and nothing in a capture currently says which. The full
snapshot is written on every screen, doze and power-save broadcast, plus
once when the recording starts, so a single line format answers both
"what is it now" and "what just changed".

The receiver is registered before the first snapshot is taken:
ACTION_SCREEN_OFF is not sticky, so a screen-off in between would appear
as neither a transition nor a corrected state.

The catch sits on the inner receiver flow rather than after
flatMapLatest. Flow.catch completes the flow it is applied to, so a
top-level catch would end the recording-flag collection on the first
failure and every later recording in the process would carry no power
state at all.

Nothing here may throw: onReceive is an Android callback outside any
flow, so a vendor PowerManager that throws would take the process down.
Each field is guarded individually so a bad read costs one value rather
than the line.
2026-09-01 10:11:01 +02:00
darken 39a6c3c89f feat(device): Log the effective BLE scan configuration
A capture that shows no scan results cannot currently answer whether the
scan was filtered. The configuration is decided once when the scan
starts, which is usually long before the recording that is meant to
diagnose it, so the line is re-emitted when a recording begins while the
scan is already running.

The re-emission drops against the value captured at the first emission
instead of drop(1): launchIn subscribes asynchronously, so a StateFlow
replays whatever is current at subscription time. A recording started in
that gap would be swallowed as if it were the initial value, which is
exactly the case the line exists for.

filterPolicy is a parameter rather than something derived from the
filter set, because the unfiltered mode is implemented as a single
match-all filter. A count-based summary would report it as a filtered
scan, inverting the answer.

"Requested" and not "filtering"/"batching": adapter capability plus our
own setting is what we asked the platform for, not proof that the
controller offloaded anything.
2026-09-01 10:11:01 +02:00
darken 2ba90e1d18 feat(debug): Flip the debug flag when the recorder starts writing
The flag was only written from committed recorder module state, which
publishes after the recording header has been read. That read is bounded
at 5s, so for up to five seconds the file logger is live while the flag
still says false, and diagnostics keyed off it are missing from exactly
the window a reporter uses to reproduce a screen-off issue.

The module-state writer stays: it covers a resumed session and any path
that reaches isRecording=false without going through Recorder.stop().
A rolled-back start self-corrects, because the rollback stops the
recorder before publishing the failure.

BaseTest resets the flag per test instance because it is JVM-global. It
goes into init rather than the companion teardown, which uses JUnit 5's
@AfterAll and never fires under the JUnit 4 Robolectric runner.
2026-09-01 10:11:01 +02:00
darken 92dac661c4 fix(device): Keep a log line for rejected malformed addresses
Structurally broken addresses now fail the octet parse instead of
throwing, so the catch-all error log no longer fires for them. Log the
rejection at WARN with a redacted address so support logs still show it.

Fixes review finding F1.
2026-08-31 15:54:51 +02:00
darken 7712b18388 fix(monitor): Re-arm the teardown when a start request is short-circuited
In AUTOMATIC mode with nothing connected the session is torn down 15 seconds
after the state that said so, and only a new state emission cancels that. A
start request arriving while a session is live is acknowledged and returns
without touching the pending teardown, and the connection state that would
abort it lags the Bluetooth event that caused the start by roughly 0.75s. A
request landing in the tail of the window was therefore answered with
"keeping current session" and the session went down anyway, taking every
reaction with it — in one recording the popup reaction was down for 34
seconds spanning an entire lid cycle.

The short-circuit now bumps a start signal that the mode pipeline combines
in, so the bump cancels the pending inner flow through the existing
cancellation topology and arms a fresh window. The countdown also re-reads
the signal after its delay, which closes the case where the bump lands while
an expired countdown is already unwinding. A re-armed window runs 15 seconds
from the start request, so the total dwell can exceed 15 seconds: the request
is fresh evidence of activity.

The decision segment moves to a top-level internal function so it can be
driven directly in tests, following MonitorModeState and
buildMonitorModeState which are top-level for the same reason.
2026-08-31 15:54:51 +02:00
darken d7988189a0 fix(reaction): Stamp connect times from the ACL broadcast
The connection timestamp cache was only maintained while the connected-devices
flow had a subscriber. If the process died while a device stayed connected and
that device then disconnected and reconnected with nothing running, the
restarted collection found the old entry still keyed by a currently-connected
address, kept it through the prune, and reported the original connect time —
so the popup age check rejected an arbitrarily old connection.

The ACL broadcasts arrive at a manifest-registered receiver that wakes the
process regardless of any flow subscription, so the stamp is taken there
instead: connect stamps (keeping an existing one, which is the earlier and
therefore truer time), disconnect drops the entry. The flow keeps its own
stamp-on-first-sight and prune as a backstop for the force-stopped state and
missed broadcasts.

ACL_DISCONNECTED was already in the receiver's expected actions but was never
registered in the manifest. It does not start the monitor: a disconnect is not
a reason to start monitoring, and the start triggers here are deliberately
conservative.
2026-08-31 15:54:51 +02:00
darken ff5d0cedfa fix(reaction): Stop bonded-device queries from poisoning connect times
seenFirstAt on a connected device is meant to be the connect time, and the
connected-devices flow maintains that by pruning its cache to the currently
connected addresses on every emission. bondedDevices() wrote into the same
cache for every bonded device, connected or not, and never pruned.

AapAutoConnect queries bonded devices on every connected-devices emission,
the disconnect one included, so an entry pruned at disconnect was re-stamped
milliseconds later at disconnect time. The next reconnect then inherited the
previous disconnect as its connect time, and the popup reaction rejects a
connection older than 30 seconds — so any reconnect more than half a minute
after the previous disconnect silently lost its popup.

bondedDevices() is now a cache reader: a connected bonded device still
reports its true connect time, a non-connected one gets the current time,
which no caller reads. The connected-devices path becomes the cache's only
writer, which is the invariant its prune-and-stamp logic already assumed.
2026-08-31 15:54:51 +02:00
darken f82adeba42 fix(monitor): Persist AAP keys only when their content changed
AppleDeviceProfile is a data class whose key fields are ByteArrays, so the
generated equals() compares them by reference and the "did anything change"
guard was true on every key exchange. That meant a redundant profile write on
every connect and a "Persisted keys" line that said nothing about whether a
key had actually changed.

Compare content instead, per key, and name the key that changed in the log.
2026-08-31 15:54:51 +02:00
darken cc99ad59ab fix(device): Resolve identity keys against reversed address octets
On at least one vendor stack the address handed up by the BLE scan callback
and the identity key stored for a profile disagree on octet order: the key
resolves the address only when its octets are reversed. Identity resolution
then fails on every advertisement, so no frame is attributed to the profile
and the case popup, connection popup, encrypted 1% battery granularity and
session reconnection all go with it.

RPAChecker gains resolve(), which reports the order that resolved. The
standard-order attempt is unchanged and ungated, so no currently-resolving
device can start failing. The reversed attempt only runs when the reversed
form carries the resolvable-private-address type marker (top bits 01), which
skips roughly three quarters of the extra comparisons for generic random
addresses. verify() is now a thin wrapper over resolve().

Address parsing is validated explicitly: exactly six components, each in
0..255. A seven-component string used to be silently mis-sliced, and
"15A:..." truncated to a valid octet — both cases could resolve.

The failure log no longer prints the identity key. A malformed address or
key wrote an identity-tracking secret into exactly the debug logs users mail
to support; the event and its level stay, the key is reduced to its length
and the address goes through redactedForLogs().

The history lookup that recovers a device by key now skips candidates bound
to a different profile. A 24-bit forward collision could already select the
wrong history; attempting two orders roughly doubles that exposure.

Test vectors are synthetic and derived from the key already committed in
RPACheckerTest. Two of them are complementary: one has an RPA-shaped
reversed form that fails the hash (proving the comparison runs), the other
has a reversed form that resolves cryptographically but carries marker 00
(proving the gate runs).
2026-08-31 15:54:51 +02:00
Matthias Urhahn b66c756741 Merge pull request #695 from d4rken-org/worktree-case-charges-align
General: Tidy up the captions under the battery levels
2026-08-31 06:34:50 +02:00
darken fbbdb4e131 ui(overview): Give the last known caption more room above
At 4.dp the caption crowded the bottom edge of the battery surface it
describes. 8.dp separates the two without pushing it far enough to read as
its own block, which 12.dp (the gap used before the ANC selector) would.
Both pod cards carry the caption, so both move together.
2026-08-30 08:12:23 +02:00
darken 0bf6bf7b5f ui(overview): Center the case charges line in the battery card
The line sat at a fixed 36.dp start indent, which put its left edge under
the case percentage label (28.dp icon + 8.dp spacer). That follows the M3
"supporting text aligns with the item's text column" convention, but the
case row is not a list item, and the sentence runs nearly full width, so
the lone left gutter read as an accident rather than an alignment.

Centering on the capsule instead would have been the other candidate, but
the capsule is weight(1f) between the percentage label and the status-chip
FlowRow, so its x-offset and width are both runtime values that vary with
locale and font scale. No static padding reaches it; it would take
onGloballyPositioned or a custom Layout, which is not worth it for a
caption. Centering in the surface is stable and needs neither.
2026-08-30 08:12:15 +02:00
darken 865a609203 fix(ui): Keep the device detail sheet reachable while scrolled
The detail sheet was composed inside the device-info lazy item, so tapping the
battery runtime warning banner after that item had scrolled out of composition
did nothing visible while still setting the visibility flag, which made the
sheet pop up unprompted on scrolling back up. Build the detail items and compose
the sheet at screen scope instead; the card now only reports the tap.

Fixes review finding F1
2026-08-25 23:42:23 +02:00
darken 9f213cf499 ui(settings): Reframe battery health as estimated listening time
The figure compares observed listening drain against Apple's rated hours, so
it cannot separate a degraded cell from loud volume, cold weather or a hungry
codec. Runtime wording is true either way, "Battery Health" is not.

String keys are kept as they are so the translated locale files aren't
orphaned.
2026-08-25 23:42:23 +02:00
darken 1330c77ed8 feat(battery): Warn when a pod's listening time drops by half
The per-pod listening-time estimate was only visible behind the info icon on
the device settings header card. A pod that reaches half its rated listening
hours or less now raises a banner on the device settings screen, tapping it
opens the same detail sheet.

A displayed number needs less backing than one that raises a warning, so a
reading is only promoted into the banner when its slot has accumulated at
least 8 listening sessions across its qualifying rates and the newest of
those rates is at most 60 days old. The banner reads the already-gated state
field, so the per-profile battery estimate toggle suppresses it too.

The detail sheet's visibility moves out of DeviceInfoCard so both the info
icon and the banner can open it.
2026-08-25 23:42:23 +02:00
darken 2356726417 fix(overview): Call an empty case not enough on older models
AirPods Gen 1 and Gen 2 publish their case capacity as a lower bound, and
the adequacy check let that guard run before any zero-reading check. At a
0% case those two models rendered the definite "no charges left" text in
the neutral uncertainty colour while the screen reader hedged with "may
not be enough", contradicting the same node three ways.

The empty reading is now handled before the lower-bound guard. It sits
after the "enough" branch, which a zero reading can never satisfy, so it
cannot mask a positive claim. The lower-bound rule exists to avoid
underselling a case whose published capacity is only a floor, and an
empty case has nothing to undersell.

Fixes review finding F4
2026-08-25 22:33:42 +02:00
darken 03c09917da fix(overview): Stop denying a charge the reading still allows
The visible count came from the pessimistic end of the interval alone, so
an interval straddling a full charge said "Less than one more full charge"
while the colour stayed neutral and TalkBack said "may not be enough". An
uncertain interval now rounds to a single charge instead, and an
open-ended spec that has not reached one charge names no number at all.

Fixes review finding F2
2026-08-25 22:33:42 +02:00
darken 70830cf2ca fix(overview): Call a case exactly one charge short "not enough"
The reading is an interval that excludes its upper end, so an upper end
landing exactly on one full charge still leaves every reachable value
below one. AirPods Gen 4 at 10% and Pro 3 at 40% read neutral instead of
orange because of it.

Fixes review finding F1
2026-08-25 22:33:42 +02:00
darken 43140005f2 chore(overview): Sort the imports added for the case charges line 2026-08-25 22:33:42 +02:00
darken 59e2a16a39 test(overview): Cover the battery level states and the case charges line
Colour is not exposed through the semantics tree, and captureToImage() times
out under Robolectric, so the rendered result is pinned in two halves: which
tier each of the five slots reports, and which colour a tier resolves to per
theme mode. The theme test drives an in-app dark override on a light host,
which is the case a plain isSystemInDarkTheme() read would get wrong.

The case charges line is covered for presence, the three adequacy states, both
special renderings, and under RTL, a narrow card and a doubled font scale.
2026-08-25 22:33:42 +02:00
darken 11d6ddf3fa feat(overview): Say how many earbud charges the case still holds
The case percentage alone doesn't answer the question people actually have,
which is whether the case can top the earbuds up again. The card now says so in
words, below the case row.

The reading is treated as the interval it really is: a decile source at 20% on
a 4.0-charge case means 0.8 to 1.2 charges, which is not an answer, so the line
stays neutral instead of flipping colour as the reading bounces between
adjacent frames. Only an interval that lies wholly above or below one full
charge is coloured, which removes the need to remember anything between frames.

The line is stacked rather than inline: the case row already has four children
with only the capsule weighted, so a plural in front of it would collapse the
capsule at large font scales and in locales with long plural forms.
2026-08-25 22:33:42 +02:00
darken b10b42eede feat(monitor): Expose how coarse a case battery reading is
PodDevice.batteryCase merges an AAP notification, the encrypted advertisement
payload, the public advertisement nibble and the cache into one Float, and the
provenance is gone by the time anyone reads it. The public nibble only carries
deciles, so 20% there can mean anything up to 29%.

batteryCaseReading walks the same precedence and keeps the step size of the
source that won, leaving batteryCase untouched for every existing caller.
2026-08-25 22:33:42 +02:00
darken 5801b8ea56 feat(device): Record how many earbud charges a case holds
Derived per model as hoursWithCase / hoursListening - 1, with both figures read
under the same noise control condition Apple quotes them under, matching their
stated test method (drained AirPods recharged to 100% and playback resumed
until both the AirPods and the case were fully discharged).

Models whose published total reads "more than N hours" are marked as a lower
bound so nothing downstream can turn an unbounded figure into a confident
claim. AirPods Pro is deliberately left without a spec: its 4.5 h ANC listening
figure is verified but the matching with-case total is not, and an unsourced
number is worse than no line at all.

The spec gates on itself rather than on hasCase — twenty models set hasCase,
including Beats Solo Buds, whose case holds no battery.
2026-08-25 22:33:42 +02:00
darken b9461d3259 feat(a11y): Announce a low battery instead of only colouring it
Crossing the warn or critical threshold was expressed as a colour change only,
so it did not exist for a screen reader. Each battery slot on the overview card
now carries the level as a state description.
2026-08-25 22:33:42 +02:00
darken 7960144be6 ui(overview): Give a low battery a colour that means "low"
The warn band was drawn in colorScheme.tertiary, which is whatever the palette
seed produces: olive under the amber theme, where the healthy band (primary) is
burnt orange, so a warning read as decoration and healthy read as a warning.

The warn band now uses fixed light/dark tokens, and the percentage text is
tinted at warn and critical instead of the level living in the gauge alone.
Values are derived against the composited backgrounds the cards actually draw
on: the gauge Surface at 4dp tonal elevation, and the same surface at alpha 0.7
that a card without live data uses. Measuring against raw surface would have
admitted values below 3:1 on screen.

The tokens are resolved from the theme mode CapodTheme already computed, not
from isSystemInDarkTheme(), so an in-app dark override on a light system does
not pick the light tokens.
2026-08-25 22:33:42 +02:00
darken 0f95538f39 feat(battery): Add a shared battery tier for level colours
The overview duplicates the same 30%/15% thresholds in four places, with two
subtly different unknown checks: the capsule and the pod gauge accept any
percent >= 0f, so Float.POSITIVE_INFINITY renders as a full, healthy ring.
batteryTier() settles that on the finite check the other two already use.
2026-08-25 22:33:42 +02:00
darken fec512175b fix: Prevent crash in Amharic battery time formatting
The Amharic translation of battery_time_remaining_format_hm dropped the
conversion character from its second placeholder: "%1$dሰ %2$ደ" instead of
"%1$dሰ %2$dደ". Java's formatter reads "%2$" followed by "ደ" as an unknown
conversion and throws UnknownFormatConversionException.

formatBatteryDurationShort() takes that branch whenever an estimate has both
hours and minutes, so any duration between 1h1m and 23h59m threw. It is
reached from the dashboard pod cards, the home screen widget, and the
foreground service notification, which makes it a crash on the common path
rather than an edge case.

Found because the Play Store rejected the Amharic screenshot upload: the
widget configuration preview had rendered as a 1x1 image, the layoutlib
render having aborted on the same exception.

Corrected at source in Crowdin, and in CAPod's translation memory, which
still mapped the English source to the broken string at 100% match and would
have re-injected it on the next pre-translate run.
2026-08-25 21:52:33 +02:00
darken a427fcbf4e chore(screenshots): Commit only English Play Store screenshots
Only en-US screenshots are tracked from now on. The other 67 locales are
generated on demand and gitignored, which drops 35 PNGs and roughly 5 MB from
the repository and stops every screenshot refresh from churning binaries in
five languages nobody reviews.

Play Store still gets the full 68 locales. supply only uploads what is present
under fastlane/metadata/android/<locale>/images/phoneScreenshots/ and retains
whatever was last pushed for locales absent from an upload, so localization is
maintained by an occasional manual regen plus screenshots_only rather than by
every PR.

PlayStoreLocales.kt had been committed since February holding a truncated
four-locale batch slice, left behind when a run was killed before its EXIT trap
restored the file. The next run then copied that slice over its own backup and
faithfully restored the corruption, which is why the remnant survived six
months. It is replaced with a documented en-US placeholder, the generator no
longer emits an annotation nothing references, and the script now refuses to
start when a stale .bak is present instead of clobbering the only good copy.

The refresh runbook is corrected as well: it ended with a checkout that restores
tracked files from the index, which would have reverted the freshly rendered
English screenshots while the store received them, and its upload step is now
gated so a failed upload leaves them staged for a retry rather than committed as
though deployed.
2026-08-25 20:08:48 +02:00
darken c49cd62b2a chore: Rename support-investigator plugin entry to support 2026-08-25 20:08:48 +02:00
d4rken-org-releaser[bot] abf28f335c Release: 5.2.4-rc0 v5.2.4-rc0 2026-08-22 04:42:29 +00:00
Matthias Urhahn 540e6d1dbc Merge pull request #690 from d4rken-org/crowdin-update-20260821
General: Update translations from Crowdin
2026-08-21 20:54:10 +02:00
darken 76e1128e93 General: Update app translations from Crowdin 2026-08-21 19:14:22 +02:00
darken cbf681ccfc General: Update fastlane translations from Crowdin 2026-08-21 18:56:35 +02:00
darken 30f17b4791 fix(ui): Drop the removed monitor mode setting from the auto connect hint
The auto connect description told users the toggle would set the monitor mode
setting to 'Always'. That setting was removed in 5.1.6 and the mode is derived
from profile state now, so the sentence pointed at a settings entry that no
longer exists. The behaviour it described is unchanged: enabling auto connect
still resolves to continuous monitoring.

Base string only, so Crowdin flags the existing translations as outdated.
2026-08-21 10:52:55 +02:00
Matthias Urhahn cd6aaaf165 Merge pull request #687 from d4rken-org/worktree-settings-plain-language
General: Explain the compatibility settings in plain language
2026-08-21 01:20:03 +02:00
darken 289dcd660e ui(settings): Explain compatibility options in plain language
The three compatibility toggles described their own mechanism ("Don't
delegate data filtering to the system", "broadcast instead of callback")
without saying which symptom they address, so a non-technical user had no
way to tell whether a row applied to them. Each summary now leads with the
symptom and keeps a plain description of what changes. Titles are
unchanged: they name the mechanism and are referenced verbatim in issue
replies.

The category description "Don't touch if everything works ;)" stopped
rendering in 63692595 when the res/xml preference screens were replaced by
Compose, since SettingsCategoryHeader only took a title. The string and its
~75 translations survived unused. Added an optional subtitle to the header
and wired it back up.

Also rewrote the extra-notification summary, which named the "Device
status" channel but never said where to find it.

Reported via a Play Store review (vi, 4 stars, 5.2.3-rc0).
2026-08-21 01:13:13 +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