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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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
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.
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.
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
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
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
upgrade_screen_sub_check_failed_title/_message no longer exist in the
English source (removed in 3e6541de). Seven locales still carried
Crowdin TM-poisoned translations for them, tripping ExtraTranslation
in lintVitalGplayBeta/Release.
refreshStrict() throws its incomplete-result error AFTER useConnection
already returned: refreshPurchases hands back a partial result instead of
throwing, so useConnection's dead-binder detection never sees it. A gate
that ran against a connection whose binder died mid-query (partialError
cause chain carrying SERVICE_DISCONNECTED / SERVICE_TIMEOUT) left that
connection installed, and every later purchase check kept talking to the
corpse until something else tore it down.
processReconciliation() already compensated for the refresh()/connect-loop
paths; the strict path had no such call. Extract that dead-connection block
into invalidateOnDeadConnection() and call it from both. The strict path
deliberately does NOT feed the episode clock — a gate the user aborted
mid-purchase is not a reconciliation outcome.
Ports the pending-purchase coverage alongside the production change.
- BillingConnectionTest / BillingManagerTest: PENDING ingestion, the
PURCHASED-only entitlement exits, provesAbsence ignoring a surviving
pending overlay, the reconciliation pass and the ack pass skipping
pending purchases.
- UpgradeRepoGplayTest: pendingSkus never feeding isPro, the strict
verify path and PendingPurchaseBillingException on an already-owned
recovery.
- GplayUpgradeViewModelTest: the shared pre-purchase gate on both paths
(pending, timeout, error, owned upgrade, renewing subscription with an
unknown product), the pending-payment launch failure, and the pending
card rendering while prices are still loading or have failed.
- GplayUpgradeScreenTest / GplayUpgradeOwnershipTest /
GplayUpgradeScreenHostTest: the card for all three audiences, the
locked offers and switch button, restore staying enabled, and the
informational dialog reaching the composition.
The FOSS upgrade screen's view mapping only surfaced STATUS_UPGRADED on
the manage route. Forced routes (the Pro-locked settings entry)
deliberately don't auto-close, so a supporter completing the sponsor
flow from there stayed on the sales pitch with a live sponsor button --
reading as if sponsoring didn't work, with only the transient thanks
toast saying otherwise. The gplay flavour already renders ownership
state route-independently on this path.
The isPro branch now wins on every route, matching the adjacent
comment's stated intent and gplay's behaviour. Forced routes keep their
don't-auto-close semantics; the durable status view is what
acknowledges the upgrade.
A user whose payment Play is still processing now gets an explanation
instead of a screen that keeps selling them the upgrade they already
bought.
- New PendingPurchaseCard, rendered above the ownership/acquisition split
so it reaches every audience: the acquisition buyer, the owner switching
products, and the grace user (whose offers box is hidden entirely during
a young episode) — all three have their purchase actions locked and need
the same explanation.
- Loaded.hasPendingPurchase is SKU-agnostic: the subscription and the
one-time purchase are alternatives, so a pending payment for either one
disables both offers and the ownership switch button. Restore stays
enabled — re-checking with Play is the useful action here.
- Both purchase paths now run the same pre-purchase gate
(verifyPurchaseStateNow, bounded): a pending payment answers with the
informational PurchasePending dialog instead of launching a flow Play
would reject. The subscription path previously launched unverified, and
its gate also blocks on an owned upgrade (RestoreSucceeded) and on a
still-renewing subscription (SubscriptionStillRenewing), so a stale
screen can't sell Pro to someone who already owns it.
- restorePurchase() reports a found-but-unpaid purchase as PurchasePending
rather than RestoreFailed, whose copy asserts a completed check and
steers toward multi-account troubleshooting and support.
- A PendingPurchaseBillingException from a launch maps to the same dialog.
- SubscriptionCheckFailed becomes PurchaseCheckFailed (both paths use it
now); its string is replaced by
upgrade_screen_purchase_check_failed_message and the translated entries
of the old key are removed, along with the already-orphaned
upgrade_screen_sub_check_failed_title, so ExtraTranslation lint stays
quiet.
Play reports a purchase as PENDING while a slow payment method (cash,
carrier billing, bank transfer) is still being processed. Until now
BillingConnection dropped those at ingestion, so the app had no idea the
user had bought anything: the upgrade screen kept selling, and a second
purchase attempt was rejected by Play with ITEM_ALREADY_OWNED.
Pending purchases now enter the reducer state and travel to the UI, while
every entitlement exit stays PURCHASED-only:
- BillingConnection ingests PURCHASED + PENDING (UNSPECIFIED_STATE is
still dropped everywhere). The freshUpdates stream keeps receiving only
PURCHASED, and provesAbsence now ignores a surviving PENDING overlay
entry, so a payment in progress can't freeze the unconfirmed-episode
clock.
- combinePurchaseResults gets the sku-type resolver: a PENDING result only
suppresses the couldn't-verify error when it maps to a known Pro SKU. An
unknown pending product proves nothing about the type whose query failed.
- PurchaseRefresh now carries provenance (confirmed set,
hasConfirmedProPurchase, commit-time occurredAt, partialError) instead of
just the merged view plus isComplete.
- BillingData splits into purchases (entitlement carrier) and
pendingPurchases via a single from() classifier used at every exit.
- BillingManager gains processReconciliation(), run after the connect
loop's initial refresh and by refresh(): it re-signals dead-binder
invalidation and feeds the grace episode clock with the refresh's COMMIT
time. The ack pass skips pending purchases, which Play rejects
permanently and would report as a bug every pass.
- BillingConnection.querySubscriptions / BillingManager.querySubscriptions
are replaced by refreshStrict(): the pre-purchase gate needs both product
types and the pending state, and still fails closed on anything short of
a complete round-trip.
- UpgradeRepoGplay exposes Info.pendingSkus (never part of isPro),
Info.hasAutoRenewingSubscription, verifyPurchaseStateNow() for the gates,
and reports PendingPurchaseBillingException when an already-owned
recovery finds a pending payment. The grace branch now carries
billingData through so pending stays visible while Pro runs on grace.
settings_upgrade_status_label lived twice per flavor: once for the
Settings row, once as a raw locale-translated string standing in for
what the upgrade screen otherwise composes from the app name and the
tier qualifier. On gplay the two could drift apart -- 15 of 75
locales showed the wrong word order or an untranslated English
fallback where the composed title used the correct language. The row
now composes through the same brandTitle template gplay's own
upgrade screen title already used, so the two can no longer disagree.
FOSS's value was a support ask ("Sponsor CAPod"), not a composed
brand title, so its wording stays untouched -- the key is renamed to
upgrade_foss_sponsor_label (byte-identical text, all 75 locales) and
both FOSS call sites read that one resource instead.
R8 reported com.google.android.gms.common.annotation.NoNullnessRewrite as missing; it is a compile-time-only annotation referenced by review-ktx and absent from the runtime classpath. Adds the same targeted -dontwarn SD Maid SE already uses.
The template moved to main, so FOSS resolves per-language arrangements
too and composes reordered titles for the first time. Running the sweep
only for gplay would leave that path unguarded.
Arrangement is a property of the language, not the flavour, so the key
now lives once in main alongside app_name. The qualifier stays
flavour-specific: only the word order and punctuation move.
FOSS consequently inherits each language's arrangement instead of being
frozen at the default order.
Phase 1 of the Crowdin migration: the flavour copies still override this
for their own builds, so behaviour is unchanged while translations are
gathered against the new source string.
The locale sweep checked format specifiers with a regex that does not know
%<s, which reuses the previous argument and so emits the qualifier twice.
That damages the template, triggers the fallback, and still satisfies every
output assertion because a fallback title also carries one correctly styled
qualifier. Both checks now run against the formatter's own output.
Also pins the highlight colour: it is a parameter because the toolbar tints
FOSS and Pro differently, so hardcoding it back would have stayed green.
Both are now derived: the title is composed from app_name and the
flavour's upgrade_badge_label through app_name_upgraded_template, so the
pre-composed strings have no remaining reader.
The composed app_name_pro was split on spaces and only styled when it
produced exactly two tokens. Arabic has four (kabud en-dash two-word
qualifier) and lost its branding entirely; Estonian puts the qualifier
first and so passed the guard while highlighting the brand instead.
Titles are now built from a per-flavour app_name_upgraded_template with
the app name and the tier qualifier as placeholders, so translators own
word order and punctuation.
The Google Play launch-failure message was shown via Toast, which Android caps
at 2 lines: English lost the trailing "device.", French was cut mid-word and
lost an entire condition. The strings are fine, the container was wrong.
The fix action now rethrows after logging, the failure reaches the dialog, and
the dialog renders the message inline while staying open. The dismiss button
stays available, so the dialog is never latched.
The message is passed per dispatch rather than read from the LocalizedError, so
no future action button can surface the fix action's failure copy. The inline
state is keyed on the throwable, not the LocalizedError, which is rebuilt with
fresh action lambdas on every recomposition.
When both the IAP and SUB queries came back with nothing, the screen always
reported a connectivity failure, telling users to clear Play's cache and reboot.
Play can answer OK and simply have no sellable offer (region, account
eligibility, pulled product), where that advice is futile.
Both causes are now inspected: only when BOTH are OfferUnavailableBillingException
does the merchandising copy surface. A single non-merchandising failure can't
rule out a real Play problem, so the conservative copy stays.
The card is a keyed item in the overview's lazy list, which restores the
saveable state of removed items when they come back. A review tap left
the dismiss action permanently disabled once a higher priority card took
the slot and gave it back, and a dismissed card returned fully dead.
Fixes review finding F1.
Adds timing, caching and boundary coverage for the review tool plus the
card's latch matrix. The existing both-actions card test is superseded:
its dismiss-then-review sequence is exactly what the latch blocks.
The card stays up until the next state emission, so a dismiss after a
review would overwrite the review bookkeeping with a snooze and a review
after a dismiss would re-open what was just closed. Repeated review taps
stay allowed so a failed Play request can still be retried.
Timeouts on all three Play calls, a dismiss generation backstop for the
tap race, and a 3-state probe verdict that caches Play's definitive
answers for the process, retries transient failures on a bounded budget,
and re-evaluates eligibility at the snooze and pro-grace boundaries.
Corrupt review settings terminated the shared state flow on AppScope,
so the exception crashed the process instead of reaching the ViewModel's
catch. Absorb it upstream of both replayingShare calls.
The review card also no longer stacks on top of the enable-Bluetooth
prompt.
Fixes review findings F1, F2
Pins the Play review tool's eligibility gate, probe retries, single-flight
guard and cancellation handling, the DataStore round trip of the review
timestamps, the overview's card priority gate and the card itself.
Adds a review prompt card to the overview. On Google Play it uses the
in-app review flow, gated on the user having been Pro for a while, not
having dismissed it recently and not having reviewed yet. FOSS gets a
no-op implementation.
The card is the lowest priority item on the overview and stays hidden
while a permission, troubleshooter, background-monitoring-off or
no-profiles card is on screen.
Shrinks oversized screenshots posted in issues and comments into clickable
thumbnails. Implementation shared from d4rken-org/.github; this stub only
supplies the triggers, because workflow_call cannot be driven by issue_comment
directly. Pinned by commit SHA rather than a moving tag.
The toast claimed Google Play was not installed for every failed launch,
but the SecurityException path means Play is installed and merely disabled
or profile-restricted - telling those users to install an app they already
have. Neutral wording covers both cases; the resource key is unchanged (no
translations exist yet).
The dialog's fix dispatch ran unguarded: a throwing action crashed the UI
thread from inside a click handler and skipped onDismiss(), leaving the
dialog latched on the current error. The dispatch is now wrapped in
try/catch with onDismiss() in a finally block.
Google Play fix action:
- Drop FLAG_ACTIVITY_NEW_TASK. The action runs on an activity context, so
the flag only detached Play's app info from the caller's task and back
stack.
- Catch SecurityException next to ActivityNotFoundException: Play can be
installed but blocked (disabled app, restricted profile, guarding ROM),
which denies the launch instead of failing to resolve it.
- The fallback toast is now a translatable string resource instead of a
hardcoded literal.
New coverage: ComposeErrorDialogGuardTest pins that a throwing fix action
still dismisses the dialog (shared source set, so both flavors run it),
GplayFixActionTest pins the denied and unresolvable launches showing a
toast instead of crashing, and ComposeErrorDialogTest now asserts the
launch intent carries no NEW_TASK flag.
Two rollback defects found reviewing the failed-start handling, both cases
where cleaning up after a start that could not finish damaged something it
did not own:
- FileLogger.start() deletes the log file only when that same call created
it, and reports the failure instead of swallowing it. A resumed session
appends to the previous recording's core.log, and a failed append used to
erase it while telling the recorder the start had succeeded. The writer is
published only once it is usable, so a failed attempt leaves nothing behind
that would make a later start() a no-op (F3).
- The module's rollback skips self-suppression: a recorder broken in one way
throws the same instance on the start line and again when the rollback
stops it, and addSuppressed(self) raises IllegalArgumentException — which
aborted the rollback before the failure state was committed and took the
shared state collector with it (F4).
Recorder.start() is the only production caller of FileLogger.start(), and it
runs inside the module's whole-branch guard, so the new throw lands in the
rollback rather than escaping.
Fixes review findings F3, F4
A start commits its session dir into the recorder state only once the
recorder is live. For the whole window before that, shouldRecord is set
but isRecording is not, so a scan sees a directory with a non-empty
core.log and no sibling zip - an orphan - and the manager's auto-zip
compresses the directory the recorder is writing into. When the start
then fails, the rollback deletion races that zipper, and the archive
left behind defeats the collision check in createSessionDir: the retry
reuses the session ID of the attempt that just died.
The scan and the recorder state it was taken against now travel as one
value, so the reconciliation can tell a pending start apart from a
settled one and defer new zips until the state is terminal. Running
zips are untouched. A sibling '.zip' or '.zip.tmp' now counts as a name
collision as well, since the session ID is derived from that name.
Fixes review finding F1.
Starting a recording spans several steps — create the session directory,
start the recorder, persist the trigger file, write the header — and only
the last of them commits the recorder into the module's state. Anything
throwing inside that window escaped the reactive collector, which then
died for the rest of the process: the started recorder kept writing where
nothing could stop it, the trigger file survived to re-attempt the dead
session on every launch, and startRecorder() waited forever for a state
nobody would publish. The debug log toggle stayed dead until reinstall.
The whole start branch is now guarded. A failure rolls back first — stop
the recorder, clear the log dir mirror, remove the trigger file, delete a
session dir this attempt created — and only then decides what the failure
means: our own scope dying still takes the collector with it, anything
else (a cancellation from inside the start work included) is committed as
a start failure and surfaced to the caller. shouldRecord is reset with it,
so the every-state collector lands in the idle branch instead of retrying.
The stop branch gets the same treatment: a recorder that cannot stop is
logged and the cleared state committed anyway, so an awaiting stop
completes. Recorder.stop() itself now guarantees logger removal, writer
closure and reference clearing. Session directory names get a collision
suffix, since a same-second retry would otherwise share a directory with
the attempt it replaces, and the public start/stop entry points are
serialized so two callers cannot race the same transition.
The tracking onEach sat downstream of flatMapLatest, so its channel buffer
could hold the Pro emission while the inner flow already threw: the catch
then read a null last-known state and emitted a non-Pro error Info, kicking
a supporter back to the pitch. Tracking now runs in the same coroutine as
the throw.
The retry sits inside the errorContainer card but drew itself with the
default primary-on-surface outlined colors, which clashes with the card and
loses contrast once the tap latch disables it. Content and border now follow
onErrorContainer, with a dimmed disabled pair, and both states get a
preview.
LocalizedError can now carry a fix action, and the shared error dialog shows
it next to a Dismiss button when one is present; errors without a fix keep
today's OK-only shape. The Google Play billing-unavailable error uses it to
open Play's app info as a generic troubleshooting affordance.
The acquisition top bar reused the widgets' flat upgrade label, so the Pro
postfix stayed uncolored while the owned/grace title highlighted it. The
title is now a gplay-only template that takes the composed brand as a
placeholder, spliced back in as the same styled AnnotatedString the status
title uses — word order stays the translation's business. The widget label
keeps its own key and its own wording.
Adds UpgradeHeroCard, which pairs the mascot with the preamble copy inside a
single ElevatedCard and stacks them once the copy runs out of room. Used by
the FOSS pitch view and the GPLAY acquisition view; grace episodes and the
FOSS status views keep their standalone header, which has no preamble to
pair with. Screen tests pin the hero's presence and absence per state.
Restore the consumed sponsor marker only when no newer launch was armed
meanwhile. Make the recorder's monotonic base a nullable sentinel so a
boot-adjacent elapsedRealtime of 0 is not mistaken for a resumed session,
and clear it on stop. Pin the contention test's collector subscription and
assert it actually echoed.
A thrown cache read used to die inside shareIn's sharing coroutine, leaving
every collector waiting forever. Catch inside flatMapLatest, keep the last
known entitlement on late failures, and let a successful persist revive an
error-stuck inner flow.
updateBlocking emitted its update into updateActions FIRST and only then
subscribed internalFlow.first { it.updatedBy == update } against a
shareIn(replay = 1). That is a lost wakeup: if the producer processes our
update plus a successor before the first{} collector is registered, our
identity-matched State is displaced from the replay-1 cache and the await
never completes. A reactive collector makes the successor guaranteed rather
than unlikely - RecorderModule reacts to every state with an update of its
own, so each caller's update immediately breeds another.
The trigger is scheduling pressure: on a 2-core machine the producer and the
awaiting caller no longer run in parallel, so the emit-to-subscribe window is
wide. The CI test jobs wedged at GitHub's 6h job timeout; reproduced locally
under taskset -c 0,1 and confirmed by jstack - the caller parked forever in
updateBlocking's first{} while the producer sat idle with the update long
since processed.
The awaiter is now started UNDISPATCHED before the emit, so it runs
synchronously up to its first suspension inside first's collect and the
collector exists on the shared flow before the update can be processed.
Covered by a contention test that mirrors the module interaction (two
concurrent updateBlocking workers plus a value-neutral reactive echo
collector), and the two recorder test harnesses now wrap their block and
their cleanup stop in timeouts - a regressed await has to fail in seconds
instead of wedging a runner for six hours.
The realtime harness cancelled its module scope but never stopped the
recorder, and cancelling a scope does not uninstall a running recorder's
globally installed FileLogger. A test that started a recording therefore left
one writing into every test that followed, and an assertion failing before the
explicit stop did the same.
The harness now stops the module in a nested finally and fails its own test if
a file logger survived, removing the straggler afterwards so a single leak
cannot cascade. The tracked-recording test gets the same finally treatment.
The "that recording looks very short" prompt measured duration against the
wall clock, so any adjustment mid-recording decided it: an NTP sync or a
manual clock change moving forward made a three-second recording look like an
hour and skipped the prompt, moving backward trapped a long recording in it
with no way past but "stop anyway".
A live session now measures from a monotonic base taken at the start. Only a
session resumed from the trigger file still uses the persisted wall time -
that file has to survive reboots, which monotonic time does not - and a
negative duration there fails open instead of warning.
The threshold moves from 5s to 10s: a recording stopped that quickly holds
nothing but the recorder starting and stopping, which costs a support
round-trip to re-request. It stays a prompt, not a block, because a crash is
logged and flushed immediately.
persistUpgrade() wrote an unconditional record, so any sponsor return that
got past the ViewModel's isPro guard replaced an existing supporter's
upgradedAt - the "supporter since" date the status screen shows - and, for
the legacy records every existing supporter has, their stored reason too.
That guard reads a shareIn replay and can be stale, so it is not a race-free
answer on its own.
The write now happens inside the store transaction: an existing record is
kept and reported back, only an absent one is created. The ViewModel thanks
the user only for an actual unlock and stays quiet otherwise. It also no
longer eats the sponsor visit when something fails - a failed entitlement
read or a failed write restores the pending-launch marker so the next return
can retry, while the error still travels the normal path.
FossCache gains the same constructor test seam BillingCache has, so the
transaction can be exercised against a real DataStore on a temp file.
An auto-created profile without a paired Bluetooth device resolves to MANUAL
mode, so nothing runs in the background while the dashboard claimed to be
monitoring. The dashboard now states that background monitoring is off and
offers to pick a paired device, and the per-card banner names what a missing
paired device costs.
Closes#658
"FOSS" is the flavor's name, not prose. Locale copies of the FOSS badge
and of the composed "CAPod FOSS" title had drifted - some translated the
word, one Thai entry into an unrelated word entirely - all of it shown as
the app's own brand.
Both base entries are now translatable="false" and every locale copy is
gone; with the flag set, any remaining locale entry would trip a fatal
ExtraTranslation in the release lint. The GPlay "Pro" badge is prose and
stays translated.
The debug log header read the flavor's upgrade diagnostics unbounded. A
wedged source (a stuck DataStore file lock, a billing store that never
answers) left the recorder started but never committed, so the user asking
for a log got nothing at exactly the moment the app was misbehaving.
The read now runs under a deadline: a source that hangs or fails degrades
to "unavailable" and the recording starts. Completion is tracked
separately from the value, so a flavor that legitimately has nothing to
report (FOSS) still logs no line at all instead of claiming a failure.
Cancellation is unchanged: an outer scope death still rolls the
uncommitted recorder back.
The GPlay diagnostics' pro-history read gets the same bound its billing
cache read already had.
stampLastProState() was bounded against a wedged file lock, but a write
that failed outright (corrupt preferences file, no disk space) still threw
straight through into the entitlement path it only decorates.
Non-cancellation exceptions from the edit now log a warning and skip the
stamp, same as the timeout does. Cancellation keeps propagating - caught
first on purpose, swallowing it would break the caller's structured
concurrency. Reads stay loud: a snapshot that couldn't be read must not
be mistaken for a never-bought install.
The sponsor unlock heuristic armed itself on every tap, regardless of
whether a browser ever opened the page. An unrelated later background
round-trip could then hand out supporter status with no page ever shown.
WebpageTool.open() now reports whether an activity was actually started
and the FOSS repo passes that through synchronously, so the ViewModel can
only arm after a successful launch. A second tap while a launch is still
pending is ignored, and the upgraded status view's donate button gets its
own unarmed entry point - an existing supporter has nothing left to
unlock, and re-persisting would rewrite their "supporter since" date.
The upgraded status now renders the date the supporter unlocked, derived in the
same emission as the view so the screen never shows the status without the date
it is supposed to carry.
Guards that date: a return from the recurring-donation button no longer runs
persistUpgrade() when the install is already Pro, which would have rewritten
upgradedAt and visibly reset the displayed date. The sponsor-return tracker is
seeded from the handle-backed pending launch so a process death while the
sponsor page is in front does not swallow the first return.
The status views are titled "CAPod FOSS" instead of "CAPod Pro" -- on FOSS the
flavor name is the brand.
The card reports that PRICES could not be loaded, so it now says so instead
of borrowing the generic "Google Play services are unavailable" title, which
contradicted its own body.
The retry latches after the first tap: the guard sits inside onClick because
`enabled` only takes effect after recomposition, so two taps in the same frame
would both fire. It resets naturally when the card leaves composition.
Returning to the screen re-runs the SKU query when it is in the unavailable
state. MainActivity's per-resume refresh only covers the entitlement, so a
transient Play outage left the retry card up until it was tapped by hand.
BillingCache reads and writes are now bounded by a timeout seam: a wedged
DataStore file lock made the debug-log header hang, and a silent fallback to
the default snapshot would have reported "never bought" for an install whose
evidence merely could not be read. Reads now fail loudly, writes fail soft.
UpgradeDiagnosticsGplay absorbs the pro-state history that the recorder header
used to read directly, with a separate failure boundary per source so one
broken DataStore cannot suppress the other's evidence.
RecorderModule's start-failure guard now covers ordinary exceptions, not just
cancellation, stops the uncommitted recorder under NonCancellable and appears
once instead of per resume branch.
- WidgetConfigurationActivity refreshes the entitlement on resume: it is a
second launcher entry point and can't rely on MainActivity reconciling.
- The upgrade-return callback re-asks decideConfirm() instead of trusting the
upgrade activity's result code, so RESULT_OK stays entitlement-gated.
- RecorderModule stops the freshly started recorder when the header's
diagnostics reads are cancelled, instead of leaking an untracked recording.
- FOSS beta channel points at the GitHub releases page; the Play testing URL
is signature-incompatible for FOSS builds.
- Billing bug reports carry the contextual wrapper again, so the report is
grouped by call site instead of the raw billing exception.
Fixes review findings F1, F2, F3, F4, F5.
Replaces capod's older billing core, upgrade UI and their tests with the
canonical sdmaid-se stack at the pinned revision.
Core (gplay): BillingManager/BillingConnection/BillingConnectionProvider on
billing 8.3 with the centralized connect loop, merging purchases-listener
overlay and the canonical ack pipeline; the dying ack collector, the
ackedTokens gate and the in-billing foreground loop are gone. Full canonical
exception set (internal/network/offer-unavailable added), OurSku with capod's
product ids, BillingCache with snapshot()/episode-guarded stampLastProState.
FOSS: UpgradeControlFoss becomes UpgradeRepoFoss and exposes the canonical API
surface over capod's RETAINED FossUpgrade/FossCache schema — existing supporter
records must keep decoding.
Diagnostics: UpgradeDiagnostics + gplay/foss implementations, read by
RecorderModule next to CurriculumVitae's Pro history as two independent,
isolated header reads.
UI: canonical upgrade screens for both flavors under common/upgrade/ui with
capod chrome (M3 AlertDialog keeping rotation-safety, capod Scaffold, capod
previews). Nav.Main.Upgrade gains `forced`. Entitlement refresh moves to a
per-resume, unthrottled MainActivity call.
Strings reuse capod's existing translated ids wherever equivalent; only
referenced-but-missing ones are authored.
mockk 1.12.4 -> 1.14.9: 1.12.4 cannot synthesize a sealed-class return value
while recording, which the ported restore tests need.
Additive infrastructure for the canonical billing port, no coupling to the
billing core yet.
- CurriculumVitae: Pro-state slice only (ProState, ProHistory,
updateProState, proHistory, transition classification, tolerant enum
decode). Raw preference keys so a transition updates state, counter and
timestamp in one DataStore transaction.
- ViewModel4.safeStateIn: render-state flows forward recoverable failures
to errorEvents and emit an explicit fallback state instead of throwing
into collectAsStateWithLifecycle().
- testhelpers: TestApplication, BaseComposeRobolectricTest and the
mockDataStoreValue helper.
UpgradeRepo gains the canonical shape: settledness rides each Info
emission, plus storeSite/upgradeSite/betaSite and a suspend refresh().
getSponsorUrl() is replaced by upgradeSite (FOSS only, GPlay keeps the
heart icon hidden). UpgradeRepoExtensions is the canonical file with
isPro/isProSettled/isProForUi.
UpgradeRepoGplay folds its parallel isSettled flow into Info.isSettled
(behaviour preserving) and implements refresh() as a bounded, unthrottled
call to the existing billing refresh. UpgradeControlFoss is settled from
its first emission and no-ops refresh().
Interactive gates move to isProForUi so a paying user isn't bounced to
the upgrade screen during the GPlay cold-start race: the device-settings
and press-controls pro gates, the theme setters, and the widget confirm
action, which now goes through a sealed ConfirmOutcome so the activity
can only return RESULT_OK for an entitled, valid configuration.
Presentation paths that can't reach a suspending gate (general settings
theme items, overview device limit) render the upgrade branch only when
the entitlement is hard-locked: settled, error-free and not pro.
Two pre-existing concurrency defects in MediaControl (#647).
Lost update: sendPlay() wrote capPaused after the suspending sendKey(),
whose delay(100) is a window in which a concurrent
sendPause(rememberForResume = true) could arm the flag only to have it
overwritten. Stem presses run on the app scope while ear, sleep and
conversation reactions run on the monitor scope, so the senders really
do race. The compound check/dispatch/flag sequence now runs under a
Mutex, the flag is cleared before the first suspension, and the key
pair completes under NonCancellable so cancellation cannot strand an
unpaired DOWN event.
Coalescing blind spot: the playback callback ignored its configs
argument and read live isMusicActive, so queued deliveries all observed
the newest state and an inactive to active edge in between was never
seen, leaving capPaused stale. The edge is now derived from the
delivered snapshot. A pause that passes the live active check records
that observation so an already-queued music-start snapshot cannot drain
later and read as a fresh edge.
Suppressing ForegroundServiceDidNotStartInTimeException and re-entering
Looper.loop() left zombie processes behind that kept collecting ANRs.
Always delegate to the previous handler instead.
Every startForegroundService() re-arms the 10s startForeground() deadline,
even when the service is already foreground. The service only promoted in
onCreate(), so repeated start requests could time out and ANR.
super.onCreate() triggers Hilt's singleton construction, so any log emitted
during that graph build was discarded before the logger existed.
Fixes review finding F2
The test claimed to guard against a main-looper binding, but the
assertion only checks that the injected handler instance is forwarded.
Rename it and document that the Looper identity is covered by the
AndroidModule provider and the runtime thread-name QA check instead.
Fixes review finding F1
MediaControl registered its AudioPlaybackCallback with a null Handler,
binding delivery to the main looper. Both the callback body and the
constructor's seed read call AudioManager.isMusicActive, a binder
transaction into AudioService, producing two ANR clusters: one in
onPlaybackConfigChanged and one in <init>, the latter on the cold-start
critical path since MediaControl is constructed during App.onCreate.
Registration and seeding now run on a dedicated, injected Handler backed
by a "CAPod-MediaControl" HandlerThread, and the callback is delivered on
that same looper. Registration happens before seeding so a transition
during registration is queued behind the seed instead of being lost.
The handler is constructor-injected via a new @AudioCallbackHandler
qualifier so unit tests can drive it without Robolectric.
Scrolling content now slides under the transparent status and
navigation bars instead of clipping at the inset boundary. Adds
PaddingValues.plus and systemBarsAndCutoutInsets helpers, moves inset
consumption from scroll viewports into content padding on every screen,
fixes reorder auto-scroll thresholds for content padding, adds IME
handling to form screens, and removes the unused EdgeToEdgeHelper.
On entry upgradeInfo looks like a non-owner until Play reconciles, so if the SKU query resolves first the offers box briefly rendered the red "unavailable" card before the owner/grace status or prices appeared. Gate that card behind settled and !skuQueryInProgress and show a neutral spinner during warm-up.
Also label the restore-failed dialog's dismiss button "Close" instead of "Cancel" — it reports a result, it doesn't ask to abort an action.
Reshape the Google Play upgrade screen into SD Maid SE's offercard layout:
purchase options as titled offer rows (name · price, terms, action) with an
"or" divider inside one action card, extracted into gplay-local UpgradeContent
/ UpgradeOffers / UpgradeOwnership / UpgradeRestore primitives. Keeps capod's
icon benefits card, splash graphic, and floating back arrow.
Restore now mirrors SD Maid: a reusable restore section (emphasized for
returning buyers), verification-gated across all surfaces, and a restore-failed
dialog that leads with the live Play check and offers Contact support.
Billing logic is unchanged apart from onContactSupport() navigating to the
contact form. Offer rows render conditionally on offer availability; the offers
box AnimatedContent keys on an availability phase so same-state updates
recompose in place.
* feat(upgrade): Add Pro status view, grace UI and sub-to-IAP switch
* fix(upgrade): Pad restore purchase to a minimum visible duration
* ui(upgrade): Mention Play-website install fix in restore troubleshooting
* fix(upgrade): Stop re-acknowledging already-acked purchases
* ui(settings): Move upgrade status row into the Other category
A mode's own drain-rate bucket starts empty until it accumulates history,
so toggling ANC into an unlearned mode fell straight through to Apple's
optimistic spec rating while the mode just left showed its worse measured
rate. Result: enabling ANC could make the displayed time jump up ~1h.
Fill an empty ANC bucket at display time with the less-optimistic of the
mode-agnostic UNKNOWN reading and a sibling mode's learned rate, scaled by
the ratio of the two modes' rated drain. Scoped to spec'd models and
device-supported modes; picks the best-evidenced sibling, tie-broken by
closest rated drain then recency. No persistence or UI change.
The existing spec ceiling and display clamp still backstop the borrowed rate.
- Show the time until the case is full inside its charging chip, learned from
the case's own rising level with the existing charge-band model; no rating
exists for case charging, so the first charge learns before it shows
- Derive a case battery health from observed transfer efficiency: pod percent
gained per case percent spent while docked and unplugged, corrected by each
pod's own health, compared against Apple's "with charging case" totals
- Case data is only genuine while a pod is docked — both transports silently
freeze the last value otherwise. Battery updates now flag whether the case
entry is live, and BLE gains strict same-frame case accessors, so estimates
never learn from frozen echoes
- The case deliberately gets no runtime estimate: idle-then-burst drain has no
meaningful hourly rate
- Mark the case metrics as experimental in the Battery settings card
- Show a "Still determining — check back after a few more listening sessions"
placeholder under Battery Health in the device info sheet while the estimate
is still accumulating data, so the feature is discoverable from day one; no
placeholder for profiles without a paired device, where it could never resolve
- Reorder the Battery card: time remaining & health settings first, then a
divider, then the charging-side settings (charge limit, charged notification)
- Mention the battery-health component in the estimate toggle and reset texts
- Ground the charge-band boundaries in Apple's documented fast-to-80%/trickle
charging behavior and standard lithium CC/CV charging references
- Replace the single linear charge rate with a three-band model (bulk / taper /
trickle) matching lithium CC/CV charging: each band learns its own rate, the
ETA walks the remaining bands, and the spec seed gets a taper haircut for the
slow bands — no more over-promising above 80%
- Base the battery-health figure exclusively on drain observed while the pod is
worn, audio is playing, AND this device is the system's audio sink; idle wear
previously diluted health upward against Apple's listening ratings
- Listening segments are flushed for persistence the moment their gate breaks
(playback stop, docking, transport flip) instead of being discarded with the
cleared window
- The time-remaining estimate keeps learning from all usage — actual current
drain, idle included, is the right basis for "how long will they last"
- The gauge line under the percentage now always shows the runtime estimate
("if used now") even while charging; the time-until-charged moved into the
charging chip itself ("Charging · 25m"), so the two can't be confused
- Battery health is now computed and shown per pod (Left/Right paired row in
the info sheet, mirroring the serial rows) — single-pod listening habits or
a replaced earbud make the sides genuinely diverge, and a combined figure
would mask a failing pod
- While charging, the gauge showed the bare runtime projection when no charge
rate existed yet — "1% · 4m" next to a charging chip reads as a four-minute
charge. The line now shows the "until charged" ETA or nothing
- Seed the charge rate from Apple's published quick-charge claims ("5 minutes
in the case provides around 1 hour of listening"), normalized against the
rated listening hours, so the ETA is present from the very first charge; a
live fit still takes over within minutes
- While a pod charges, fit its rising level and show the time until full in the
gauge instead of the runtime estimate; learned charge rates are persisted per
slot so the ETA appears immediately on later charges
- Suppress the charge ETA during Optimized Battery Charging holds, the final
trickle phase, and whenever the level stalls longer than one visible step
should take (granularity-aware: 1% AAP steps vs 10% BLE steps)
- Clear a slot's fit window when its readings switch between AAP and BLE — the
granularity jump would otherwise read as a fake level step
- Derive a battery-health percentage (median of accumulated drain rates vs the
model's rated life) and show it in the device info sheet; the info button now
also appears for BLE-only devices once health data exists
- Tag learned rates with the model they came from so re-pointing a profile at
different hardware starts learning fresh instead of inheriting foreign rates
- Track how many sessions blended into each learned rate and require three
before a health figure is shown
- Replace the global estimate toggle with a per-device toggle stored on the profile
- Seed the estimate from each model's rated battery life and show it immediately, using
the rating as a hard upper bound on displayed life while the measured rate converges
- When the ANC mode is unknown, seed from the shorter of a model's ANC-on/off ratings
- Show a projection while charging ("if used now") without ever learning from a rising battery
- Consolidate charge limit, "notify when charged", the estimate toggle and reset into one
Battery card; the charge notification now works for any live device, not only classic
audio connections
- Smooth the displayed time asymmetrically (drop fast, rise slow) so a faster-than-rated
drain stops over-promising within a couple of updates
Without tint, white PNG product icons (AirPods photos) are invisible on
light notification backgrounds. Restore src_in tint with notification_icon_tint
(a reliable day/night color resource) in both the PodInfoItemIcon.Notification
style (covers small layouts) and the big layout product ImageViews that do
not use that style.
Remove Theme.Material3.DynamicColors.DayNight from the notification
container style; it does not resolve correctly in the notification host
process, causing light-mode colors to appear in dark mode. Replace all
?attr/colorAccent and ?android:attr/textColorPrimary refs in RemoteViews
layouts with explicit day/night color resources (notification_icon_tint)
and TextAppearance.Compat.Notification.Title defaults. Change tintMode
from multiply to src_in for status glyphs; remove tint from product PNG
icons to prevent silhouette rendering.
Fixes#613
The conversation volume slider committed a profile write on every
drag frame — laggy dragging, and racing async writes persisted stale
mid-drag values (observed: 78 stored while the UI showed 70). Use
local state while dragging and commit on release, matching the
adaptive-noise and tone-volume sliders.
With one AirPod in the case the pod deterministically drops the
terminal 0x4B frame: the wind-down flurry ends on a transitional
status (1,2,3,0xB,4 then nothing), so the reaction never disengaged
and the volume stayed low until the 5-minute backstop (#608).
Reproduced on Pro 3 (fw 6589, 6503) and Pro 2 USB-C (fw 6814) —
all three share one firmware train; with both pods worn the terminal
always arrives.
The pod sends no frames during active speech, so any non-START frame
means the wind-down has begun and a terminal is imminent. A HOLD now
arms a short 6s fuse instead of the 5-minute backstop: if no terminal
(or fresh START) follows, the reaction disengages anyway. The fuse
must stay above ~5s — gaps up to 2.8s were observed between
consecutive wind-down frames. STARTs still arm the long backstop,
since the pod stays engaged and silent against ambient noise for
20-30s+ and a short timeout there resumes media mid-conversation.
Validated on hardware on both models: 7/7 single-pod conversations
restored exactly 6s after the last wind-down frame.
AirPods Gen4 emit a one-off type 0x07 frame from their identity address
on connect whose payload prefix is 0x07 instead of 0x01. Its bytes are
not the plaintext status format, but the model bytes happen to match,
so it minted a fresh device tracker and showed as a duplicate device
card until the stale timeout.
Every known plaintext status broadcast (including pairing mode) uses
prefix 0x01; pairing state lives in the suffix byte. Reject anything
else at the decoder, logging the dropped frame's hex.
Fixes#603
@mvanhorn independently fixed#598 in #605 by no longer resetting the
case cooldown on close, throttling the re-pop. We cherry-picked that
commit above to keep his authorship/credit, but this PR instead removes
the underlying lid-state flapping at its source (out-of-case pod's stale
lid byte -> UNKNOWN), so the cooldown can keep resetting on close and a
genuine close->reopen still shows the popup. Reverting his change here so
the two approaches don't stack; thanks @mvanhorn for the parallel work.
When one pod is out of the case, the out-of-case pod broadcasts bit4-only
frames whose lid byte is stale and decodes to a phantom OPEN even while the
case is shut, interleaved ~1:1 with the correct in-case-pod (bit6) frames.
The derived lid state flapped OPEN<->CLOSED, so the case-open popup re-popped
~0.5s after closing and sometimes lingered. Verified on AirPods Pro 1 (issue
log) and Pro 3 (live BLE capture).
- Trust the lid bit only from in-case-pod (bit6) or both-in-case (bit2) frames;
bit4-only frames now decode to UNKNOWN, and the real state is recovered from a
recent in-case broadcast (matches LibrePods).
- getLatestCaseLidState recovers from history within a 2s age window instead of a
fixed frame count, so a missed CLOSED can't keep a stale OPEN.
- Don't refresh the show-cooldown on a non-CLOSED hide, so a transient UNKNOWN
can't suppress a genuine re-open.
- Add a freshness backstop: dismiss the popup if a fresh OPEN broadcast stops
arriving (device left BLE range while open).
- Key popup/auto-connect de-duplication on the derived lid state, not just raw
advertisement bytes, since the effective lid can change while bytes don't.
Closes#598
Adds a dashboard hint card pointing at the existing Troubleshooter when a profile is connected to the system (audio) but CAPod is receiving no live data — the symptom of a phone dropping AirPods BLE broadcasts (e.g. some HyperOS devices, #603). Debounced ~15s so it doesn't flash during the gap between an audio connection and the first broadcast, and suppressed whenever any pod is live so it never claims 'no data' while data is visibly arriving.
Probe compatibility options through a transient in-memory override on BlePodMonitor instead of writing the user's persisted settings on every attempt. Only the winning combo is persisted, and only on success; failure or cancellation clears the override, restoring the user's original settings. Combos are tried fewest-disables-first so a phone that only needs batching disabled isn't left with filtering disabled too. Per-attempt cache clearing plus a freshness cutoff stop a previous combo's cached devices from satisfying the next one, and the 'found' checks now require a fresh live BLE observation rather than cached/AAP state.
The pod only signals CA start and end, not continuous keep-alives. On fw 6861 it held CA engaged for 21s with zero 0x4B frames while the wearer kept talking, so the 12s stale-timeout fired mid-speech and resumed media; the pod never re-sent a start frame, so it didn't re-pause.
Disengage now waits for the explicit not-speaking frame (status 5 added as a terminal STOP, since that firmware winds down 3->5 and never reaches 6/8/9). Transitional/unknown statuses stay engaged. The stale timer is demoted to a long 5min backstop for a fully-dropped terminal frame; constants moved to Kotlin Durations.
Reacts to the AirPods speaking-detection event (AAP 0x4B): lowers media volume by a configurable amount or pauses playback when you start talking, and reverts when you stop. Per-profile, Pro-gated, opt-in (default off).
Decodes the speaking status from the last payload byte ({1,2}=start, {6,8,9}=stop, else keep-alive); engage/disengage with a frame-idle stale timeout to recover a dropped stop, plus disconnect and service-stop cleanup.
Consolidate the nudge availability DataStoreValue with the rest of the persisted settings instead of carrying it in a separate Hilt module + DataStore file. Matches the existing convention where compat-style flags (offloaded filtering, indirect callbacks, etc.) live alongside theme/notification settings.
NudgeCapabilityStore keeps the verdict-mapping behavior; only the source of the persisted value changes.
- Seed connectedDevices flow with onStart so ALWAYS autolaunch fires before HEADSET profile binds
- Gate UnavailableMissingPermission classification on Android 12+ (BLUETOOTH_CONNECT only exists from API 31)
- Treat blank/empty profile.address as unpaired (matches AutoConnect)
- Subscribe DeviceSettingsViewModel to nudgeCapabilityStore.availability so UI updates immediately on verdict change
- Seed availability StateFlow with the persisted value via valueBlocking to close the cold-start race
Matches Apple's iOS/macOS behavior: stem-press pauses and sleep-detection pauses are explicit user intent, not eligible for auto-resume on next pod-in. sendPause gains a rememberForResume parameter (default false); only PlayPause's auto-pause branch passes true. New sendStop wrapper clears the auto-resume flag for stem-mapped MEDIA_STOP.
Replaces the origin-tracking machinery with a simpler model that matches Apple's iOS/macOS behavior: auto-play strictly resumes a CAP-dispatched pause, gated by a sticky boolean cleared on inactive→active transitions. The original fire-on-cold-wear behavior is preserved as a per-device opt-in 'Start music on wear' setting.
Two SIGSEGV native crashes recurred on Android 10 in 5.1.4-rc0 inside JIT-cached code at toBatteryFloat+4 (popup) and mergeBatterySlot+40 (cache merge). Both functions had a boxed Float? unbox at function entry that R8 horizontally merged into stdlib host classes, where Android 10 ART JIT miscompiled the unbox.
Convert PodDevice battery getters to non-null Float with BATTERY_UNKNOWN sentinel and propagate primitive Float through every display/persistence consumer. mergeBatterySlot now takes primitive Float; toBatteryFloat and toBatteryOrNull are deleted. Add isKnownBattery and batteryProgress helpers used everywhere instead of scattered nullable checks. Raw live extraction in toCachedState avoids touching the unified getter so cached values aren't refreshed as live.
Move consumeUpgradeExtra() out of MainActivity.onNewIntent and into a Compose-level LaunchedEffect that collects a new MainActivity.warmIntents SingleEventFlow.
onNewIntent could fire before the setContent{} lambda registered the back stack with NavigationController, leaving navCtrl.goTo(Nav.Main.Upgrade) to throw IllegalStateException("NavigationController not initialized"). Same race shipped a fix for in octi (d4rken-org/octi#284).
Use AAP-connected mocks for the dark dashboard to showcase full connectivity badges (BLE + IRK + encrypted + AAP) and the ANC mode selector. Populate the add-profile preview with a demo name, model, and paired device. Add user-facing labels to the dashboard mock devices.
Refactor BluetoothDevice2 so name/address are primary constructor fields, letting previews construct one without a real Android BluetoothDevice. Bump the screenshot test JVM heap to 4g — the smoke batch (42 renders) was hitting the test executor's default ceiling.
Mirrors permission-pilot's policy: only the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) check phoneScreenshots PNGs into the repo. Non-smoke locales are excluded via .gitignore. Drops fastlane/metadata/android/ from ~67 MB to ~7 MB and prevents future bloat from full regens. Play Store's supply retains previously-uploaded screenshots for locales not pushed, so full localization is maintained by occasional manual regen + screenshots_only upload.
BluetoothSocket.connect() is a blocking JNI call that ignores coroutine cancellation, so the previous withTimeout in AapAutoConnect only cancelled the suspending wrapper while the native thread stayed pinned. Hung threads accumulated and could trigger ANRs.
Move the timeout inside AapConnection and run the blocking connect on a daemon thread; on timeout, close the socket from the caller thread to unblock the native call (the documented Android pattern for cancelling in-flight L2CAP connects).
Also cancel appScope before delegating uncaught exceptions so coroutines have a best-effort window to release resources before the system handler terminates the process.
Battery slot percent comparisons in mergeBatterySlot/hasStateChanged compiled to Intrinsics.areEqual on boxed Float; R8 optimization on Android 10/11 dropped a null check during inlining and the resulting NPE escaped onEach { persistLiveDevices }, cancelling the upstream combine and freezing every observer of DeviceMonitor.devices.
Comparisons now operate on primitive float (cmpg-float in dex) so no Intrinsics.areEqual call remains in the merge path. The persist loop also catches and reports per-profile, and AAP-only profiles with active DeviceInfo are now persisted even when no BLE pod is in range.
Wrap logging, reporting, and Looper resume calls so the foreground service timing exception suppression cannot itself trigger another crash. Extract handler into a dedicated class with seams for unit tests.
Bit 5 of pubStatus is always set on A3454 and no longer carries the wear flag (unlike Max gen 1). Read bits 1 and 3 instead — the per-earcup sensors. OR rather than AND so phones that only see one bit reliably still report worn correctly.
Closes#548
Sync flags with what the BLE classes actually report and what iOS exposes:
- AirPods Gen 1/2/3: enable hasEarDetection (already parsed via DualApplePods) and hasEarDetectionToggle
- AirPods Gen 3, Pro 1: enable hasEndCallMuteMic (force-sensor stems)
- Powerbeats Pro, Beats Fit Pro: enable hasEarDetectionToggle (iOS exposes it)
- Beats Solo Pro, Studio 3: drop hasEarDetection (over-ear, BLE class is bare SingleApplePods)
- FAKE_AIRPODS_GEN1/2/3: enable hasEarDetection to match HasEarDetectionDual
- Generalize microphone mode description from 'AirPod' to 'earbud'
Tests rewritten as exhaustive set assertions plus implication invariants.
Glance only calls provideGlance() once per widget session; subsequent update() calls recompose the existing composition without re-running provideGlance. The previous capture-once approach left widgets frozen at initial state because the composition had no reactive State to read.
Subscribe to a widgetDeviceFlow(profileId) inside provideContent that pre-filters by WidgetDeviceKey, so the composition recomposes on visible state changes without firing on every BLE advertisement. Replace updateAll() with explicit per-GlanceId update() calls in WidgetManager, with a platform-id fallback when Glance returns no IDs.
Replaces the user-facing scanner mode setting with an automatic policy that picks LOW_LATENCY when a profile-paired device is connected, BALANCED in the foreground, and LOW_POWER in the background. The TroubleShooter scopes a temporary LOW_LATENCY override via a refcounted withTemporaryOverride block so overlapping callers stay correct.
Fixes a regression where the controller could block BLE scanning entirely if BLUETOOTH_CONNECT was missing or the HEADSET profile proxy stalled, and adds a reactive bondedDeviceAddresses flow so bond changes propagate without waiting for an unrelated input. Cleans up the now-dead scanner mode strings across all locales and unused ScannerMode fields.
- Apply seenLastAt freshness to all unauthenticated BLE samples (worn and not-worn). The earlier scoping to not-worn-only collapsed the second worn sample for BLE-only autoplay confirmation, so the staged play never fired.
- Replace distinctUntilChangedBy with a manual filter so worn samples that need to reset an active pause debounce (count went up) can pass through even when the monitor key is otherwise identical.
- Skip BLE-only autoplay confirmation for trusted sources. With BLE_IRK_MATCH and AAP, autoplay now fires on the first not-worn -> worn transition, mirroring the pause-debounce skip on the same sources.
- Skip the reaction entirely when the previous emission had no live evidence (NO_LIVE_BLE). Prevents app-process-start from synthesising a fake not-worn -> worn transition and firing autoplay while the user is already wearing the pods. Same guard handles mid-session BLE gap recoveries.
- Add MonitorFlowTests covering process-start-worn, genuine-insertion-after-startup, mid-session BLE-gap recovery, IRK-matched immediate autoplay, BLE-only autoplay confirmation, 3-sample pause debounce, and rebound-tolerated debounce reset.
- Commit pending pause when a trusted source (AAP / BLE_IRK_MATCH) corroborates the not-worn condition mid-debounce, instead of dropping pending silently.
- Scope debounceFreshness to not-worn samples only; identical both-in samples no longer pass distinctUntilChangedBy and can't accidentally trigger BLE-only auto-play confirmation.
- Add resetTolerance to PendingPauseDebounce so a single corrupt count-up advert no longer kills a legitimate pending pause; reorder reset checks so rawDecision.shouldPlay resets immediately.
- Drop bleKeyState from the INFO autoPause log; source already encodes trust without leaking key-configuration state to logcat.
- Add flow-level MonitorFlowTests verifying the distinctUntilChangedBy interaction with seenLastAt freshness, plus the #557-direction test (AAP-worn vs corrupt-BLE-not-worn) and rebound-tolerance test.
- Clarify in BLE_ANONYMOUS KDoc that the path is unreachable in production via DeviceMonitor.primaryDevice.
Classifies the ear-detection source (AAP / BLE_IRK_MATCH / BLE_PROFILE_FALLBACK / BLE_ANONYMOUS / NO_LIVE_BLE) and applies a 3-sample debounce only to unauthenticated BLE paths. AAP and IRK-authenticated BLE pass through unchanged.
Also tightens toEarDetectionState() to prefer AAP aggregate over BLE per-side bits whenever AAP EarDetection is present, and suppresses pause on NO_LIVE_BLE (cache-only state) to avoid firing without live evidence.
Adds push: [main] alongside workflow_dispatch so edits to README.md, _config.yml, _layouts, or the CHANGELOG.md template publish without a manual dispatch. The chain step in release-tag.yml still runs after release publish to guarantee the new release is in site.github.releases by the time Pages rebuilds — concurrency: cancel-in-progress: false serialises the two runs.
Adds an if: github.ref == 'refs/heads/main' guard on the deploy job so workflow_dispatch from a non-main branch builds for verification but doesn't deploy.
After moving Gemfile/Gemfile.lock to fastlane/, the - Gemfile / - Gemfile.lock entries in _config.yml's exclude list are no-ops; the parent fastlane exclude already covers everything inside.
.gitignore picks up _site/, .jekyll-cache/, vendor/bundle/ so local Jekyll runs don't leave tracked artifacts.
release: published events triggered by secrets.GITHUB_TOKEN do not start new workflow runs (only workflow_dispatch and repository_dispatch are exceptions). The Pages workflow's release: published trigger would never have fired in production since release-tag.yml's softprops/action-gh-release uses GITHUB_TOKEN to publish.
Fix: drop the release: published trigger and have release-tag.yml's release-github job explicitly run gh workflow run pages.yml --ref main after the release is created. release-github gains actions: write to authorize the dispatch.
Also adopts refinements from sibling org PRs (permission-pilot#356, bluemusic#220):
- Top-level permissions reduced to contents: read; pages: write and id-token: write moved to the deploy job only (least privilege)
- JEKYLL_GITHUB_TOKEN on the build step so jekyll-github-metadata authenticates when fetching site.github.releases
- Sanity-check step (test -f _site/index.html && _site/CNAME) fails fast if Jekyll produced nothing
- Explicit upload-pages-artifact path: ./_site matches the build's destination
- Verify fastlane Bundler wiring step (bundle exec fastlane --version) lets workflow_dispatch dry_run=true exercise the relocated Gemfile before the next real release
The root Gemfile only ever declared the fastlane gem and lived next to fastlane configuration anyway. Moving it under fastlane/ matches that ownership and keeps the repo root cleaner.
release-gplay job now sets BUNDLE_GEMFILE=fastlane/Gemfile and runs ruby/setup-ruby with working-directory=fastlane so bundler-cache resolves the moved Gemfile. fastlane lanes still run from the repo root.
Replaces the auto pages-build-deployment (which still uses Node-20 actions/checkout@v4 and actions/upload-artifact@v4) with a custom workflow using configure-pages@v6, jekyll-build-pages@v1.0.13, upload-pages-artifact@v5, deploy-pages@v5.
Triggered by release publication so the changelog Liquid template (which reads site.github.releases) only rebuilds when a release actually exists. workflow_dispatch is kept for manual rebuilds when debugging Pages content.
- Hide reactions and AAP sections unless device is classically connected
- Move advanced-settings-unavailable card to the bottom of the list
- Show 'device not nearby' infobox when out of range
- Show missing-paired-device banner with edit-profile action
- Replace pending banner with snackbar on user-initiated change
Verified on a real AirPods Pro 3: toggling flips pod charging state from CHARGING_OPTIMIZED to CHARGING and persists across reconnects. Apple-bool wire format is confirmed, so the 'experimental' warning box is no longer warranted.
Adds a per-battery 'Optimized' chip on the overview card when pods report wire value 0x05 (CHARGING_OPTIMIZED), which was already decoded but collapsed into a plain 'Charging' in the UI. On AirPods Pro 3, also adds a user-facing toggle for the device-side Optimized Charge Limit (AAP setting 0x3B).
- Decode setting 0x3B via decodeAppleBool so unknown values fall through instead of coercing to false
- Bypass ear-detection queue for SetDynamicEndOfCharge so the toggle works while pods sit in the closed case
- Expose per-slot ChargingState? on PodDevice; StatusChipRow renders 'Optimized' for CHARGING_OPTIMIZED, 'Charging' for CHARGING
- New BatteryCard in device settings with experimental warning (pattern matches Sleep Detection)
- Generic settingRejectedEvents flow alongside the existing offRejectedEvents so the toggle can show a dedicated snackbar on verification failure
Adopts the Wireshark AAP dissector (pabloaul/apple-wireshark) as a third reference source alongside LibrePods and MagicPodsCore. Catalogues every known message type and control/setting ID, corrects DeviceInfo field labels, and adds sealed AapPacket hierarchy with Connect Response parsing. Case Info probe (Pro 3), Sleep event, and Dynamic End of Charge decoders are in place for future use.
Introduce BatteryLayout enum with size-driven dispatch. Drops minWidth from 80dp to 40dp so the battery widget can be placed at one cell. At 1-cell-wide placements render a compact icon+percent stack; wider sizes keep the current NARROW/WIDE layouts.
Drops stale app-common/Wear OS references, renames migrated classes
(ReactionSettingsFragment/PopUpPodViewFactory/PodMonitor), documents
the BLE/AAP split in monitor/core, the layered AAP stack, Widget
(Glance) and Upgrade subsystems, Navigation3 + legacy helpers. Fixes
localization examples and screenshot pipeline counts.
New home-screen widget that toggles AirPods ANC modes (Off / ANC / Transparency / Adaptive) directly, with six adaptive layouts that pick based on widget size (QUAD_CORNERS, ROW_ICONS, COLUMN_ICONS, GRID_2X2, ROW, COLUMN). Consolidated battery+ANC configuration into a single ViewModel that detects widget type from AppWidgetManager. ACTIVE state uses Material3 secondaryContainer/onSecondaryContainer for guaranteed contrast. Includes live config preview, preview subtitle, stale-selection guard, device-label toggle, and aligned icons with the app's AncModeSelector.
Adds Stop, Fast Forward, Rewind, Mute, Cycle Noise Control, and Toggle Transparency as gesture targets. Migrates StemAction from enum to sealed interface with polymorphic serialization to unlock future parameterized actions. ANC actions are gated per-device capability and reuse the existing listening-mode cycle mask.
Move press timing, call controls, and stem mappings out of the Controls card into a new Press Controls screen. The screen name is device-agnostic so it applies to both stemmed AirPods and the AirPods Max's Digital Crown/noise button.
Stem mappings are now per-device (stored on the AppleDeviceProfile) rather than a single app-global DataStore. The Pro gate for mappings moves from screen entry to per-change with an Upgrade badge on the mappings card header, so free users can still access the non-Pro press timing and call-control settings that live on the same screen.
Sort profiled devices into tiers (system-connected > nearby > cached), ordered by profile list within each tier. Collapse non-pinned cards to a compact battery tray with mini gauge rings matching the expanded card design. System-connected and top devices stay expanded; others can be tapped to toggle.
Split AapSessionEngine into dedicated controllers (AapAncController, AapOutboundController), a typed inbound decoder (AapInboundInterpreter), a HID frame batcher (HidTracker), and a device-info diagnostics helper (AapDeviceInfoDiagnostics). Each controller returns typed decisions carrying state, timer actions, and logs instead of mutating engine state via callbacks. AapSettingsCoordinator is now stateless — pending queue and verification state live in engine runtime state.
All coroutine timer Jobs live in the engine's timerJobs map keyed by EngineTimerKey, with cancelAllTimers() on reset to prevent forgotten cancellations. Engine event dispatch is split: suspend path for user-initiated sends (errors propagate to caller), non-suspend for sync events (timer fires, inbound updates).
Fix Elvis operator precedence bug in visibleAncModes() where OFF passed the filter unconditionally. Move filtering logic to PodDevice.visibleAncModes extension, unifying DualPodsCard, SinglePodsCard, and DeviceSettingsScreen. Gate AllowOffOption inference on pod-in-ear + 1.5s stability to prevent false positives from in-case OFF reports.
During case transitions, AirPods send 800+ cmd 0x0017 HID frames in ~20s. Previously each logged identically at VERBOSE with raw hex (~160KB noise). Now a HidTracker classifies frames (service directory, descriptor bulk, terminator) and batches consecutive bulk frames by (phase, fill), emitting 3-4 summary lines instead of 822.
Track lastAncSentAt separately so non-ANC commands during flush don't break the ANC echo debounce window. Prioritize ANC for post-flush verification.
Move HANDSHAKING→READY transition to top of processMessage so decoded battery, stem press, and device info messages also trigger it.
Sort flush: AllowOffOption before AncMode before others, preventing device rejection when enabling OFF mode.
Queue all setting commands when no pod is in ear, flush when a pod goes in. Enable the adaptive noise slider when ADAPTIVE mode is pending. Show an info box when settings changes are pending.
Extract AapSessionEngine (state, send path, message processing, inference) and AapSettingsCoordinator (queue, optimistic updates, verification) from AapConnection, reducing it from 773 to 173 lines.
Move serial, firmware, build, manufacturer, and per-pod serials from
the info card into a ModalBottomSheet triggered by an info icon.
Firmware+build and left+right pod serials render as paired rows.
Device info card: add model label with Apple model number, rename label to
Bluetooth Device Label, show cursive font on name mismatch with system BT
name, combine first/last seen on single row, add profile prefix to subtitle.
ANC mode: extract shared AncModeUi helpers for labels and icons, update
overview cards and device settings to use shared components.
Swap the logical mapping for `EndCallMuteMic` settings to align with hardware states observed on AirPods Pro devices. Updated session tests for Pro 1, 2, and 3 to reflect the corrected protocol behavior.
Based on Apple's official documentation, enable features for models
that support them per iOS but were previously excluded in CAPod:
- Gen 1/2: microphone mode (auto/left/right)
- Gen 3: press speed, press hold duration, tone volume, microphone mode
- Gen 4 non-ANC: press speed, press hold duration, tone volume
- Max original/USB-C: listening mode cycle, allow off option
Add SettingsInfoBox title support and experimental feature warning for Sleep Detection and Personalized Volume toggles with issue tracker action.
Pro-gate tone volume, microphone mode. Un-gate sleep detection, conversation awareness. Fix SettingsSwitchItem allowing direct switch toggle to bypass requiresUpgrade.
Older devices silently ignore the 0x4D packet, so there is no need to gate it per model. Sending it unconditionally prevents silent feature degradation when new H2+ models are added without the flag.
Remove needsInitExt from PodModel.Features.
Combine reaction one-pod mode (autoplay/autopause) and firmware NC-with-one-AirPod into one setting. The merged toggle always sets the app-local flag and additionally syncs the firmware NC setting via AAP when connected.
Also remove redundant @OptIn(ExperimentalCoroutinesApi::class) annotations from tests, already covered by the global compiler opt-in flag.
Real AirPod firmware silently drops writes with the LibrePods-documented 0x21 subtype. Switch to the compact 0x20 format that matches what devices actually emit in their echo frames. Also enforce the complementary-pair invariant at the SetEndCallMuteMic command boundary so validation runs before the optimistic UI update.
The encode/decode now flips UI 0..100 to wire 100..0. Update the clamp and decode expectations accordingly, and add a round-trip test to guard against asymmetric changes in either direction.
Switch the 0x4D init packet flags from 0x0E to 0xD7 to match the value captured from Apple's own stack (librepods AACPManager). The previous value traced to MagicPodsCore and matches no known Apple capture.
Invert the Adaptive Audio Noise level on write and read: wire 0 means max noise reduction, wire 100 means transparency-like. UI stays in intuitive 100 = max NC semantics, matching librepods' slider behavior.
Three dividers separate the logical clusters inside the Reactions section: ear-detection behaviors, AAP auto-behaviors (when connected), connection settings, and pop-up notifications. AAP divider only renders when at least one AAP item is shown.
AAP vs BLE is an implementation detail. From a user perspective these are 'when X happens, do Y' toggles like Auto Play/Pause. Gate them on device.isAapConnected so they stay hidden on phones without L2CAP support, avoiding confusion. The separate 'Smart features' section and its string resource are removed.
Visual: wrap Noise Control in SettingsSection for consistency with Sound/Controls/Smart features, add divider between ANC mode picker and trailing items.
Categories: rename 'Other' to 'Smart features', move Microphone Mode to Sound, move Conversation Awareness to Smart features alongside Sleep Detection, place Adaptive Noise adjacent to ANC mode picker.
Extraction: split DeviceInfoCard, NotConnectedCard, AapUnavailableCard into cards/; AutoConnectConditionDialog, RenameDialog, SystemRenameUnavailableDialog into dialogs/; NoiseControlCombined into components/. Each with isolated previews.
PlayPause coerced null per-side ear values to false, making all ear states invisible when resolvedPrimaryPod was unknown. Fall back to AAP aggregate state (isBeingWorn/isEitherPodInEar) when per-side mapping is unavailable, keeping PodDevice.isLeftInEar/isRightInEar truthful for UI consumers. Add isEitherPodInEar to PlayPauseMonitorKey for proper dedup. Add diagnostic logging at the distinctUntilChangedBy boundary.
Hide the overlay popup when MainActivity is in the foreground since the user can already see battery info in the app. Show an info card when popups are enabled explaining they only appear outside the app. Show a warning card with a Fix button when monitor mode is MANUAL.
Refactor SettingsInfoBox into a reusable component with INFO/WARNING types and optional action slot.
Move each composable from PodCardComponents.kt into its own file under a new cards/components/ subpackage. Add previews to each file. Also add getBatteryIcon() for Compose Material Icon battery levels used in the popup.
When both pods broadcast independently (one in case, one on desk), the pod inside the case carries authoritative case state via hasCaseContext bits. Previously, whichever address was processed last would overwrite the other, causing case state to flip-flop between OPEN and NOT_IN_CASE every scan cycle.
Two-layer fix: BlePodMonitor.processWithCache() now prefers the pod with case context when two scan results map to the same identity in one batch. ApplePodsFactory.getLatestCaseLidState() no longer treats NOT_IN_CASE as authoritative when recent history contains a broadcast with case context.
Serialize AapConnection.send() with a dedicated sendMutex so two concurrent optimistic state updates cannot clobber each other. Collapse the OFF toggle click into a single combined VM call that runs both SetListeningModeCycle and SetAllowOffOption sequentially.
Wrap setting sections in SettingsSection cards using Material 3 surfaceContainerLow surfaces with rounded corners. Reorder Controls by usage frequency, move Microphone Mode to Other section, and replace ear detection info row with a contextual info box that only appears for BLE-only connections.
Migrate reaction toggles (auto-play, auto-pause, auto-connect, popups) from global ReactionSettings singleton to per-profile fields on AppleDeviceProfile. Each paired device can now have independent reaction behavior.
Extract ReactionConfig snapshot to decouple PodDevice from AppleDeviceProfile — reaction consumers read device.reactions instead of device.profile. Remove auto-connect from upgrade benefits (now free). Add LegacyReactionSettingsReader for one-shot DataStore migration.
Makes the inline upgrade badge a tiny star + short label (Pro on gplay, FOSS on foss) instead of icon-only. Text is flavor-switched via upgrade_badge_label in each flavor's strings.xml, primary color, labelSmall typography, no pill background to keep it lightweight when multiple rows stack it. Accessibility uses the existing common_upgrade_required_label.
The 'Pro' label is gplay-specific vocabulary — FOSS unlocks the same features via sponsorship. Aligns with the existing flavor-neutral terms already used in UpgradeRepo, Nav.Main.Upgrade and launchUpgrade(). Renames the proLocked parameter to requiresUpgrade across SettingsBaseItem and its three wrappers, and adds a new common_upgrade_required_label string (Requires upgrade) for the badge content description.
Unifies the three inconsistent pro-gating UI patterns (text button header, star-replaces-switch, silent gates) into a single inline star badge next to the title, driven by a new proLocked parameter on SettingsBaseItem that propagates to all wrappers. Switches stay visible so users can see state and disable ex-pro toggles. Hides the Noise Control visibility buttons when non-pro and surfaces a dedicated cycle customization row with the indicator. Deletes two duplicate local ProGated composables.
Groundwork for a future pro indicator on the overview card's ANC mode selector. Adds isPro + onUpgrade parameters to OverviewScreen, PodDeviceCard, DualPodsCard, SinglePodsCard (currently unused inside the cards, defaults keep existing previews working). Wraps AncModeSelector in a Box as a scaffold for an overlay. Bumps surfaceContainerLowDark to match surfaceContainerDark in all three theme palettes (Amber/Blue/Green) across standard/medium/high contrast. No user-facing change yet.
After the AAP rename succeeds, try to update Android's per-device bond alias via the hidden BluetoothDevice.setAlias(String) method, so the new name also shows up in the system Bluetooth settings on this phone.
Known failure mode on Android 12+: setAlias is gated behind a Companion Device Manager (CDM) association at the service layer, and raises 'does not have a CDM association with the Bluetooth Device' for third-party apps that don't hold one. In that case, a dedicated snackbar explains that the system rename didn't go through and suggests renaming manually in system settings or re-pairing.
The AAP-level rename is always attempted first and is the load-bearing part; the system alias is a best-effort extra.
Switch the AAP rename packet to the opcode 0x1A format (04 00 04 00 1A 00 01 [size] 00 [name]) matching the LibrePods documentation and Linux implementation. The previous 0x1E variant (from the LibrePods Android code) was silently ignored by AirPods Pro 2 USB-C firmware — no 0x001D echo, no persistence across reconnect.
Verified on AirPods Pro 2 USB-C (firmware 81.2675...): the device now echoes the new name back via the next 0x001D INFORMATION message, and the name persists after disconnect/reconnect.
Also hardens the rename UX: gate the edit icon on isAapReady (was isAapConnected, which allowed sending during HANDSHAKING), apply an optimistic deviceInfo update with a scoped rollback on send failure, surface send errors via a new Event.SendFailed + snackbar, and restrict dialog input to ASCII with inline error feedback. Unifies send() / sendProGated() through a single sendInternal() helper so error plumbing benefits every command, not just rename.
Note: this only updates the AirPods firmware's self-reported name. Android's system Bluetooth settings read from the bond database and are not affected — renaming there still requires the Android system Bluetooth UI.
Diagnostic-only NUL-delimited UTF-8 segmentation of the 0x1D INFORMATION packet, logged at INFO so it lands in debug recordings via the existing FileLogger pipeline. The production decode path stays untouched — this only adds visibility, no behavior change. Refs #173.
- Show connected device MACs in correct byte order (remove stale reversal)
- Rename 'Volume Swipe Length' setting to 'Volume Swipe Wait Time'
- Hide 'Charging Sounds' toggle: real case tones go over ATT and the actual effect of this AAP setting is unknown. Decode kept internally so AAP freshness signal still refreshes.
- Fix duplicate commands on End Call/Mute Mic radio buttons: use Modifier.selectable with Role.RadioButton, short-circuit when already selected, and wrap options in selectableGroup for TalkBack.
Fix volume up/down doing nothing by using adjustSuggestedStreamVolume instead of dispatchMediaKeyEvent which ignores volume keycodes.
Add reset-to-defaults button with confirmation dialog in the stem actions TopAppBar.
Rename 'None' to 'Default' (firmware handles the press) and add 'No Action' (claimed but do nothing) to correctly model per-press-type claim mask semantics.
Remove disableNone lock; add cross-side auto-set logic so selecting Default resets both sides and selecting an action promotes the other side from Default to No Action.
Combine ANC mode selector with listening mode cycle into a card with eye icon visibility toggles and radio button mode selection. Send AllowOff command when toggling OFF visibility.
Fix ANC resend logic fighting rapid mode changes by cancelling pending resend jobs on new commands. Reorder settings into Noise Control, Sound, Controls, General, and Connections sections.
Add 8 new AAP writable settings (microphone mode, ear detection toggle, listening mode cycle, allow off, stem config, sleep detection, in-case tone, device rename) and 4 new data reception features (stem press events, connected devices, audio source, EQ data).
Implement stem press action system with per-bud configurable Android actions (play/pause, next/prev track, volume), auto-sent stem config on connection, and dedicated config screen.
Combine ANC mode selector with listening mode cycle visibility into a unified noise control component. Eye icons control which modes appear in the dashboard card and settings. Pro gating with stars icon for upgrade-required features.
Add 64 unit tests covering all new decoders, model feature flags, malformed payloads, and rename byte-length validation.
Remove timestamps, connection state, and conversation awareness from overview cards. Move signal badge inline with model subtitle. Relocate removed info to device settings screen info card. Fix cached devices showing Gen 1 icons by adding per-pod icon properties to PodModel. Add profile name subtitle to device settings toolbar. Simplify preview coverage to full/minimal/cached variants.
AAP connections now run in appScope via AapLifecycleManager, independent of MonitorMode. Fixes AAP not connecting when monitor mode is MANUAL.
Also fixes reconnect cleanup on flow cancellation (try/finally).
Free users see only their highest-priority profiled device. Additional devices are hidden behind an upgrade card showing the count and a flavor-aware CTA (Upgrade/Donate).
Wire up the missing drag-to-reorder UI that the priority hint already advertises. Long-press a profile row to reorder. Extracts a reusable ReorderableState component into common/compose.
Backend (repo + ViewModel) was already in place; this adds the gesture handling, auto-scroll, visual feedback, and ID-based reorder validation.
Move per-device persist logic from DeviceMonitor into a pure PodDevice.toCachedState() extension in the cache package. Add ToCachedStateTest with coverage for creates, skips, dedup, and slot preservation. Delete unused PodSorter.
Eliminate DeviceStatePersister class by chaining persistence as a side effect in DeviceMonitor's flow. The flow is now shared via replayingShare(appScope) so persistence runs once per emission regardless of subscriber count, and works for BLE-only devices without MonitorService.
Add label property to PodDevice, populated from the profile in DeviceMonitor. Cards now use device.label instead of reaching through BLE metadata, so cached-only cards display the profile name instead of '?'.
Move DeviceStatePersister and AapKeyPersister from reaction to monitor package since they are always-on infrastructure, not user-togglable reactions.
Add periodic ticker to BlePodMonitor to force stale device eviction when BLE scanner produces no results, fixing cached card not appearing after disconnect.
Replace PodDeviceCache (raw BLE scan bytes) with DeviceStateCache that stores decoded combined device state (battery, charging, model) per profile.
Battery values persist across app restarts with per-slot timestamps. Cached-only cards appear for offline devices with muted visuals and a staleness indicator. Fallback chain: AAP -> BLE -> cached.
Add AirPodsPro2UsbcAapSessionTest with real captured bytes from a live
device session (model A3048, Pixel 8, 2026-04-02). Covers handshake,
device info, battery states, private keys, all Pro 2 USB-C settings
(including ADAPTIVE ANC, VolumeSwipe, ConversationalAwareness),
ear detection across 6 transitions, and unhandled messages.
Also document ChargingState observations across models and
EndCallMuteMic subtype variations in code comments.
Add @Stable to PodDevice to enable Compose referential equality checks, reducing unnecessary recompositions from the 3-second update ticker. Make icon properties non-null with built-in defaults and fix lazy list keys to use stable string identifiers.
Remove false positive hasEarDetection from Beats Studio Buds and Studio Buds+ (neither has in-ear detection). Remove false positive hasVolumeSwipe and hasVolumeSwipeLength from AirPods 4 ANC (volume swipe is exclusive to AirPods Pro). Add missing hasEarDetection to AirPods Max, AirPods Max USB-C, AirPods Gen 4, Beats Solo Pro, and Beats Studio 3 (all have head/ear detection per Apple docs).
Add AirPodsProAapSessionTest with real captured protocol data from AirPods Pro (A2084). Fix AIRPODS_PRO feature flags: remove hasVolumeSwipe/Length (hardware limitation), add hasEndCallMuteMic (firmware-supported). Fix primary pod decoder to accept byte[2]=0x00 format sent by Pro 1 on initial connect.
Only attempt AAP connections to classically-connected devices, eliminating futile retries for devices connected to other phones.
- Filter initialConnect() to devices in connectedAddresses
- Use mapLatest so stale retry loops cancel on state changes
- Parallelize per-profile connection attempts
- Add 5s connect timeout (best-effort) to cap retry cycles
- Add classic BT check to reconnectOnDisconnect()
- Keep MonitorService alive when AAP connections are active
When connected via AAP, the device reports its hardware model number. If the profile has a wrong or missing model, detect the mismatch and automatically correct it with a reconnect to apply correct feature flags (ANC modes, InitExt, etc.).
Adds modelNumbers field to PodModel enum with Apple hardware identifiers for all known devices, and a fromModelNumber() lookup function.
Initial connect had no retry — a single failed L2CAP attempt was silently swallowed. Reconnect-on-disconnect used separate longer backoff delays.
Both paths now share the same retry schedule (3s,3s,3s,5s,5s,10s,10s) giving 7 retries over ~39s. Initial connect checks if another path already reconnected before each retry.
initialConnect() only ran on profile changes, so if the L2CAP connection failed at service start (device not yet connected), it was never retried. Now also triggers on connectedDevices changes.
Also fix reconnect BLE address comparison: was comparing BLE RPA with bonded BR/EDR address (never matches), now uses profile address.
Decode cmd 0x0006 as EarDetection with per-pod placement (IN_EAR, NOT_IN_EAR, IN_CASE, DISCONNECTED). Map AAP primary/secondary to left/right using BLE primary pod bit.
Queue ANC mode changes when no pod is in ear, auto-send when a pod goes in ear. Show pending mode in UI with secondary color treatment.
Debounce device-initiated ANC mode cycling during ear transitions. Skip debounce for user-initiated commands and initial handshake. Optimistic UI update on send for instant feedback.
Move BLE key and AAP connection icons into the SignalBadge pill.
Add BleKeyState enum on PodDevice to expose IRK/ENC state cleanly.
Key icon: outlined for IRK-only, solid for IRK+ENC.
Bluetooth icon shown when AAP transport is active.
Solo Pro (0x0C20), Solo 4 (0x2520), Solo Buds (0x2620), Studio Buds (0x1120), Studio Buds+ (0x1620), Studio Pro (0x1720). Tests use handcrafted data pending real captures.
Move BLE-specific code (snapshots, devices, factories, protocol) under apple/ble/. Move AAP code under apple/aap/ with protocol/ subdirectory. PodModel stays in apple/ as the only shared type.
Standardize the formatting of pod models and their feature sets. This improves the readability of device-specific capabilities and ensures cleaner diffs for future hardware additions.
Full integration of the Apple Accessory Protocol (AAP) over L2CAP, enabling direct communication with AirPods for 1% battery granularity, ANC mode control, Conversation Awareness toggle, and private key exchange for BLE encrypted battery.
Complete type rename chain: PodDevice (interface) becomes BlePodSnapshot, MonitoredDevice (facade) claims PodDevice name, Model enum extracted to top-level PodModel. Delete redundant type alias files. Fix stale comments and log tags.
Replace direct PodDevice/PodMonitor usage with MonitoredDevice/DeviceMonitor across ViewModels, UI screens, notifications, widgets, reactions, and service. All interface cast-based property access replaced with flat MonitoredDevice properties. Make L2capSocketFactory injectable.
Introduce type aliases for the planned rename (PodDevice -> BlePodSnapshot, PodMonitor -> BlePodMonitor). New code uses the aliases to clarify BLE-specific types vs the unified MonitoredDevice facade.
Full codebase rename deferred to IDE refactoring pass (103 files, 470 occurrences).
MonitoredDevice unifies BLE and AAP data sources with dynamic resolution.
DeviceMonitor combines PodMonitor + AapConnectionManager into a single Flow.
To be renamed to PodDevice/PodMonitor when the old types are renamed to BlePodSnapshot/BlePodMonitor.
Add explicit permissions and persist-credentials: false to all workflows.
Without an explicit permissions block, GITHUB_TOKEN inherits the repo default (write-all). These CI workflows only need contents: read. The release workflow already declares contents: write at job level where needed.
persist-credentials: false prevents the token from lingering in .git/config for subsequent steps, reducing attack surface if a third-party action is compromised.
BluetoothHeadset.connect() requires MODIFY_PHONE_STATE on modern Android, which is a system-only permission. Detect the SecurityException and stop further attempts instead of retrying every second.
AGP 9.0.1 no longer generates a separate mapping.txt for bundle tasks when -dontobfuscate is active. The mapping param was causing supply to fail on a non-existent file.
Wrap super.onCreate() (Hilt injection) in try-catch to prevent
DI failures from crashing the process. The service is already
foreground at this point, so a graceful stopSelf() satisfies the
FGS timeout requirement without killing the app.
ComposerImpl.changed() boxes Float? and calls Float.equals() at the ART native level, triggering a known crash. Non-null Float params use the primitive overload with no boxing.
Introduces BATTERY_UNKNOWN sentinel, toBatteryFloat(), and toBatteryOrNull() extensions to eliminate Float? from all composable signatures and WidgetRenderState data classes.
Calling startMonitor() in App.onCreate() started the foreground service timeout
clock before the BroadcastReceiver even ran, consuming timeout budget
with post-init work and receiver processing. The service is already
started by BluetoothEventReceiver, BootCompletedReceiver, and
OverviewViewModel, making this call redundant.
Allow the app to scan for AirPods when only BLE scan permissions are granted, without waiting for all optional permissions (notifications, overlay, etc.).
Add isScanBlocking flag to Permission enum. Gate monitor service and pod scanning on scan permissions only. Show scan-blocking permission cards with error color and sorted first. Wrap BLUETOOTH_CONNECT-dependent calls in try-catch for graceful degradation. Fix POST_NOTIFICATIONS minApiLevel from S (31) to TIRAMISU (33).
The keep button's onClick handler was already named onKeep but the label said Close. Renamed to Keep for clarity alongside the Delete/Share buttons. Added dedicated string resource with translations for all 75 locales.
Remove Moshi dependency entirely, completing the migration to kotlinx.serialization. All JSON serialization now uses kotlinx with explicit @SerialName annotations for wire format stability.
- Migrate PodDeviceCache from Moshi to kotlinx Json injection
- Add MapIntByteArrayBase64Serializer for BleScanResult cache compat
- Strip @JsonClass/@Json annotations from all dual-annotated classes
- Delete Moshi adapters, ProGuard rules, and build config
- Convert compat tests to pure kotlinx round-trip tests
The debug recording trigger file now stores the session directory path and start timestamp. On app restart, the recorder resumes into the same session directory and log file instead of creating a new one, preserving the original start time so the short-recording guard doesn't reset.
Replace all 11 instances of MaterialAlertDialogBuilder across 5 files with Compose AlertDialog. Use sealed dialog state per screen to prevent dialog stacking. Add shared ConfirmationDialog composable. Delete dead ErrorDialog.kt.
Parse creation time from the embedded filename timestamp instead of relying on filesystem attributes, which are non-deterministic and caused flaky test failures in CI.
After sharing a debug log or sending a contact form email, show a dialog on return asking if the send was successful. Confirming deletes the log session and closes/navigates back.
Replace Array<String> with List<String> in Zipper.zip().
Add global -opt-in flag for ExperimentalMaterial3Api in build.gradle.kts
and remove per-file @OptIn annotations from 12 Compose screens.
Replace sessions.first() inside fsMutex.withLock with synchronous volatile
read to avoid potential deadlock. Wrap file deletions in IO dispatcher.
Log partial delete failures. Make pendingAutoZips thread-safe via
Collections.synchronizedSet. Add test for ext/cache same-basename IDs.
Introduce DebugSession sealed interface as single source of truth for session lifecycle (Recording, Compressing, Ready, Failed). Auto-compress after recording stops. Replace scattered clear/delete actions with a session manager bottom sheet on the Support screen.
Extract pure decision functions from AutoConnect and PopUpReaction for testability, following the existing PlayPause pattern. Add FakeDataStoreValue test helper.
Fix reversed Duration.between args in PopUpReaction connection monitor that caused the false-positive age filter to never trigger.
R8 full mode strips no-arg constructors from work-runtime classes accessed via
Class.newInstance() reflection (WorkDatabase_Impl, OverwritingInputMerger, etc).
Replace narrow WorkDatabase_Impl keep rule with broad androidx.work.** rule.
Also add @Keep to Hilt WidgetEntryPoint and wrap provideGlance setup in
try-catch so the widget shows an error message instead of spinning forever.
Rewrite SKU type system from data class to interface hierarchy supporting both IAP and subscriptions. Add subscription query, offer matching, and billing flow launch for yearly plans with optional free trial.
FOSS: Add sponsor-gate with 10-second timer and snackbar nudge.
Replace FlowPreference<T> wrapping SharedPreferences with DataStoreValue<T> wrapping AndroidX DataStore. Includes SharedPreferencesMigration for preserving existing user data, kotlinx-serialization for complex types (replacing Moshi for preferences), and comprehensive unit tests for the new infrastructure.
PodMonitor's retryWhen always returned true, causing infinite 3-second retry loops when BLUETOOTH_SCAN permission was missing (e.g. on emulators). Now the scan flow checks missingPermissions before starting, and SecurityException aborts retries since it requires user action.
Add dedicated Compose upgrade screens for both FOSS and gplay flavors, presenting pro benefits before the purchase/sponsor action. Migrate all upgrade dialog callers to navigate to the new screen instead.
Add collectAsStateWithLifecycle for all Compose state collection (except Glance widgets). Add asLiveState() extension to ViewModel2 as a simpler replacement for shareLatest(scope = vmScope). Remove custom waitForState helper and shareLatest utility.
Reorder support screen items with category headers, add log session count to clear action, fix contact form hint strings, filter active recordings from log picker, convert RecorderActivity to Compose with proper system bar insets and Material 3 color tokens, refresh log metadata on resume, add wiki link to settings, and move support entry under Other category
Add structured contact form with category selection (Question/Feature/Bug), description with word count validation, expected behavior field for bugs, and debug log picker with inline recording.
Enhance RecorderActivity with full-screen hero layout, file list, Share/Keep/Discard actions. Migrate debug logs from flat files to session directories in external files dir. Add DebugLogZipper, EmailTool attachment support, log folder size display, and clear stored logs.
Gate theme mode, style, and color settings behind pro/upgrade status.
Add Material Design 3 surface container color tokens to all color palettes.
Move upgrade prompt string to main resources with generic wording.
Disable theme preference items for non-pro users instead of late rejection.
Replace the legacy AppWidgetProvider/RemoteViews implementation with Glance AppWidget for reactive updates, proper centering, and a unified render state model.
- Add BatteryGlanceWidget with collectAsState for live device/profile/upgrade data
- Add WidgetRenderState sealed class and WidgetRenderStateMapper
- Add GlanceWidgetContent (Glance composables) and ComposeWidgetPreview (config preview)
- Fix centering by passing spacing via modifier param (no trailing padding on last item)
- Remove old XML widget layouts and RemoteViews rendering code
Add distinct expanded (big) notification layouts with visual battery
progress bars for dual pods, single pods, and unknown devices.
Collapsed views remain unchanged. Status icon containers use fixed
width to ensure uniform progress bar lengths across rows.
The widget configuration activity used enableEdgeToEdge() but never synced the Android window background with the Compose Material theme. When the system dark/light mode disagreed with the app's theme setting, text became invisible (dark on dark). Also extend the bottom bar Surface under the navigation bar to eliminate the white gap.
- Add CasePopUp as screenshot 3 (popup shown on case open)
- Reorder: WidgetConfiguration moves to 4, app screens follow at 5-8
- Add Widget Configuration screen title heading
- Fix appearance card reset button to always wrap below title
- Balance padding on requires-Pro notice in widget config bottom bar
Monitor mode and scanner mode are functional settings, not appearance
settings. Give them their own "Monitoring" category header and place
them at the top of the general settings screen.
When two popup triggers fire in rapid succession (e.g. connection + case open),
the overlay is now updated via Compose MutableState rather than torn down and
recreated, eliminating the visual flicker.
Add card-based section grouping, animated transitions for custom mode,
animated color swatch selection, preset chip color indicators, and
Surface-based bottom bar and preview wrapper.
Use profile-based identity matching and cooldown keys so two BLE
signals for the same AirPods share one cooldown timer. Revert
connection monitor to show-once-per-connection behavior.
Navigation 3 doesn't auto-populate SavedStateHandle from NavKey args
like Navigation 2.x did. Pass profileId explicitly from the entry
lambda through the ScreenHost to the ViewModel via initialize().
Reset initialized flag on every exit path so re-entering a profile
reloads fresh data from the repository.
In create mode, the init block pre-filled _currentState with a default
name but left _initialState empty. The hasUnsavedChanges() check saw
the non-blank name as a change, triggering the dialog on back press
even without user edits.
Set _initialState to match _currentState defaults in create mode and
unify the comparison logic to always use current != initial.
Navigation 3 doesn't auto-populate SavedStateHandle from NavKey args
like Navigation 2.x did. Pass profileId explicitly from the entry
lambda through the ScreenHost to the ViewModel via initialize().
Reset initialized flag on every exit path so re-entering a profile
reloads fresh data from the repository.
In create mode, the init block pre-filled _currentState with a default
name but left _initialState empty. The hasUnsavedChanges() check saw
the non-blank name as a change, triggering the dialog on back press
even without user edits.
Set _initialState to match _currentState defaults in create mode and
unify the comparison logic to always use current != initial.
Delete dead XML layouts, unused drawable icons, the orphaned
RecyclerView adapter infrastructure, and Fragment-specific
extension functions left behind by the Compose migration.
Add user-facing theme preferences with three independent axes:
- Theme mode (System/Dark/Light)
- Theme style (Default/Material You/Medium Contrast/High Contrast)
- Theme color (Blue/Green/Amber)
Includes safe Moshi enum fallback for corrupted preference values,
color palettes for all combinations, and window background sync
to prevent flash during navigation transitions.
Add @Preview2 multi-preview annotation (light/dark), mock data provider
with realistic device scenarios, and ~45 preview functions across cards,
screens, popup, and settings components.
Check onboarding state in MainActivity before creating the NavBackStack,
so the correct screen is shown from the first frame. Removes the
redundant async check from OverviewViewModel that caused the dashboard
to briefly flash before redirecting to onboarding on fresh installs.
Switch all Compose screens from Icons.Default/Filled and painterResource
drawable references to Icons.TwoTone for a consistent two-tone icon style.
Delete 24 drawable XML files that are no longer referenced.
Replace shared NotificationCompat.Builder + Mutex with a stateless
design where each method builds a fresh notification. Introduces a
baseBuilder() helper to deduplicate common setup. Removes builderLock
and suspend modifiers from getNotification/getNotificationConnected.
Add promoteToForeground() helper that catches ForegroundServiceStartNotAllowedException
(Android 12+) and SecurityException, allowing graceful service exit instead of crashing.
Always show signal quality when available instead of gating on debug
mode. Position icon + percentage text in the top-right corner of the
card, aligned with the title. Remove unused DebugSettings dependency.
Replace vector silhouette placeholders with actual product photo PNGs
for 14 device families across 5 DPI buckets. Fix drawable naming prefix
(devic_ -> device_), add per-model icon overrides for popup left/right/case
views, disable image tinting for color PNGs, fix inverted signal visibility
logic, and use monochrome icon for notification small icon.
Add missing test cases for single-pod one-pod-mode pause direction,
EarDetectionState single-pod properties, and normal-mode steady-state.
Fix stale test comments that incorrectly claimed tests would fail.
Clarify onePodMode flow combine pattern and isWorn field semantics.
Fix edge case where removing a pod while both were in ears didn't trigger pause in one-pod mode. The aggregate boolean (isEitherPodInEar) stayed true, masking the individual pod removal.
See thread by `ZV`:
https://discord.com/channels/548521543039189022/1437499888068726814
Add color presets, custom color picker, transparency slider, and device
label toggle to the widget configuration screen. Widget preview updates
in real-time. Use profile label as device name in both preview and
actual widget.
Updated fastlane (2.232.1), aws-sdk, and google-cloud gems. Added several standard library shims (csv, logger, mutex_m, etc.) to support newer Ruby environments.
Move startForeground() before super.onCreate() in MonitorService to
avoid ForegroundServiceDidNotStartInTimeException when Hilt DI is slow
on backgrounded cold starts. An early minimal notification is shown
immediately, then replaced with the full one after DI completes.
The copyTo-based APK renaming (from AGP 9 upgrade) leaves both the
original and renamed APK in the output directory. Narrow the glob to
only match renamed APKs.
- Fix BleScanResultReceiver package name in manifest (.bluetooth → .common.bluetooth)
- Fix HandlerThread leak in BluetoothManager2 when registerReceiver() throws
- Cache battery values in MonitorNotifications to match hardening pattern
- Remove duplicate fragment-ktx dependency and align fragment-testing version
- Remove deprecated lifecycle-extensions dependency
Migrate monolithic root CLAUDE.md into .claude/rules/ structure
with focused topic files and glob-based contextual loading.
Clean up leftover helper scripts in .claude/tmp/.
EdgeToEdgeHelper now considers both systemBars() and displayCutout()
insets when applying padding. This prevents camera cutouts from
obscuring UI content when the device is in landscape orientation.
Use the widget ID as the request code for `PendingIntent`. This ensures that each widget instance has a distinct `PendingIntent`, preventing them from overwriting each other and allowing clicks on multiple widgets to work correctly.
Uses the application context instead of the activity context when updating the widget. This avoids leaking the activity instance if the update operation outlives the configuration screen.
Enhances robustness by adding extensive error handling around broadcast receiver registration, profile event processing, and service disconnections to prevent crashes. Also ensures device flow is cleared when Bluetooth is disabled.
Convert `connectedDevices()` from a function returning a cold Flow to a property that is a hot `StateFlow`. This simplifies call sites and improves efficiency by sharing the underlying subscription.
Switched from monitoring generic ACL events to specific Headset profile state changes for more reliable device connection and disconnection detection.
This fixes a race condition where CAPod thinks no device is connected because we triggered too early, before "connectedDevices" on the HEADSET profile contains our target device.
This fixes#313
This commit updates translations for German, Spanish, and Catalan. It also adds a comprehensive set of new strings for the Chinese (Simplified) localization.
Explains that single-pod detection is an Apple limitation, not an app bug.
Users experiencing this issue will now understand it only affects the
"primary pod" (microphone pod) and can be configured in iOS settings.
Closes#38, Closes#329
- Migrate ProjectConfig from static object to proper Gradle plugin
- Add version type support (beta/rc) to version.properties
- Clean up legacy fastlane changelogs
- Update release script to support version types
- Remove automatic fastlane changelog generation from release script
- Add BuildConfig field injection for version info
The navigation graph had a duplicate entry for DeviceManagerFragment.
This commit removes the duplicate and ensures the correct navigation action to DeviceProfileCreationFragment is present.
The lid state for AirPods Gen 4 was incorrectly reported as UNKNOWN when it should have been NOT_IN_CASE. This commit fixes the issue in both the ANC and non-ANC tests.
This commit replaces the direct usage of GeneralSettings for fetching identity and encryption keys with the DeviceProfilesRepo. BaseAirPodsTest is updated to reflect this change, mocking DeviceProfilesRepo instead of GeneralSettings.
This commit introduces a new card to the Overview screen that informs the user that the app is actively monitoring for devices when there are profiles configured but no devices are currently connected.
- Replace multiple StateFlows with single ProfileEditorState data class
- Store ByteArray keys as hex strings to enable proper equality comparison
- Fix race conditions in state initialization by setting initial and current state atomically
- Simplify change detection logic to simple data class comparison
The issue was caused by:
1. ByteArray types not implementing proper equals() (compared by reference)
2. Race conditions between multiple StateFlow updates
3. Complex change detection logic with timing issues
Now uses atomic state management with proper value-based equality.
- Shows hint when 2+ profiles exist explaining order determines priority
- Appears as flat list item at bottom, less intrusive than card
- Prevents dragging hint item, keeps it at bottom
- Uses book icon for informational guidance
The signal quality slider in `device_profile_creation_fragment.xml` now ranges from 0 to 100 with a step size of 1, previously 10 to 100 with a step size of 5.
This commit introduces a new device profile system, moving away from a single "main device" configuration in `GeneralSettings`.
Key changes:
- `DeviceProfilesRepo` now manages a list of `DeviceProfile` objects, allowing for multiple device configurations.
- On first launch with this update, existing "main device" settings from `GeneralSettings` (address, model, keys, signal quality) are migrated into a new default `AppleDeviceProfile`.
- The `MonitorWorker`, `PopUpReaction`, `AutoConnect`, and `PodHistoryRepo` have been updated to use the new `DeviceProfilesRepo` instead of the old `GeneralSettings` for device-specific information.
- `AppleFactory` now uses `DeviceProfilesRepo` to find matching profiles based on IRK.
- `TroubleShooterFragmentVM` now interacts with `DeviceProfilesRepo` for profile management during troubleshooting.
- The `create` method in `ApplePodsFactory` and its implementations are now `suspend` functions to allow for asynchronous operations like fetching profiles from the repository.
- Old "main device" preference keys in `GeneralSettings` have been renamed with an "old" prefix and will be removed in a future update.
- A new `currentProfiles()` extension function provides a convenient way to get the current list of profiles.
This commit modifies the `AppleFactory` to correctly handle `PodDevice.Model.UNKNOWN`.
Previously, if a device's model was `UNKNOWN`, it wouldn't match any profile and `isIRKMatch` would be false. This change ensures that `isIRKMatch` reflects the actual IRK match status, regardless of whether a specific profile is found.
Devices with an `UNKNOWN` model can now match profiles that are also marked as `UNKNOWN`, allowing for generic profile matching when the specific model isn't identified.
This commit enables the automatic generation of per-app language preferences by configuring the Android Gradle Plugin.
A `resources.properties` file is added to specify `en` as the `unqualifiedResLocale`, which serves as the default/fallback locale.
Additionally, `.kotlin` is added to `.gitignore` to exclude Kotlin build-related files.
Removed the deviceModel property from ApplePodsFactory interface and all 25 implementing classes across AirPods, Beats, and misc device factories. This simplifies the factory pattern as the model information can be derived from the concrete implementation type itself.
This commit adds a TODO comment to `GeneralSettings.kt` to migrate settings related to signal quality and main device details to the new device profiles system.
- Fix navigation bar overlap issues in profile creation and list screens
- Add proper edge-to-edge handling with dynamic padding for navigation bars
- Improve paired device selection with "None" option and clear functionality
- Add localized error messages for key validation with proper placeholders
- Centralize default signal quality value in DeviceProfile companion object
- Add unsaved changes detection and confirmation dialogs
- Enhance drag handle touch target size with FrameLayout wrapper
- Restore device address in profile editing and improve change tracking
- Localize all user-facing strings following project guidelines
This commit renames the `devices` package and its contents to `profiles` for better clarity and consistency. This includes renaming classes, files, and updating import statements and navigation graph references.
This commit introduces a new UI for creating and editing device profiles. Users can now define profile names, select device models, set minimum signal quality, and optionally add identity and encryption keys.
The device manager screen now supports drag-and-drop reordering of profiles. The order of profiles in the list now determines their priority, with items at the top having higher priority. The internal data structure for device profiles has been updated, and the `PodMonitor` now sorts devices based on this new profile priority.
This commit updates the `OverviewFragment` to use the `androidx.core.net.toUri()` extension function when creating Uris for system settings intents. This replaces the direct usage of `Uri.parse()`.
- Update all Apple device classes to use AppleMeta with profile support
- Modify device factories to inject profile information during creation
- Update UI components to handle profile-aware device metadata
- Refactor reaction systems to work with profile-based devices
- Update monitor worker and cache to support profile relationships
- Ensure consistent meta structure across all device types
All devices now properly reference their associated profiles through
the meta.profile field, enabling profile-based functionality.
- Implement DeviceProfile interface with sealed interface pattern
- Add AppleDeviceProfile for Apple devices with IRK support
- Create DeviceProfilesRepo for profile persistence with Moshi
- Add NameBasedPolyJsonAdapterFactory for polymorphic JSON serialization
- Update PodDevice.Meta to include profile reference
- Extend ApplePods with AppleMeta containing profile information
- Add utility extensions for profile-based device filtering
This establishes the foundation for device profile management system.
- Add UnmatchedDevicesCard to show/hide devices without profiles
- Separate devices with profiles from unmatched devices in overview
- Add priority-based sorting (profile.priority with 0 = highest)
- Add compiler args for experimental unsigned types and annotation targets
- Add string resources for unmatched devices UI
- Refactor overview to handle both profiled and non-profiled devices
The UI now shows:
1. Devices with configured profiles first (sorted by priority)
2. Collapsible section for unmatched devices with toggle button
3. Session-persistent show/hide state for unmatched devices
- Merged all 76 locale strings.xml files from app-common to app module
- Removed app-common module dependency from app/build.gradle.kts
- Removed app-common from settings.gradle
- Fixed all BuildConfig and R class import statements
- Updated fully qualified R references from eu.darken.capod.common.R to R
- Deleted entire app-common directory and all associated files
- Added temporary fallback for missing GITSHA BuildConfig field
The project now has a simplified structure with all string resources
consolidated in the app module, eliminating the unnecessary app-common
module while maintaining full functionality.
Builds successfully for both FOSS and Google Play variants.
This commit removes the "Show all devices" toggle from General Settings. The app will now always display all nearby Bluetooth devices in the overview, simplifying the UI and device discovery.
The corresponding preference `core.showall.enabled` and its usage have been removed. The debug setting `showUnfiltered` no longer affects this behavior.
- Change "No primary device" card to "No device configured" with clearer messaging
- Replace troubleshoot action with "Manage devices" button that navigates to device manager
- Move troubleshooter from overview to Settings → Support section
- Add concise troubleshooter description for settings preference
- Update navigation to support troubleshooter access from settings
- Improve user experience by providing more intuitive device management flow
This commit introduces the AirPods Pro 3 model to the application, including its representation in the README, the PodDevice interface, and the AppleFactoryModule.
A new class for AirPods Pro 3 is created, along with corresponding unit tests to validate its functionality.
Based on the AirPods Pro 2 USBC.
This commit removes the `BUILDTIME` field from `BuildConfig` across all modules. This change improves build caching and reproducibility by eliminating a value that changed with every build.
The `BUILDTIME` was previously used in `VERSION_DESCRIPTION_LONG` but is no longer included.
This commit refactors the `buildSrc` module by:
- Removing the `setupLibraryDefaults()` extension function from `ProjectConfig.kt` as its functionality is largely covered by standard Gradle plugin configurations.
- Inlining version numbers for Moshi and various testing libraries directly into `Dependencies.kt`, removing them from `Versions.kt`. This simplifies version management for these specific libraries.
- Removing unused version declarations from `Versions.kt`.
Additionally, this commit updates the following dependencies:
- Kotlin from 2.1.21 to 2.2.10
- Android Gradle Plugin from 8.11.0 to 8.12.2
- KSP from 2.1.21-2.0.1 to 2.2.10-2.0.2
- Dagger from 2.56.2 to 2.57.1
- AndroidX Navigation from 2.8.9 to 2.9.3
The `targetSdk` was also removed from the `app-common` module's `defaultConfig` as it's typically inherited or set at the app level.
This commit introduces a new setting that allows users to keep the "connected" notification visible even after the monitored device disconnects.
When this setting is enabled and the "Extra notification" is also active, the `MonitorWorker` will no longer cancel the connected notification upon finishing. This provides users with the option to see the last known battery levels after disconnection.
A new preference `keepConnectedNotificationAfterDisconnect` is added to `GeneralSettings` and the UI.
This commit introduces localized error messages for Google Play billing issues and refactors the error handling in `UpgradeRepoGplay`.
- `BillingException` and `BillingResultException` now implement `HasLocalizedError` to provide user-friendly error messages.
- New string translations for billing error labels and descriptions have been added for various languages.
- `UpgradeRepoGplay` now includes an `error` field in its `Info` state to propagate billing errors.
- The `upgradeInfo` flow in `UpgradeRepoGplay` has been updated with a `retryWhen` operator to handle errors more gracefully. If an error occurs but the pro state was recent, it emits a grace period state. Otherwise, it emits the error.
- `OverviewFragmentVM` now observes `upgradeState` and posts any non-pro errors to `errorEvents` for display.
This commit updates the Jekyll configuration file (`_config.yml`) to:
- Include `README.md` and `CHANGELOG.md` in the site build.
- Exclude `CONTRIBUTING.md` and `CLAUDE.md` from the site build.
This commit introduces a `CLAUDE.md` file.
This file contains instructions and context for the Claude AI to assist with development in this repository. It includes:
- Common build, test, and code quality commands.
- An overview of the project's multi-module architecture, core patterns (MVVM, DI with Hilt, Coroutines, Repository), and key components like `PodMonitor` and the reaction system.
- Details on build flavors (FOSS, Google Play) and build types (debug, beta, release).
- A description of the data flow architecture.
- Information on the testing strategy and key dependencies.
- Development notes regarding Bluetooth LE implementation and multi-platform considerations for phone and Wear OS.
This commit incorporates various translation improvements across multiple languages (Ukrainian, Greek, French, Turkish, Spanish (Mexico), Arabic, Korean, Urdu (India), Indonesian, Romanian, Zulu) for UI strings and Play Store metadata.
Additionally, this commit introduces new translations for Norwegian Bokmål (nb) for the Play Store listing (title, short description, full description) and adds initial string resources for Romansh (rm).
This commit introduces an `EdgeToEdgeHelper` class to centralize and simplify the application of window insets for edge-to-edge display.
Key changes:
- Created `EdgeToEdgeHelper` to manage padding based on system bar insets.
- Migrated various Fragments (`OverviewFragment`, `OnboardingFragment`, `SettingsFragment`, `TroubleShooterFragment`) to use `EdgeToEdgeHelper` for consistent padding.
- Removed manual inset handling from `Activity2` and `Fragment2` as `EdgeToEdgeHelper` now manages this.
- Enabled edge-to-edge display in `MainActivity`.
- Minor updates to `BillingClientConnection` for product details fetching and `build.gradle.kts` for configuration.
This commit updates:
- Android Gradle Plugin to 8.11.0
- Compile/Target SDK to 36
- Google Play Billing Library to 8.0.0
The `BillingClient` is updated to use the new `PendingPurchasesParams` and `QueryPurchasesParams` APIs.
This commit modifies the handling of Identity Resolving Key (IRK) and Encryption Key (EncKey) to allow users to clear these values by providing an empty input.
Specifically:
- In `GeneralSettingsFragment.kt`, the `onKey` callbacks for both IRK and EncKey dialogs now use `takeIf { it.isNotEmpty() }` after converting the input hex string to a byte array. This ensures that an empty input results in `null` being set for the respective key.
- In `PodMonitor.kt`, when determining the main device, `mainDeviceIdentityKey.value` is now checked with `takeIf { it.isNotEmpty() }` to ensure an empty IRK is treated as no IRK being configured.
- In `AppleFactory.kt`, when attempting to decrypt the private payload, `mainDeviceEncryptionKey.value` is now checked with `takeIf { it.isNotEmpty() }` to ensure an empty EncKey prevents decryption attempts.
This commit modifies `PodMonitor.kt` to ensure that the `isIRKMatch` flag is only used to determine the main device if an Identity Resolving Key (IRK) is actually configured in the general settings.
If no IRK is set, the main device determination will fall back to other logic, preventing a device from being incorrectly selected as "main" solely based on an IRK match when no specific IRK is being looked for.
- Prevent heuristic device detection when IRK/ENC keys are configured
- Maintain last IRK-matched device state instead of fallback
- Auto-enable LOW_POWER scan mode when IRK device is found
- Fixes false positives in crowded places
This commit updates the app title in the Google Play Store metadata for Tamil (ta-IN), Romanian (ro), and Swedish (sv-SE) by removing or adjusting a word to meet length constraints.
This commit corrects a typographical error in the Lao translation of the app title.
Specifically, it changes "ຄູ່ຮ່ວມສຳລັບ" to "ຄູ່ຮ່ວມສຳລັບ" in `fastlane/metadata/android/lo-LA/title.txt`.
This commit updates various Ruby gems in `Gemfile.lock`, most notably upgrading `fastlane` from version 2.213.0 to 2.228.0.
It also introduces new Fastlane lanes for Wear OS builds:
- `beta_wearos`
- `production_wearos`
The `fastlane/README.md` has been updated to reflect these new lanes.
This commit refactors the `crowdin.yaml` configuration file to:
- Use YAML anchors (`&stringmapping`, `*stringmapping`) to define and reuse the `languages_mapping` for different source files. This reduces redundancy and improves maintainability.
- Update language codes for Sardinian ("sc") and Norwegian ("no") to "sc-rIT" and "nb" respectively in the `stringmapping`.
- Update language codes for Sardinian ("sc") and Albanian ("sq") to "sc-IT" and "sq-AL" respectively in the `playstoremapping`.
This commit introduces new translations and updates existing ones for the CAPod application in Filipino (fil), Albanian (sq-rAL), Sardinian (sc-rIT), and Urdu (ur-rIN).
Specifically, it:
- Adds comprehensive translations for various UI elements, settings, permissions, and status messages in Albanian (`app/src/main/res/values-sq-rAL/strings.xml` and `app-common/src/main/res/values-sq-rAL/strings.xml`).
- Adds translations for Google Play related strings in Sardinian (`app/src/gplay/res/values-sc-rIT/strings.xml`).
- Adds translations for FOSS-specific upgrade options in Sardinian (`app/src/foss/res/values-sc-rIT/strings.xml`).
- Adds comprehensive translations for various UI elements, settings, permissions, and status messages in Sardinian (`app/src/main/res/values-sc-rIT/strings.xml`).
- Updates and expands translations in Filipino (`app-common/src/main/res/values-fil/strings.xml`), including terms for app names, general UI elements, permissions, device states, and settings.
- Adds comprehensive translations for various UI elements, settings, permissions, and status messages in Urdu (`app-common/src/main/res/values-ur-rIN/strings.xml`).
This commit updates the `crowdin.yaml` file to include language mappings for Sardinian (sc-IT) and Albanian (sq-AL).
Specifically, it adds:
- `sc-IT: sc-rIT` and `sq-AL: sq-rAL` to the `android_code` language mappings for various files.
- `sc-IT: sc-IT` and `sq-AL: sq-AL` to the `locale` language mappings under `playstoremapping`.
This commit corrects the escaping of quotation marks in several translated strings. Specifically, it replaces `\\\"` with `\"` in:
- Galician (gl-rES) translations for various permission descriptions.
- Telugu (te-rIN) translations for settings descriptions and troubleshooter text.
- Romansh (rm) translations for settings descriptions and troubleshooter text.
This commit introduces new translations for the app in Zulu (zu), Finnish (fi), Galician (gl-rES), Kyrgyz (ky-rKG), Romansh (rm), Telugu (te-rIN), and Uzbek (uz).
This commit updates the Android Gradle Plugin (AGP) from version 8.10.0 to 8.10.1 and the Kotlin Symbol Processing (KSP) plugin from version 2.1.20-2.0.0 to 2.1.21-2.0.1.
These changes are applied in the root `build.gradle.kts` file and the `buildSrc/build.gradle.kts` file.
This commit introduces support for the PowerBeats Pro 2 device.
Had to change the PowerBeats Pro 1 device code due to conflicting matches. Previously was using the half code variant. Might have broken that, but don't have any feedback from actual users, someone will have to speak up...
Closes#298
This commit adds default changelog files for various languages in the Fastlane metadata directory. Each file contains a generic message about bug fixes, performance improvements, and potential new features, along with a link to the full changelog and a note about the developer being a single person.
This commit refactors the Liquid templating in `CHANGELOG.md` to:
- Indent Liquid logic for better readability.
- Improve spacing around headings (##, ###) and list items (-) for a cleaner rendered output.
This commit updates the Jekyll configuration to include `jekyll-github-metadata` and `jemoji` plugins. It also introduces a `CHANGELOG.md` file that automatically generates a formatted changelog page from GitHub releases. The default changelog text for new app versions in Fastlane has also been updated to be more engaging and directly link to the new changelog page.
This commit corrects the `isIRKMatch` flag assignment in `AppleFactory.kt`. Previously, it was hardcoded to `true`. Now, it correctly uses the `isIrkMatch` variable determined by the Identity Resolving Key (IRK) comparison.
This commit introduces translations for a new notification channel "Device connected" and updates some existing string descriptions in various languages.
Specifically, it:
- Adds translations for `settings_monitor_connected_notification_label`, `settings_monitor_connected_notification_description`, and `notification_channel_device_status_connected_label` in Spanish (es), French (fr), Estonian (et-rEE), Norwegian (no), Czech (cs), Polish (pl), Catalan (ca), Greek (el), and German (de).
- Updates the full app descriptions in `fastlane/metadata/android/` for Catalan (ca), German (de-DE), Spanish (es-ES), French (fr-FR), Estonian (et), Norwegian (no-NO), Czech (cs-CZ), Polish (pl-PL), and Russian (ru-RU) to replace "phone" with "device" in the context of connecting to AirPods.
- Adds missing Arabic (ar) translations for various strings related to debug logging, settings, troubleshooting, and onboarding.
- Fixes a typo in the German translation for `settings_general_description`.
- Corrects Hebrew (iw) translations for Bluetooth permission descriptions.
This commit enhances the test coverage for `AirPodsProTest` by:
- Verifying the hex representation of the public payload data.
- Confirming that the private payload is null when no IRK/EncKey is set.
- Verifying the hex representation of the private payload data when IRK/EncKey are set.
Additionally, a fix is introduced in `AppleFactory.kt` to correctly parse the public payload by taking only the first 9 bytes instead of 16. This ensures that the private payload is only processed if the incoming message length matches the expected `PAIRING_MESSAGE_LENGTH`.
This commit modifies `SingleApplePods`, `AirPodsMaxUsbc`, and `AirPodsMax` to prioritize battery information from the decrypted private payload when available.
Specifically:
- `SingleApplePods.batteryHeadsetPercent`: Now checks `payload.private?.asBatteryState(1)?.level` first. If the private payload is not available or doesn't contain battery information at position 1, it falls back to the existing logic using `pubPodsBattery`.
- `AirPodsMaxUsbc.isHeadsetBeingCharged` and `AirPodsMax.isHeadsetBeingCharged`: Now check `payload.private?.asBatteryState(1)?.isCharging` first. If the private payload is not available or doesn't contain charging status at position 1, they fall back to the existing logic using `pubFlags.isBitSet(0)`.
This change allows for more accurate battery level and charging status reporting when the private payload can be decrypted.
This commit refines the logic for selecting the main Pod device. Previously, the `RPAChecker` was used directly to verify the Identity Resolving Key (IRK).
The new approach leverages the `isIRKMatch` flag within the `ApplePods` class. This flag is set during the device parsing process, indicating whether the device's address matches the configured IRK.
This change:
- Simplifies the `PodMonitor` by removing the direct dependency on `RPAChecker`.
- Encapsulates the IRK matching logic within the `ApplePods` class, improving modularity.
- Ensures that the main device selection correctly prioritizes devices whose IRK has been matched.
This commit introduces a new `Flags` data class within `ApplePods` to store boolean flags related to the device, specifically `isIRKMatch`.
This flag is now passed during the creation of `ApplePods` instances in all `ApplePodsFactory` implementations and the central `AppleFactory`.
The UI has been updated to display a key icon in the overview cards (`DualPodsCardVH` and `SinglePodsCardVH`):
- The icon is only visible if `flags.isIRKMatch` is true.
- The icon changes to a filled key (`ic_key_24`) if `payload.private` is not null (indicating successful decryption), otherwise an outline key (`ic_key_outline_24`) is shown.
- Minor layout adjustments were made in `overview_pods_single_item.xml` and `overview_pods_dual_item.xml` to accommodate the new key icon.
This commit introduces a new `BatteryState` data class and an extension function `ProximityPayload.Private.asBatteryState` to simplify the parsing of battery level and charging status from the proximity payload.
The `asBatteryState` function takes the position of the battery data in the payload and returns a `BatteryState` object, or null if the data is invalid.
This change improves code readability and maintainability by encapsulating the battery state parsing logic.
The previous logic for determining if the case was charging relied on the `batteryCasePercent` being non-null. This commit refines the logic to check if the `batteryCasePercent` falls within the valid range of 0.0 to 1.0, providing a more accurate assessment of the case's charging status.
This moves the history tracking logic for discovered Apple Pods from individual `ApplePodsFactory` implementations to a new `PodHistoryRepo` class.
Benefits:
- Centralized logic for device history management.
- Simplifies individual `ApplePodsFactory` implementations.
- Improves code maintainability and testability.
Relocate RPAChecker to the `pods.core.apple.protocol` package as it's specific to Apple's protocol.
Introduce type aliases `IdentityResolvingKey` and `ProximityEncryptionKey` for `ByteArray` to improve code clarity and type safety when dealing with these keys. Update relevant classes to use these new type aliases.
This commit introduces a typealias `BluetoothAddress` for `String` to improve code clarity and type safety when dealing with Bluetooth MAC addresses.
The following changes were made:
- Created `BluetoothAddress.kt` defining the typealias.
- Updated various classes to use `BluetoothAddress` instead of `String` for Bluetooth addresses:
- `RPAChecker`
- `PopUpReaction`
- `DeviceSelectionDialogFactory`
- `MonitorWorker`
- `GeneralSettings`
- `BluetoothDevice2`
This commit introduces a new helper function `fromHex()` that converts a HEX string (with optional separators like spaces or hyphens) into a byte array. It also adds a corresponding `toHex()` function for converting a byte array to a HEX string.
These helpers are now used in various parts of the codebase, including:
- Fake BLE data generation
- AirPod key input dialog
- RPA checker tests
- BaseAirPodsTest
Additionally, a unit test for these conversion functions has been added.
This commit adds a test case to `AirPodsProTest` to verify the decryption of AirPods Pro data with and without encryption/IRK keys. It also introduces a new test class `RPACheckerTest` to validate the RPA checking mechanism.
Helper functions for converting hex strings to byte arrays and setting mock keys have been added to `BaseAirPodsTest` to simplify test setup.
The encryption key setting for the main device is now disabled if the identity key is not set. This prevents users from trying to set an encryption key for a device that doesn't have an identity key.
This change introduces the capability to decrypt the last 16 bytes of the proximity pairing message if an encryption key is available.
The decrypted payload provides more precise battery information (0-100% instead of 0-10%) and charging status for each pod and the case.
This information is now used by `DualApplePods` to enhance battery reporting accuracy.
The `RPAChecker` has also been updated with improved error logging.
Replace `values()` with `entries` for enum iteration, as recommended by Kotlin for improved performance and consistency.
Also:
- Suppress unused warning in `ByteArrayAdapter`
- Improve logging in `WebpageTool` by including the exception details.
This commit introduces settings for users to input their AirPods' Identity Resolving Key (IRK) and Encryption Key. These keys, obtainable via a MacBook, allow CAPod to reliably identify the AirPods and decrypt detailed status information.
Key changes:
- Added new preferences in General Settings for IRK and Encryption Key.
- Implemented a custom dialog for key input, including validation and a link to a guide.
- Added a Moshi adapter for serializing/deserializing `ByteArray` to/from Base64 for storing the keys.
- Included new string resources for labels, descriptions, and explanations related to the keys.
- Added new icons for the key preferences.
```
Caused by java.lang.IllegalStateException: WorkManager is not initialized properly. You have explicitly disabled WorkManagerInitializer in your manifest, have not manually called WorkManager#initialize at this point, and your Application does not implement Configuration.Provider.
at androidx.work.impl.WorkManagerImpl.getInstance (WorkManagerImpl.java)
at androidx.work.impl.foreground.SystemForegroundDispatcher.<init> (SystemForegroundDispatcher.java)
at androidx.work.impl.foreground.SystemForegroundService.initializeDispatcher (SystemForegroundService.java)
at androidx.work.impl.foreground.SystemForegroundService.onCreate (SystemForegroundService.java)
at android.app.ActivityThread.handleCreateService (ActivityThread.java:4726)
```
```java
12:49:29.433 AndroidRuntime E FATAL EXCEPTION: main
Process: eu.darken.capod, PID: 15164
java.lang.SecurityException: Starting FGS with type connectedDevice callerApp=ProcessRecord{cb03698 15164:eu.darken.capod/u0a352} targetSDK=34 requires permissions: all of the permissions allOf=true [android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE] any of the permissions allOf=false [android.permission.BLUETOOTH_ADVERTISE, android.permission.BLUETOOTH_CONNECT, android.permission.BLUETOOTH_SCAN, android.permission.CHANGE_NETWORK_STATE, android.permission.CHANGE_WIFI_STATE, android.permission.CHANGE_WIFI_MULTICAST_STATE, android.permission.NFC, android.permission.TRANSMIT_IR, android.permission.UWB_RANGING, USB Device, USB Accessory]
at android.os.Parcel.createExceptionOrNull(Parcel.java:3242)
at android.os.Parcel.createException(Parcel.java:3226)
at android.os.Parcel.readException(Parcel.java:3209)
at android.os.Parcel.readException(Parcel.java:3151)
at android.app.IActivityManager$Stub$Proxy.setServiceForeground(IActivityManager.java:7167)
at android.app.Service.startForeground(Service.java:863)
at androidx.work.impl.foreground.SystemForegroundService$Api31Impl.startForeground(SystemForegroundService.java:194)
at androidx.work.impl.foreground.SystemForegroundService$1.run(SystemForegroundService.java:130)
at android.os.Handler.handleCallback(Handler.java:959)
at android.os.Handler.dispatchMessage(Handler.java:100)
at android.os.Looper.loopOnce(Looper.java:232)
at android.os.Looper.loop(Looper.java:317)
at android.app.ActivityThread.main(ActivityThread.java:8699)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:580)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:886)
This commit modifies all major elements of the UI for phones to use Material3 elements, including the toolbar.
Signed-off-by: Ricky Cheung <rcheung844@gmail.com>
Bump workmanager `2.7.1` -> `2.8.1` to fix
```java
android.app.ForegroundServiceStartNotAllowedException: startForegroundService() not allowed due to mAllowStartForeground false: service eu.darken.capod/androidx.work.impl.foreground.SystemForegroundService
```
> Your app does not display the time of day clearly at the top of the app home screen and any ongoing activity screens. We recommend that you display the time of day at the top of all activities except dialog and confirmation screens. For more information, see Show the time.
* More IAP improvements
* More refactoring
* More tolerance towards GPlay API issues.
* Bump billing dependency
* Migrate away from deprecated methods.
* wip
* Setup bugsnag only in gplay builds.
* Bugsnag does not support uploading mapping files or build information from library modules. This should be done from the application module which produces your APK instead.
* Only apply Bugsnag plugin for the right flavors.
* Run unit tests in debug, otherwise we run issues requiring api keys in the env
* Improve compat options: Add alternative method for receiving BLE scan results via PendingIntents
* Add missing logtags
* Reduce log spam
* Make the linter happy
* Make pod features more granular
Don't assume all AirPods/Beats return a correct connection state.
* Refactoring
* Fix refactoring regression
* AirPods Gen1 don't support state detection
* Update airpods pro icons to SVG variant
* Fix AirPodsPro2 not using the new icons
* Revert "Update airpods pro icons to SVG variant"
This reverts commit d7ca13d439.
* Add padding to icons
* Preliminary support for AirPods Pro 2, based on #31, assuming identifier is `11 20`
* Fix AirPods Pro 2 identifier (it's 0x1420)
* Add second AirPods Pro 2 test case from reddit user.
* Update Readme with AirPods Pro 2
Android app that detects and monitors AirPods via Bluetooth LE. Displays battery levels, triggers popup notifications on case open, and provides home screen widgets.
## Project Structure
Single Gradle module `app/` with multiple source sets (`main`, `foss`, `gplay`, `debug`, `test`, `testFoss`, `testGplay`, `screenshotTest`). A previous `app-common/` module was merged into `app/`.
## Build Flavors
- **FOSS** (`foss`): Open-source, no Google Play dependencies
- **Google Play** (`gplay`): Includes billing client for IAP
Common prefixes currently in use: `device_`, `settings_`, `support_`, `profiles_`, `press_`, `general_`, `pods_`, `upgrade_`, `widget_`, `debug_`, `permission_`, `troubleshooter_`, `overview_`, `anc_`, `onboarding_`. There is no `error_*` prefix — error labels live under the relevant feature (e.g. `general_error_label`, `troubleshooter_*_failure_*`).
String context, character limits and file context are managed on Crowdin through the android-translation plugin's `crowdin-annotate` skill. XML comments in `values/strings.xml` no longer reach translators once a string's context has been written on Crowdin; change it there.
Localized screenshots are generated using Compose Preview Screenshot Testing (alpha), rendered offline (no device needed), and sorted into fastlane metadata directories for Play Store upload.
| `app/src/debug/java/.../screenshots/ScreenshotContent.kt` | Mock data composables (7 exist; `HomescreenWidgetContent` has an IDE preview only and is **not** in the Play Store pipeline) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations. The committed content is an en-US placeholder, not meaningful data — `generate_screenshots.sh` rewrites it per batch and restores it from a `.bak` on exit. A run killed hard leaves that `.bak` behind, so the script now refuses to start until it is restored by hand |
| `fastlane/generate_screenshots.sh` | Batched generation; locale list (`ALL_LOCALES`) and `BATCH_SIZE` are defined inside the script |
| `fastlane/copy_screenshots.sh` | Copies rendered PNGs into fastlane structure |
## Commit policy
Only `en-US` has `phoneScreenshots/*.png` checked into the repo — 7 PNGs, ~1 MB tracked. Every other locale is excluded by `.gitignore`.
`--smoke` still *renders* 6 locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR), but only en-US is committed. The other five cover LTR, RTL and CJK layout so a render that breaks on non-Latin script fails during generation, and the resulting PNGs sit in the working tree for manual inspection. Nothing compares them against a baseline, so this is render coverage plus eyeballing, not regression checking.
Play Store's `supply` only uploads what's present in `fastlane/metadata/android/<locale>/images/phoneScreenshots/`. For locales not in the upload, Play Store retains whatever was last pushed. So full localization on Play Store is maintained by an **occasional manual** full regen + `:screenshots_only` upload — not by every PR.
## Commands
```bash
# Default — smoke set (6 locales × 7 screens, ~42 PNGs, single batch).
# Use this for local iteration and PRs that touch screenshot content.
./fastlane/generate_screenshots.sh --smoke
# Full run — all 68 locales. Use only when intending to upload to Play Store
# (the non-smoke output is .gitignored and should not be committed).
./fastlane/generate_screenshots.sh
# Copy into fastlane directories (run after generate)
./fastlane/copy_screenshots.sh
# Clean copy (removes old screenshots first) — REQUIRED when screens are removed or renamed
./fastlane/copy_screenshots.sh --clean
```
## Adding a New Screenshot
1. Add a composable content function in `ScreenshotContent.kt` (e.g. `NewScreenContent()`)
2. Add a `@PreviewTest` function in `PlayStoreScreenshots.kt` that calls it
3. Add the function name → filename mapping in `copy_screenshots.sh``SCREEN_MAP`
4. Update the expected count in `generate_screenshots.sh` (composables per locale)
5. Run the smoke pipeline: `generate_screenshots.sh --smoke` then `copy_screenshots.sh --clean`
## Removing or Renaming a Screenshot
1. Remove the `@PreviewTest` entry and its `SCREEN_MAP` mapping
2. Run `generate_screenshots.sh --smoke`
3. Run `copy_screenshots.sh --clean` — **`--clean` is required** here; without it, old files (e.g. a renamed `8_reaction_settings.png`) stay in `fastlane/metadata/android/<smoke locale>/images/phoneScreenshots/` and get uploaded to Play Store
## After UI Changes
When modifying a screen that appears in screenshots (check `ScreenshotContent.kt`), regenerate the smoke set:
echo"Upload failed; the refreshed en-US screenshots remain staged for retry."
fi
```
The `git add` has to happen before the upload. The final `git checkout` restores every tracked file under that path **from the index**, so staging the refreshed English set is precisely what makes it survive the checkout — skip the `git add` and the checkout silently reverts the refresh while the store still receives the new images.
Restoring is the checkout's job otherwise: `screenshots_only` runs `remove_unsupported_languages.sh`, which deletes 9 tracked locale directories (es-AR, sc-IT, sq-AL, uz, kmr-TR, ur-IN, zu, si-LK, nb) from the working tree before uploading — 35 tracked files, a subset of the 309 tracked non-screenshot metadata files under that path, all of them put back by the checkout. It does **not** touch the regenerated non-English PNGs: those are untracked and ignored, so they stay on disk and never show up in `git status`. Because the checkout discards any uncommitted metadata text edits too, run this refresh only with an otherwise-clean metadata tree. The final commit is path-limited on purpose, so an unrelated staged change can't ride along, and it is gated on `screenshots_only` succeeding rather than merely sequenced after it: if the upload fails, the refreshed English files stay staged for a retry instead of being committed as though they were deployed. The deleted locale directories are restored on either path.
## Technical Notes
- Batch size defaults to 2 locales; renders per batch = `BATCH_SIZE × screen count` (currently 2 × 7 = 14). Small batches avoid layoutlib memory leaks (~10MB/image)
- Gradle daemon is stopped between batches to release memory
-`PlayStoreLocales.kt` is temporarily rewritten per batch and restored via trap
Releases are cut via the **Release prepare** workflow (`.github/workflows/release-prepare.yml`). It bumps `version.properties` and `VERSION`, commits to `main`, tags `v<version>`, pushes atomically, and dispatches `release-tag.yml` which builds, signs, and uploads.
## Required order
A real cut pushes a commit and a tag to `main` and is public the moment it lands. Do not skip ahead.
1. Run the dry run first and read its summary — never dispatch `dry_run=false` blind.
2. Report the planned version and `versionCode` back to the user.
3. Get explicit confirmation for that specific version before dispatching `dry_run=false`.
4. If the user named `bump_kind`/`version_type`/`version_override`, use exactly those. If the request
is ambiguous about which field moves, ask rather than assuming `build`.
## Dispatch
`gh workflow run` only fires the dispatch — it returns nothing about the result. The summary is
written asynchronously, so you have to go fetch it.
```bash
# Step 1 — plan only. No commit, no tag, no push. Always run this first.
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true
# Step 2 — find the run just dispatched and wait for it.
gh run list --workflow=release-prepare.yml --limit 1# note the run id
gh run watch <run-id> --exit-status
# Step 3 — read the computed plan (version + versionCode) before going further.
gh run view <run-id> --log | tail -40
```
Report the planned version and `versionCode`, get explicit confirmation, then:
```bash
# Step 4 — real cut. Repeat the dry run's inputs EXACTLY; change only dry_run.
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=false
```
The `bump_kind=build` above is only an example. If the confirmed plan came from a `patch`/`minor`/
`major` bump, a `version_type` switch, or a `version_override`, Step 4 must carry those same flags —
otherwise you cut a different version than the one that was approved.
After `dry_run=false`: Job 1 computes + writes the summary, then Job 2 immediately commits/tags/pushes (no env gate — cancel the run between Job 1 and Job 2 if the summary looks wrong; you have ~seconds). The tag push naturally triggers `release-tag.yml` (the App-token push fires `on: push:` workflows; only `GITHUB_TOKEN`-pushes are suppressed). `release-tag.yml` then runs `validate-tag` and the existing `release-github` (`foss-production` approval) + `release-gplay` (`gplay-production` approval) jobs — those are the two human checkpoints, matching the pre-migration UX.
| `version_type` | `keep-current` | Preserves current `rc`/`beta`. Set explicitly to switch. |
| `version_override` | empty | e.g. `5.1.2-rc0`. Bypasses bump_kind/version_type. |
| `expected_current` | empty | Optional: fail if `version.properties` ≠ this. Useful for tight coordination. |
| `dry_run` | `true` | Default is plan-only. |
Bump rules: `build` increments build; `patch`/`minor`/`major` zero everything to the right of the bumped field. All numeric fields bounded `0..99` (the `versionCode` formula collapses at ≥100).
| Play upload completed | Above + halt rollout in Play Console (or `bundle exec fastlane supply --track beta --rollout 0 --version-code <bad-code>`) |
| Job 2 ran but downstream rejected at env approval | Treat as first row — bump+tag are public on `main` regardless of downstream outcome |
`bump.sh` enforces strict `versionCode` monotonicity, so re-using a code is impossible without manually editing `version.properties`.
## Auth setup
`release-prepare.yml` Job 2 uses a GitHub App token (not `GITHUB_TOKEN`) to push the bump commit and tag. The App identity is in the rulesets' bypass list, which is what allows the push to bypass branch protection + tag-creation restrictions.
Required org secrets (set on the d4rken-org organization, accessible to `capod`):
-`RELEASE_APP_CLIENT_ID` — Client ID of the `d4rken-org-releaser` GitHub App (visible on the App's settings page, format `Iv1.<hex>` or similar)
-`RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines)
The App is installed on this repo and added as a bypass actor to:
- The main-branch ruleset (PR + status check requirements)
- The tag ruleset (creation restriction on `v*`)
Other apps in the org can reuse the same App + secrets — just install the App on each repo and add it to that repo's rulesets' bypass lists.
## Defense in depth
`release-tag.yml` includes `validate-tag` which: (1) regex-checks `github.ref_name`, (2) runs `bump.sh --mode=check`, (3) asserts the parsed name matches the tag. Manual `gh workflow run release-tag.yml --ref vfoo` or hand-pushed tags fail before any build.
## Stuck-dispatch recovery
If Job 2's atomic push lands but the natural `on: push:` trigger doesn't fire `release-tag.yml` (rare — would mean GitHub dropped the event), the tag is public but no pipeline runs. Re-dispatch manually: `gh workflow run release-tag.yml --ref v<new> -f dry_run=false`.
* I do not sell, monetize or otherwise misappropriate any collected data.
# Privacy policy
This is the privacy policy for the Android app "CAPod - Companion for AirPods" by Matthias Urhahn (darken).
Anonymous device information may be collected in the event of a crash (see [Automatic crash reports](#automatic-crash-reports)).
## Preamble
CAPod respects your privacy.
I do not collect, share or sell personal information.
Send a [quick mail](mailto:support@darken.eu) if you have questions.
My underlying privacy principle is the [Golden Rule](https://en.wikipedia.org/wiki/Golden_Rule).
Send a [quick mail](mailto:support@darken.eu) if you have further questions.
## Location data
## Automatic crashreports
CAPod does not collect, shareor sell location data.
The app uses "Bugsnag" for automatic crash reports:
Location permissions are required to receive Bluetooth Low Energy (BLE) data and ebale its core functionality.
The permission "access fine location" (`ACCESS_FINE_LOCATION`) and "access coarse location" (`ACCESS_COARSE_LOCATION`) are required to receive Bluetooth Low Energy data on Android 11 and lower.
On Android 12+ the newer and more fine grained `BLUETOOTH_SCAN` permission is used instead.
Bluetooth Low Energy is a technology that devices like AirPods use to communicate their status to nearby devices.
CAPod requests location permissions because these permissions are required to work with Bluetooth Low Energy data.
This is a privacy measure on Android's side because you could determine someones location by scanning for Bluetooth
devices:
If you know the physical location of a Bluetooth device (e.g. AirTags) you could use Bluetooth data to calculate your
position.
### Location access in the background
CAPod uses the "location access in the background" permission (`ACCESS_BACKGROUND_LOCATION`) on Android 11 and older to
receive Bluetooth Low Energy data while the app is in the background. This permission enables the "Show popup" and "
Autoconnect" features and allows CAPod to react to nearby devices whil the app is closed.
## Automatic error reports
*This was removed in v2.11.0+*
If an error occurs, an automated report may be sent to help me fix the issue.
This is optional and you can opt out of this in the settings.
Error reports are collected using "Bugsnag":
https://www.bugsnag.com/
Bugsnags privacy policy can be found here:
Bugsnag's privacy policy can be found here:
https://docs.bugsnag.com/legal/privacy-policy/
Crash reports may contain device and app related information.
Error reports contain device and app information related to the error that occured.
Additional information about the error context may also be included, e.g. what this app did shortly before the error.
You can disable automatic crash reports in the app's settings.
Additional details about the type of data that is collected by the error tracking SDK can be found here:
Error reports are pseudonymous. Unless you tell me your install-ID, I don't know that an error report came from you.
Error reports are automatically deleted after 90 days.
## Debug logs
The app has a debug log feature that can be used to assist troubleshooting efforts. This feature creates a log file that contains verbose output of what the app is doing.
It is manually triggered by the user through an option in the app settings. The recorded log file can be shared through compatible apps (e.g. your email app) using the system's share dialog. As this log file may contain sensitive information (e.g. details about files or installed applications) it should only be shared with trusted parties.
[](https://github.com/d4rken-org/capod/edit/main/README.md#download)
A companion app that adds support for AirPod specific features to Android:
@@ -12,43 +16,72 @@ A companion app that adds support for AirPod specific features to Android:
* Additional infos about connection, microphone & case.
* Can receive and show all nearby devices.
* Ear detection with automatic play/pause.
* Automatically connect phone & airpods.
* Automatically connect phone & AirPods.
* Show popup when case is opened.
* Widgets
CAPod is ad-free. Some additional features require an in-app purchase.
<stringname="upgrade_foss_preamble">CAPod FOSS is gratis en oopbron. As u dit nuttig vind, oorweeg dit om ontwikkeling te befonds om die projek aan die gang te hou.</string>
<stringname="upgrade_screen_how_title">Hoe om te help</string>
<stringname="upgrade_screen_how_body">Word \'n beskermheer en borg ontwikkeling! Tik die knoppie hieronder om alle ekstra funksies te aktiveer en my GitHub Sponsors profiel oop te maak.</string>
<stringname="upgrade_screen_status_free_body">Jy gebruik die gratis weergawe van CAPod. Ekstra funksies kan ontsluit word deur die ontwikkeling te ondersteun.</string>
<stringname="upgrade_screen_recurring_title">Hou dit voort</string>
<stringname="upgrade_screen_recurring_body">CAPod bly verbeter deur opdaterings en regstellings. As jy dit wil volhou, oorweeg \'n herhalende skenking via GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">برنامج كابود FOSS مجّاني ومفتوح المصدر. إذا وجدته مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
<stringname="upgrade_screen_how_body">كُن راعيًا وادعم تطوير المشروع! اضغط على الزرّ أدناه لتفعيل جميع المزايا الإضافية وفتح ملفّي الشخصي على GitHub Sponsors.</string>
<stringname="upgrade_screen_recurring_body">يستمرّ كابود في التطوّر عبر التحديثات والإصلاحات. إذا كنت ترغب في دعم هذا التطوّر، ففكّر في التبرّع بشكلٍ دوريٍّ عبر GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS pulsuzdur və açıq mənbəlidir. Faydalı tapırsınızsa, layihənin davamına kömək etmək üçün inkişafı maliyyələşdirməyi düşünün.</string>
<stringname="upgrade_screen_how_title">Necə kömək etmək</string>
<stringname="upgrade_screen_how_body">Himayədar olun və inkişafı sponsor edin! Bütün əlavə xüsusiyyətləri aktivləşdirmək və mənim GitHub Sponsors profilimi açmaq üçün aşağıdakı düyməni toxunun.</string>
<stringname="upgrade_screen_status_free_body">Siz CAPod-un pulsuz versiyasından istifadə edirsiniz. Əlavə xüsusiyyətlər inkişafı dəstəkləyərək açılır.</string>
<stringname="upgrade_screen_recurring_body">CAPod yeniləmələr və düzəltmələr vasitəsilə təkamül etməkdə davam edir. Bunu dəstəkləmək istəsəniz, GitHub Sponsors vasitəsilə təkrari donasiya nəzərdən keçirin.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS бясплатны і з адкрытым зыходным кодам. Калі вы лічыце яго карысным, разгледзьце магчымасць спансіравання распрацоўкі, каб дапамагчы трымаць праект ў жывых.</string>
<stringname="upgrade_screen_how_body">Станьце спонсарам распрацоўкі! Націсніце кнопку ніжэй, каб актывіраваць усе дадатковыя функцыі і адкрыць мой профіль на GitHub Sponsors.</string>
<stringname="upgrade_screen_recurring_body">CAPod пастаянна развіваецца дзякуючы абнаўленням і выпраўленням. Калі вы хочаце гэта падтрымаць, разгледзьце магчымасць рэгулярнага ахвяравання праз GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS е безплатен и с отворен код. Ако ви е полезен, размислете да подкрепите разработката, за да помогнете проектът да продължи.</string>
<stringname="settings_upgrade_status_description">Вашият статус на поддръжник.</string>
<stringname="upgrade_screen_why_title">Преимущества на надстройката</string>
<stringname="upgrade_screen_how_title">Как да помогнете</string>
<stringname="upgrade_screen_how_body">Станете покровител и спонсорирайте разработката! Докоснете бутона по-долу, за да активирате всички допълнителни функции и да отворите моя GitHub Sponsors профил.</string>
<stringname="upgrade_screen_status_free_body">Използвате безплатната версия на CAPod. Допълнителни функции могат да бъдат отключени чрез подкрепа на разработката.</string>
<stringname="upgrade_screen_status_free_action">Вижте опциите за надстройка</string>
<stringname="upgrade_screen_status_upgraded_title">Надстройката е активна</string>
<stringname="upgrade_screen_recurring_body">CAPod продължава да се развива чрез актуализации и корекции. Ако искате да подкрепите това, разгледайте възможност за редовно дарение чрез GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS বিনামূল্যে এবং মুক্ত উৎস। আপনি যদি এটি উপযোগী মনে করেন, প্রকল্পটি চালু রাখতে সাহায্য করতে ডেভেলপমেন্ট স্পনসর করার কথা বিবেচনা করুন।</string>
<stringname="upgrade_screen_how_title">কিভাবে সাহায্য করতে পারি</string>
<stringname="upgrade_screen_how_body">একটি পৃষ্ঠপোষক এবং স্পনসর উন্নয়ন হয়ে! সমস্ত অতিরিক্ত বৈশিষ্ট্য সক্রিয় করতে নীচের বোতামটি আলতো চাপুন এবং আমার GitHub স্পনসর প্রোফাইল খুলুন।</string>
<stringname="upgrade_screen_status_free_body">আপনি CAPod এর বিনামূল্যে সংস্করণ ব্যবহার করছেন। উন্নয়ন সমর্থন করে অতিরিক্ত বৈশিষ্ট্য আনলক করা যায়।</string>
<stringname="upgrade_screen_recurring_title">এটি চালু রাখুন</string>
<stringname="upgrade_screen_recurring_body">CAPod আপডেট এবং সংশোধনের মাধ্যমে ক্রমাগত উন্নত হচ্ছে। আপনি যদি তা বজায় রাখতে চান তবে GitHub Sponsors এর মাধ্যমে নিয়মিত দান বিবেচনা করুন।</string>
<stringname="upgrade_foss_preamble">El CAPod FOSS és gratuït i de codi obert. Si el trobeu útil, considereu patrocinar el desenvolupament per ajudar a mantenir el projecte en marxa.</string>
<stringname="upgrade_foss_sponsor_action">Patrocina el desenvolupament</string>
<stringname="upgrade_foss_sponsor_subtitle">Sense anuncis. Sense seguiment. Sense dependència del Google Play.</string>
<stringname="upgrade_foss_sponsor_returned_early">Ja heu tornat? El vostre suport manté viu el CAPod.</string>
<stringname="upgrade_foss_supporter_since">Col·laborador des de %s</string>
<stringname="upgrade_foss_supporter_thanks">Gràcies per donar suport al desenvolupament del CAPod!</string>
<stringname="upgrade_foss_sponsor_again_action">Obre la pàgina del patrocinador</string>
<stringname="upgrade_foss_sponsor_label">Patrocineu el CAPod</string>
<stringname="settings_upgrade_status_description">El vostre estat de col·laborador.</string>
<stringname="upgrade_screen_why_title">Beneficis de la millora</string>
<stringname="upgrade_screen_how_body">Convertiu-vos en mecenes i patrocinadors del desenvolupament! Toqueu el botó següent per activar totes les funcions addicionals i obrir el meu perfil de patrocinadors del GitHub.</string>
<stringname="upgrade_screen_status_free_body">Esteu utilitzant la versió gratuïta de CAPod. Podeu desbloquejar funcions addicionals donant suport al desenvolupament.</string>
<stringname="upgrade_screen_status_free_action">Veure opcions de millora</string>
<stringname="upgrade_screen_recurring_title">Manteniu-ho en marxa</string>
<stringname="upgrade_screen_recurring_body">CAPod continua evolucionant a través d\'actualitzacions i correccions. Si voleu sostenir-ho, considereu una donació recurrent a través de GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">Aplikace CAPod FOSS je zdarma a open source. Pokud vám přijde užitečná, zvažte sponzorování vývoje, abyste pomohli udržet projekt v chodu.</string>
<stringname="upgrade_screen_how_body">Staňte se patronem a sponzorem vývoje! Klepnutím na tlačítko níže aktivujete všechny další funkce a otevřete můj profil sponzora na GitHubu.</string>
<stringname="upgrade_screen_status_free_body">Používáte bezplatnou verzi CAPod. Další funkce lze odemknout podporou vývoje.</string>
<stringname="upgrade_screen_status_free_action">Zobrazit možnosti upgradu</string>
<stringname="upgrade_screen_status_upgraded_title">Upgrade je aktivní</string>
<stringname="upgrade_screen_recurring_title">Pokračuj v tom</string>
<stringname="upgrade_screen_recurring_body">CAPod se neustále vyvíjí prostřednictvím aktualizací a oprav. Pokud to chcete podporovat, zvažte pravidelný příspěvek prostřednictvím GitHub sponzorů.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS er gratis og open source. Hvis du finder det nüttigt, kan du overveje at sponsorere udviklingen for at hjælpe projektet med at fortsætte.</string>
<stringname="upgrade_screen_how_body">Bliv patron og sponsorér udvikling! Tryk på knappen nedenfor for at aktivere alle ekstra funktioner og åbne min GitHub-sponsorprofil.</string>
<stringname="upgrade_screen_status_free_body">Du bruger den gratis version af CAPod. Ekstra funktioner kan låses op ved at støtte udviklingen.</string>
<stringname="upgrade_screen_recurring_title">Hold det kørende</string>
<stringname="upgrade_screen_recurring_body">CAPod udvikles løbende gennem opdateringer og fejlrettelser. Hvis du gerne vil understøtte det, kan du overveje en tilbagevendende donation via GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS ist kostenlos und Open Source. Wenn du es nützlich findest, erwäge, die Entwicklung zu sponsern, um das Projekt am Laufen zu halten.</string>
<stringname="upgrade_screen_why_title">Vorteile des Upgrades</string>
<stringname="upgrade_screen_how_title">Wie man hilft</string>
<stringname="upgrade_screen_how_body">Werde Unterstützer und fördere die Entwicklung! Tippe auf den Button unten, um alle Zusatzfunktionen zu aktivieren und mein GitHub-Sponsors-Profil zu öffnen.</string>
<stringname="upgrade_screen_status_free_body">Du verwendest die kostenlose Version von CAPod. Zusätzliche Funktionen können durch die Unterstützung der Entwicklung freigeschaltet werden.</string>
<stringname="upgrade_screen_recurring_body">CAPod entwickelt sich durch Updates und Fehlerbehebungen kontinuierlich weiter. Wenn du das unterstützen möchtest, ziehe eine wiederkehrende Spende über GitHub Sponsors in Betracht.</string>
<stringname="upgrade_foss_preamble">Το CAPod FOSS είναι δωρεάν και ανοιχτού κώδικα. Αν το βρίσκετε χρήσιμο, εξετάστε το ενδεχόμενο να χρηματοδοτήσετε την ανάπτυξη για να συνεχίσει το έργο.</string>
<stringname="upgrade_foss_sponsor_action">Χορηγήστε την ανάπτυξη</string>
<stringname="upgrade_foss_sponsor_subtitle">Χωρίς διαφημίσεις. Χωρίς παρακολούθηση. Χωρίς δέσμευση στο Google Play.</string>
<stringname="upgrade_foss_sponsor_returned_early">Επιστρέψατε ήδη; Η υποστήριξή σας κρατά το CAPod ζωντανό.</string>
<stringname="upgrade_foss_supporter_since">Υποστηρικτής από %s</string>
<stringname="upgrade_foss_supporter_thanks">Σας ευχαριστούμε που υποστηρίζετε την ανάπτυξη του CAPod!</string>
<stringname="upgrade_screen_how_title">Πώς να βοηθήσετε</string>
<stringname="upgrade_screen_how_body">Γίνετε patron και χορηγήστε την ανάπτυξη! Πατήστε το κουμπί παρακάτω για να ενεργοποιήσετε όλα τα επιπλέον χαρακτηριστικά και ανοίξτε το προφίλ μου GitHub Sponsors.</string>
<stringname="upgrade_screen_status_free_body">Χρησιμοποιείτε τη δωρεάν έκδοση του CAPod. Επιπλέον δυνατότητες μπορούν να ξεκλειδωθούν υποστηρίζοντας την ανάπτυξη.</string>
<stringname="upgrade_screen_status_free_action">Δείτε τις επιλογές αναβάθμισης</string>
<stringname="upgrade_screen_recurring_title">Κρατήστε το ζωντανό</string>
<stringname="upgrade_screen_recurring_body">Το CAPod συνεχίζει να εξελίσσεται μέσω ενημερώσεων και διορθώσεων. Αν θέλετε να το στηρίξετε, σκεφτείτε μια επαναλαμβανόμενη δωρεά μέσω του GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS es gratuito y de código abierto. Si lo encontrás útil, considerá patrocinar el desarrollo para ayudar a mantener el proyecto en marcha.</string>
<stringname="upgrade_foss_sponsor_action">Sponsorear el desarrollo</string>
<stringname="upgrade_foss_sponsor_subtitle">Sin publicidad. Sin rastreo. Sin dependencia de Google Play.</string>
<stringname="upgrade_foss_sponsor_returned_early">¿Ya te vas? Tu apoyo mantiene CAPod vivo.</string>
<stringname="upgrade_foss_supporter_since">Colaborador desde %s</string>
<stringname="upgrade_foss_supporter_thanks">¡Gracias por apoyar el desarrollo de CAPod!</string>
<stringname="upgrade_foss_sponsor_again_action">Abrir página de patrocinio</string>
<stringname="upgrade_screen_how_body">¡Conviértete en patrocinador y patrocina el desarrollo! Toque el botón de abajo para activar todas las funciones adicionales y abrir mi perfil de Patrocinadores de GitHub.</string>
<stringname="upgrade_screen_status_free_body">Estás usando la versión gratuita de CAPod. Se pueden desbloquear características adicionales apoyando el desarrollo.</string>
<stringname="upgrade_screen_status_free_action">Ver opciones de mejora</string>
<stringname="upgrade_screen_recurring_title">Mantené el impulso</string>
<stringname="upgrade_screen_recurring_body">CAPod sigue evolucionando a través de actualizaciones y correcciones. Si querés sostener eso, considera una donación recurrente a través de GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS es gratuito y de código abierto. Si lo encuentras útil, considera patrocinar el desarrollo para ayudar a mantener el proyecto.</string>
<stringname="upgrade_foss_sponsor_action">Patrocinar el desarrollo</string>
<stringname="upgrade_foss_sponsor_subtitle">Sin anuncios. Sin rastreo. Sin dependencia de Google Play.</string>
<stringname="upgrade_foss_sponsor_returned_early">¿Ya de vuelta? Tu apoyo mantiene CAPod con vida.</string>
<stringname="upgrade_foss_supporter_since">Colaborador desde %s</string>
<stringname="upgrade_foss_supporter_thanks">¡Gracias por apoyar el desarrollo de CAPod!</string>
<stringname="upgrade_foss_sponsor_again_action">Abrir página de patrocinio</string>
<stringname="upgrade_screen_how_body">¡Conviértete en un patrocinador y apoya el desarrollo constante de la aplicación! Presiona el botón a continuación para activar todas las funciones adicionales además de abrir mi perfil de Patrocinadores en GitHub.</string>
<stringname="upgrade_screen_status_free_body">Estás usando la versión gratuita de CAPod. Puedes desbloquear características extra apoyando el desarrollo.</string>
<stringname="upgrade_screen_status_free_action">Ver opciones de actualización</string>
<stringname="upgrade_screen_recurring_body">CAPod sigue evolucionando con actualizaciones y correcciones. Si quieres sostener eso, considera una donación recurrente a través de GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS es gratuito y de código abierto. Si lo encuentras útil, considera patrocinar el desarrollo para ayudar a mantener el proyecto en marcha.</string>
<stringname="upgrade_screen_how_body">¡Conviértete en patrocinador y patrocina el desarrollo! Toque el botón de abajo para activar todas las funciones adicionales y abrir mi perfil de Patrocinadores de GitHub.</string>
<stringname="upgrade_screen_status_free_body">Estás usando la versión gratuita de CAPod. Puedes desbloquear funciones adicionales apoyando el desarrollo.</string>
<stringname="upgrade_screen_status_free_action">Ver opciones de actualización</string>
<stringname="upgrade_screen_recurring_body">CAPod sigue evolucionando gracias a actualizaciones y correcciones. Si quieres mantener eso en marcha, considera una donación recurrente a través de GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS on tasuta ja avatud lähtekoodiga. Kui sellest on sulle kasu, kaalu arenduse rahastamist, et aidata projekti arendada.</string>
<stringname="upgrade_screen_how_body">Hakake toetajaks ja rahastage arendamist. Kõikide lisavõimaluste kasutamiseks ja minu GitHub Sponsors profiili avamiseks puudutage alumist nuppu.</string>
<stringname="upgrade_screen_recurring_body">CAPod areneb pidevalt värskenduste ja paranduste kaudu. Kui soovite seda toetada, kaaluge korduvat annetust GitHub Sponsorsi kaudu.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS doakoa eta kode irekikoa da. Erabilgarria iruditzen bazaizu, kontuan hartu garapena babestu proiektua aurrera jarraitzeko.</string>
<stringname="upgrade_screen_how_body">Izan babesle eta garapen babeslari! Egin klik beheko botoian eginbide gehigarri guztiak aktibatzeko eta nire GitHub Sponsors profila irekitzeko.</string>
<stringname="upgrade_screen_status_free_body">CAPod-en doako bertsioa erabiltzen ari zara. Funtzio gehigarriak desblokeatu ditzakezu garapena lagunduz.</string>
<stringname="upgrade_screen_recurring_body">CAPod eguneratzen eta konpondutzen jarraitzen du. Hori mantendu nahi baduzu, dohaintza periodikoa GitHub Sponsors bidez kontuan hartu.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS رایگان و متن-باز است. اگر آن را مفید یافتید، حمایت مالی از توسعه را در نظر بگیرید تا پروژه ادامه یابد.</string>
<stringname="upgrade_foss_sponsor_action">حامی توسعه دهنده</string>
<stringname="upgrade_foss_sponsor_subtitle">بدون تبلیغ. بدون ردیابی. بدون وابستگی به Google Play.</string>
<stringname="upgrade_foss_sponsor_returned_early">زود برگشتیدی؟ حمایت شما CAPod را زنده نگه میدارد.</string>
<stringname="upgrade_foss_supporter_since">حامی از %s</string>
<stringname="upgrade_foss_supporter_thanks">از حمایت شما از توسعه CAPod سپاسگزاریم!</string>
<stringname="upgrade_foss_sponsor_again_action">باز کردن صفحه حمایت مالی</string>
<stringname="upgrade_foss_sponsor_label">حمایت از CAPod</string>
<stringname="upgrade_screen_how_title">چگونه کمک کنیم</string>
<stringname="upgrade_screen_how_body">به حامی تبدیل شوید و توسعه را حمایت کنید! برای فعال کردن همه ویژگیهای اضافی و باز کردن نمایه GitHub Sponsors من، روی دکمه زیر ضربه بزنید.</string>
<stringname="upgrade_screen_recurring_body">CAPod از طریق بروزرسانیها و رفعهای خطا به تکامل خود ادامه میدهد. اگر میخواهید این تکامل ادامه یابد، کمک مالی مکرر از طریق GitHub Sponsors را در نظر بگیرید.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS on ilmainen ja avoimen lähdekoodin ohjelma. Jos löydät sen hyödylliseksi, harkitse kehityksen tukemista projektin elässä pitämiseksi.</string>
<stringname="upgrade_screen_how_body">Ryhdy tukijaksi ja sponsoroi kehitystä! Napauta alla olevaa painiketta aktivoidaksesi kaikki lisäominaisuudet ja avataksesi GitHub Sponsors -profiilini.</string>
<stringname="upgrade_screen_recurring_title">Pidä se käynnissä</string>
<stringname="upgrade_screen_recurring_body">CAPod kehittyy jatkuvasti päivitysten ja korjausten kautta. Jos haluat tukea sitä, harkitse toistuvaa lahjoitusta GitHub Sponsorien kautta.</string>
<stringname="upgrade_foss_preamble">Ang CAPod FOSS ay libre at open source. Kung nakita mong kapaki-pakinabang ito, isaalang-alang ang pag-sponsor ng development para mapanatiling aktibo ang proyekto.</string>
<stringname="upgrade_foss_sponsor_action">Mag-sponsor ng development</string>
<stringname="upgrade_foss_sponsor_subtitle">Walang ads. Walang tracking. Walang Google Play lock-in.</string>
<stringname="upgrade_foss_sponsor_returned_early">Bumalik na? Ang iyong suporta ay nagpapanatiling buhay ng CAPod.</string>
<stringname="upgrade_screen_how_body">Maging patron at mag-sponsor ng development! I-tap ang button sa ibaba para ma-activate ang lahat ng extra features at buksan ang aking GitHub Sponsors profile.</string>
<stringname="upgrade_screen_status_free_body">Gumagamit ka ng libreng bersyon ng CAPod. Ang mga extra features ay maaaring ma-unlock sa pamamagitan ng pag-suporta sa development.</string>
<stringname="upgrade_screen_status_free_action">Tingnan ang mga opsyon sa upgrade</string>
<stringname="upgrade_screen_recurring_title">Panatilihin ang pagpapatuloy</string>
<stringname="upgrade_screen_recurring_body">Ang CAPod ay patuloy na umuunlad sa pamamagitan ng updates at fixes. Kung gusto mong suportahan iyan, isaalang-alang ang recurring donation sa pamamagitan ng GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS est gratuite et à code source ouvert. Si vous la trouvez utile, pensez à soutenir le développement pour contribuer à la pérennité du projet.</string>
<stringname="upgrade_foss_sponsor_action">Soutenir le développement</string>
<stringname="upgrade_foss_sponsor_subtitle">Pas de publicités. Pas de suivi à la trace. Pas de dépendance à Google Play.</string>
<stringname="upgrade_foss_sponsor_returned_early">Déjà de retour? Votre soutien maintient CAPod en vie.</string>
<stringname="upgrade_foss_supporter_since">Soutien depuis %s</string>
<stringname="upgrade_foss_supporter_thanks">Merci de soutenir le développement de CAPod !</string>
<stringname="upgrade_foss_sponsor_again_action">Ouvrir la page de parrainage</string>
<stringname="upgrade_screen_how_body">Devenez mécène et soutenez le développement. Touchez le bouton ci-dessous pour activer toutes les fonctions supplémentaires et ouvrir mon profil GitHub Sponsors.</string>
<stringname="upgrade_screen_status_free_body">Vous utilisez la version gratuite de CAPod. Des fonctionnalités supplémentaires peuvent être déverrouillées en soutenant le développement.</string>
<stringname="upgrade_screen_status_free_action">Voir les options de mise à niveau</string>
<stringname="upgrade_screen_status_upgraded_title">Mise à niveau active</string>
<stringname="upgrade_screen_recurring_title">Gardez ça en route</string>
<stringname="upgrade_screen_recurring_body">CAPod continue à évoluer grâce aux mises à jour et aux corrections. Si vous souhaitez soutenir cela, envisagez un don récurrent via GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS é gratuito e de código aberto. Se o atopas útil, considera patrocinar o desenvolvemento para axudar a manter o proxecto en marcha.</string>
<stringname="upgrade_screen_how_body">Convértete en mecenas e patrocina o desenvolvemento! Toca o botón de abaixo para activar todas as características extra e abrir o meu perfil de GitHub Sponsors.</string>
<stringname="upgrade_screen_status_free_body">Estás usando a versión gratuita de CAPod. As características adicionais pódense desbloquear apoiando o desenvolvemento.</string>
<stringname="upgrade_screen_status_free_action">Ver opcións de actualización</string>
<stringname="upgrade_screen_recurring_title">Manténo en marcha</string>
<stringname="upgrade_screen_recurring_body">CAPod segue evolucionando a través de actualizacións e correccións. Se queres mantelo así, considera unha doación recorrente a través de GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS मुफ्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो प्रोजेक्ट को जारी रखने में मदद के लिए विकास को प्रायोजित करने पर विचार करें।</string>
<stringname="upgrade_foss_sponsor_action">विकास को प्रायोजक करें</string>
<stringname="upgrade_foss_sponsor_subtitle">कोई विज्ञापन नहीं। कोई ट्रैकिंग नहीं। Google Play पर निर्भरता नहीं।</string>
<stringname="upgrade_foss_sponsor_returned_early">अभी वापस? आपका समर्थन CAPod को जीवित रखता है।</string>
<stringname="upgrade_foss_supporter_since">%s से समर्थक</string>
<stringname="upgrade_foss_supporter_thanks">CAPod के विकास का समर्थन करने के लिए धन्यवाद!</string>
<stringname="upgrade_screen_why_title">अपग्रेड के लाभ</string>
<stringname="upgrade_screen_how_title">कैसे सहायता करें</string>
<stringname="upgrade_screen_how_body">संरक्षक बनें और विकास को प्रायोजित करें! सभी अतिरिक्त सुविधाओं को सक्रिय करने और मेरी GitHub Sponsors प्रोफ़ाइल खोलने के लिए नीचे दिए गए बटन को टैप करें।</string>
<stringname="upgrade_screen_status_free_body">आप CAPod के मुफ़्त संस्करण का उपयोग कर रहे हैं। विकास का समर्थन करके अतिरिक्त सुविधाओं को अनलॉक किया जा सकता है।</string>
<stringname="upgrade_screen_recurring_body">CAPod अपडेट और सुधार के माध्यम से विकसित होता रहता है। यदि आप इसे जारी रखना चाहते हैं, तो GitHub Sponsors के माध्यम से आवर्ती दान पर विचार करें।</string>
<stringname="upgrade_foss_preamble">CAPod FOSS je besplatan i otvorenog koda. Ako vam je koristan, razmislite o sponzoriranju razvoja kako bi projekt ostao živ.</string>
<stringname="upgrade_screen_how_body">Postanite pokrovitelj i sponzorirajte razvoj! Dodirnite gumb ispod da biste aktivirali sve dodatne značajke i otvorili moj GitHub sponzorski profil.</string>
<stringname="upgrade_screen_status_free_body">Koristite besplatnu verziju aplikacije CAPod. Dodatne mogućnosti mogu se otključati podržavanjem razvoja.</string>
<stringname="upgrade_screen_status_free_action">Pogledajte mogućnosti nadogradnje</string>
<stringname="upgrade_screen_status_upgraded_title">Nadogradnja je aktivna</string>
<stringname="upgrade_screen_recurring_body">CAPod se nastavlja razvijati kroz ažuriranja i ispravke. Ako želite održati to, razmislite o ponavljajućoj donaciji putem GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">A CAPod FOSS ingyenes és nyílt forráskódú. Ha hasznosnak találod, fontold meg a fejlesztés támogatását, hogy a projekt tovább élhessen.</string>
<stringname="upgrade_screen_how_title">Hogyan lehet segíteni</string>
<stringname="upgrade_screen_how_body">Legyen patron támogató és szponzorálja a fejlesztést! Kattintson az alábbi gombra az összes extra funkció aktiválásához és a GitHub Sponsors profilom megnyitásához.</string>
<stringname="upgrade_screen_status_free_body">A CAPod ingyenes verzióját használod. A további funkciókat a fejlesztés támogatásával feloldhatod.</string>
<stringname="upgrade_screen_recurring_title">Folytasd a támogatást</string>
<stringname="upgrade_screen_recurring_body">A CAPod folyamatosan fejlődik frissítéseken és hibajavításokon keresztül. Ha szeretnéd ezt fenntartani, fontold meg az ismétlődő adományt a GitHub Sponsors-on.</string>
<stringname="upgrade_screen_how_body">Դարձեք հովանավոր և հովանավորեք զարգացումը: Սեղմեք ստորև գտնվող կոճակը՝ բոլոր լրացուցիչ գործառույթները ակտիվացնելու և իմ GitHub Sponsors պրոֆիլը բացելու համար:</string>
<stringname="upgrade_screen_status_free_body">Դուք օգտագործում եք CAPod-ի անվճար տարբերակը: Լրացուցիչ գործառույթները կարող են բացվել, աջակցելով զարգացմանը:</string>
<stringname="upgrade_screen_recurring_body">CAPod-ը շարունակում է զարգանալ թարմացումների և ուղղումների միջոցով: Եթե ցանկանում եք աջակցել դրան, դիտարկեք պարբերական նվիրատվություն GitHub Sponsors-ի միջոցով:</string>
<stringname="upgrade_foss_preamble">CAPod FOSS gratis dan sumber terbuka. Jika Anda merasa bermanfaat, pertimbangkan untuk mensponsori pengembangan guna menjaga proyek ini berjalan.</string>
<stringname="upgrade_screen_how_body">Jadilah pelindung dan sponsor pengembangan! Ketuk tombol di bawah untuk mengaktifkan semua fitur tambahan dan membuka profil GitHub Sponsors saya.</string>
<stringname="upgrade_screen_recurring_body">CAPod terus berkembang melalui pembaruan dan perbaikan. Jika Anda ingin mendukung itu, pertimbangkan donasi berulang melalui GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS er ókeypis og opinn kóði. Ef þú finnur það gagnlegt, þakkaðu þér fyrir að stuðla að þróun til að hjálpa til við að halda verkefninu gangandi.</string>
<stringname="upgrade_screen_how_title">Hvernig á að hjálpa</string>
<stringname="upgrade_screen_how_body">Vertu styrktaraðili og styrktu þróun! Ýttu á hnappinn hér að neðan til að virkja alla viðbótareiginleika og opna GitHub Sponsors prófílinn minn.</string>
<stringname="upgrade_screen_recurring_title">Haltu því gangandi</string>
<stringname="upgrade_screen_recurring_body">CAPod heldur áfram að þróast með uppfærslum og lagfæringum. Ef þú vilt standa við það, skaltu íhuga endurtekna framlög í gegnum GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS è gratuito e open source. Se lo trovi utile, considera di sponsorizzare lo sviluppo per contribuire a mantenere il progetto in vita.</string>
<stringname="upgrade_foss_sponsor_action">Finanzia lo sviluppo</string>
<stringname="upgrade_foss_sponsor_subtitle">Nessuna pubblicità. Nessun tracciamento. Nessun vincolo con Google Play.</string>
<stringname="upgrade_foss_sponsor_returned_early">Già via? Il tuo supporto mantiene CAPod in vita.</string>
<stringname="upgrade_foss_supporter_since">Sostenitore dal %s</string>
<stringname="upgrade_foss_supporter_thanks">Grazie per supportare lo sviluppo di CAPod!</string>
<stringname="upgrade_foss_sponsor_again_action">Apri la pagina sponsor</string>
<stringname="upgrade_screen_how_body">Diventa un sostenitore e sponsorizza lo sviluppo! Clicca il pulsante qui sotto per attivare tutte le funzioni extra e apri il mio profilo Sponsor Github.</string>
<stringname="upgrade_screen_status_free_body">Stai usando la versione gratuita di CAPod. Funzionalità aggiuntive possono essere sbloccate supportando lo sviluppo.</string>
<stringname="upgrade_screen_status_free_action">Visualizza opzioni di aggiornamento</string>
<stringname="upgrade_screen_recurring_body">CAPod continua a evolversi con aggiornamenti e correzioni. Se desideri sostenerlo, considera una donazione ricorrente via GitHub Sponsors.</string>
<stringname="upgrade_screen_how_body">הפוך לפטרון ונותן חסות לפיתוח! הקש על הכפתור למטה כדי להפעיל את כל התכונות הנוספות ולפתוח את פרופיל הספונסרים בגיטהאב שלי.</string>
<stringname="upgrade_screen_recurring_body">CAPod ממשיך להתפתח דרך עדכונים ותיקונים. אם אתה רוצה לתמוך בכך, שקול תרומה חוזרת דרך GitHub Sponsors.</string>
<stringname="upgrade_screen_how_body">გახდით მფარველი და სპონსორობა გაუწიეთ განვითარებას! შეეხეთ ქვემოთ მოცემულ ღილაკს, რათა ააქტიუროთ ყველა დამატებითი ფუნქცია და გახსნათ ჩემი GitHub Sponsors პროფილი.</string>
<stringname="upgrade_screen_status_free_body">თქვენ იყენებთ CAPod-ის უფასო ვერსიას. დამატებითი ფუნქციები შეიძლება აშვებული იყოს განვითარების მხარდასაჭერით.</string>
<stringname="upgrade_screen_recurring_body">CAPod განაგრძობს განვითარებას განახლებებისა და შეასწორებების მეშვეობით. თუ გსურთ ამის შენარჩუნება, განიხილეთ რეგულარული დონაცია GitHub Sponsors-ის მეშვეობით.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS belaş û çavkaniya vekirî ye. Ger hûn wê kêrhastî dibînin, fikir bikin ku perkirezan bike da ku alîkariya berdewamî projeya bide.</string>
<stringname="upgrade_screen_how_body">Bibin patronê û pîşesaziyê xwehûnand bikin! Bişka jêrîn dakirin da ku hemî taybetiyên zêde çalak bikin û profîla GitHub Sponsors-a min vekirin.</string>
<stringname="upgrade_screen_status_free_body">Hûn guhertoya belaş ya CAPod bikar tîne. Taybetiyên zêde dikarin bê pîşesaziyê xwehûnandê tê de vebirîn.</string>
<stringname="upgrade_screen_recurring_body">CAPod di navberê nûkariyan û raskariyan de cardin pêşve diçe. Ger hûn dixwazin vê berdewam bikin, hemî kirînên danûstî di riya GitHub Sponsors-a de bifikirin.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS ಉಚಿತ ಮತ್ತು ತೆರೆದ ಮೂಲದ್ದಾಗಿದೆ. ನಿಮಗೆ ಉಪಯುಕ್ತವಾಗಿ ತೋರಿದರೆ, ಯೋಜನೆಯನ್ನು ಮುಂದುವರಿಸಲು ಅಭಿವೃದ್ಧಿಗೆ ಪ್ರಾಯೋಜಕತ್ವ ನೀಡಲು ಪರಿಗಣಿಸಿ.</string>
<stringname="upgrade_screen_how_title">ಸಹಾಯ ಮಾಡುವುದು ಹೇಗೆ</string>
<stringname="upgrade_screen_how_body">ಆಶ್ರಯದಾತ ಮತ್ತು ಅಭಿವೃದ್ಧಿ ಸ್ಪನ್ಸರ್ ಆಗಿ ಮಾರ್ಪಡಿ! ಎಲ್ಲಾ ಹೆಚ್ಚುವರಿ ವೈಶಿಷ್ಟ್ಯಗಳನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಲು ಮತ್ತು ನನ್ನ ಗಿಟ್ಹಬ್ ಸ್ಪನ್ಸರ್ಗಳ ಪ್ರೊಫೈಲ್ ತೆರೆಯಲು ಕೆಳಗಿನ ಬಟನ್ ಟ್ಯಾಪ್ ಮಾಡಿ.</string>
<stringname="upgrade_screen_status_free_body">ನೀವು ಸಿಎಪಿಪಾಡ್ನ ಉಚಿತ ಆವೃತ್ತಿಯನ್ನು ಬಳಸುತ್ತಿದ್ದೀರಿ. ಅಭಿವೃದ್ಧಿಯನ್ನು ಬೆಂಬಲಿಸುವ ಮೂಲಕ ಹೆಚ್ಚುವರಿ ವೈಶಿಷ್ಟ್ಯಗಳನ್ನು ಅನ್ಲಾಕ್ ಮಾಡಬಹುದು.</string>
<stringname="upgrade_screen_recurring_body">CAPod ನವೀಕರಣ ಮತ್ತು ಸುಧಾರೆಗಳ ಮೂಲಕ ನಿರಂತರವಾಗಿ ಅಭಿವೃದ್ಧಿ ಆಗುತ್ತಿದೆ. ನೀವು ಇದನ್ನು ಮುಂದುವರಿಸಲು ಬಯಸಿದರೆ, GitHub Sponsors ಮೂಲಕ ನಿಯಮಿತ ದಾನವನ್ನು ಪರಿಗಣಿಸಿ.</string>
<stringname="upgrade_screen_how_body">Демеки болуп, иштөөсүнө спонсорлик кылыңыз! Бардык кошумча өзгөчөлүктөрдүн ишке кирүүсүнө жана GitHub Sponsors профилимди ачуу үчүн төмөндөгү баскычын басыңыз.</string>
<stringname="upgrade_screen_status_free_body">CAPod программасынын акысыз версиясын колдонуп жатасыз. Иштөөнүн өнүгүүсүнө спонсорлик кылсаңыз кошумча өзгөчөлүктөрдүн ишке кирүүсүн ачуп ала аласыз.</string>
<stringname="upgrade_screen_recurring_body">CAPod жаңыланууларды жана оңдуулар аркылуу өнүгүп турат. Эгерде сиз муну сактоону кааласаңыз, GitHub Sponsors аркылуу спонсорлук туурасында ойлонуңүз.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS yra nemokama ir atviro kodo programa. Jei ji jums naudinga, pagalvokite apie rėmimą, kad projektą būtų galima tęsti.</string>
<stringname="upgrade_screen_how_body">Tapkite globėju ir rėmėju plėtros! Palieskite žemiau esantį mygtuką, kad suaktyvintumėte visas papildomas funkcijas ir atidarytumėte mano GitHub Sponsors profilį.</string>
<stringname="upgrade_screen_status_free_body">Jūs naudojate nemokamą CAPod versiją. Papildomos funkcijos gali būti atblokuotos palaikant kūrimą.</string>
<stringname="upgrade_screen_recurring_body">CAPod nuolat tobulėja per atnaujinimus ir pataisas. Jei norite tai palaikyti, apsvarstyti pasikartojančią donaciją per GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS ir bezmaksas un atkļējkoda. Ja tā ir noderba, apsveriet attīstības sponsēšanu, lai palīdzētu turpināt projektu.</string>
<stringname="upgrade_screen_how_body">Kļūstiet par aizbildni un sponsorējiet attīstību! Pieskarieties pogas zemāk, lai aktivizētu visas papildu funkcijas un atvērtu manu GitHub Sponsors profilu.</string>
<stringname="upgrade_screen_recurring_body">CAPod nepārtraukti attīstās caur atjauninājumiem un labojumiem. Ja vēlaties to atbalstīt, izsveriet regulāru ziedojumu caur GitHub Sponsors.</string>
<stringname="upgrade_foss_preamble">CAPod FOSS е бесплатен и отворен код. Ако ви е корисен, размислете да го спонзорирате развојот за да помогнете проектот да продолжи.</string>
<stringname="settings_upgrade_status_description">Твојот статус на поддржувач.</string>
<stringname="upgrade_screen_why_title">Предности на надградување</string>
<stringname="upgrade_screen_how_title">Како да помогнете</string>
<stringname="upgrade_screen_how_body">Станете покровител и спонзорирајте го развојот! Допрете го копчето подолу за да ги активирате сите дополнителни функции и да го отворите мојот GitHub Sponsors профил.</string>
<stringname="upgrade_screen_status_free_body">Користете ја бесплатната верзија на CAPod. Дополнителни функции може да ги отклучите со поддршка на развојот.</string>
<stringname="upgrade_screen_status_free_action">Видете опции за надградување</string>
<stringname="upgrade_screen_recurring_body">CAPod постојано се развива преку ажурирања и поправки. Ако сакате да го поддржите тоа, размислувајте за повторена донација преку GitHub Sponsors.</string>
</resources>
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.