Compare commits

...
87 Commits
Author SHA1 Message Date
d4rken-org-releaser[bot] 8a57257f03 Release: 5.2.6-rc0 2026-09-10 14:31:40 +00:00
darken fc4d2674e9 General: Update app translations from Crowdin 2026-09-10 16:13:30 +02:00
darken 1a176e3df8 chore: Remove retired Claude Cafe plugin settings 2026-09-08 18:27:44 +02:00
Matthias Urhahn 1fff40b9d4 Merge pull request #707 from d4rken-org/chore/clanker-cafe-plugins
General: Move project assistant plugins to Clanker Cafe
2026-09-07 13:12:59 +02:00
darken 9a9d4452d1 chore: Migrate Claude plugins to Clanker Cafe
Enable the six migrated plugins at project scope and retain explicit disabled entries for the old marketplace. The local DebugBadger pilot has been promoted to a project installation.

Validation: a fresh Claude Code session loaded all six new plugin identities, their commands and agents, the migrated hooks and all four MCP services. Read-only schema discovery passed without device actions or service writes.
2026-09-07 13:06:08 +02:00
darken 43740d8b2e chore(build): Enable R8 obfuscation for the Google Play flavor
Google Play scores the obfuscation share of uploaded bundles and flags
listings below its threshold ("App optimisation is below our threshold",
fix by Feb 2027). Minify and shrink were already on; the shared
-dontobfuscate was the only thing keeping the score at 0%.

-dontobfuscate moves to a FOSS-only rule file; the Play rule file keeps
SourceFile/LineNumberTable for retracing and pins names only where they
reach users or logs: AapSetting/AapCommand subclasses (session logs),
ViewModel1 subclasses (log tag), Throwables (error dialog label). All
reflective targets (BuildConfig, ArtMirror, NeverCall, InvokeStub,
AncModeActionCallback) already carry @Keep or explicit keeps and are
identity-mapped in the gplayRelease mapping. The release workflow
archives the mapping next to the Play upload.
2026-09-06 10:41:37 +02:00
darken 83849efe80 refactor(logging): Replace receiver-derived log tags with explicit tags
The Any.log overload built its tag from this::class.java.name, which turns
into CAP:a once the class is obfuscated. Every call site now passes a
logTag(...) constant and the overload is gone so it cannot be reintroduced.
2026-09-06 10:41:37 +02:00
darken ed66731207 chore(fastlane): Roll out beta track releases to 100%
Both supply lanes staged uploads at 10% of the beta track, which left the
remaining 90% waiting on a manual Play Console rollout step for every
release. Beta-track users have already opted in, so staging within that
audience adds a manual gate without adding safety.

Promotion from the beta track to production stays manual.
2026-09-03 18:20:01 +02:00
d4rken-org-releaser[bot] a41bef21b5 Release: 5.2.5-rc0 2026-09-03 11:25:44 +00:00
darken 91b49536b7 chore: Disable kotlin-lsp plugin 2026-09-03 13:10:25 +02:00
darken 8690072ee2 General: Update app translations from Crowdin 2026-09-03 13:10:25 +02:00
darken 92c829c7e6 General: Update fastlane translations from Crowdin 2026-09-03 13:10:25 +02:00
Matthias Urhahn 1c67a78130 Merge pull request #703 from d4rken-org/docs/crowdin-context-ownership
General: Note that translator context is edited on Crowdin
2026-09-03 11:09:08 +02:00
darken 7a64a23d1e docs: Record that translator context lives on Crowdin
Every string in the three Android source files now carries a custom
context written on Crowdin. A custom value is sticky per string: Crowdin
stops re-deriving that string's context from its XML comment on later
source pushes, so editing the comment in values/strings.xml silently
reaches no one.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reported via a Play Store review (vi, 4 stars, 5.2.3-rc0).
2026-08-21 01:13:13 +02:00
Matthias Urhahn b07428f894 Merge pull request #686 from d4rken-org/worktree-anc-adaptive-misreport
Device: Fix duplicate listening mode commands and silent failures
2026-08-20 18:16:20 +02:00
darken dbd1d422c6 refactor(aap): Split the echo classifier out of this change
Everything that reinterprets what the device reported moves to its own branch
(anc-echo-classifier), leaving only changes that stand on their own.

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

What remains does not depend on the misreport:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The predicate is applied >= prior rather than == prior. A route that
quantizes the target back up to the starting index attenuated nothing
either, and a volume that came back higher (user raised it between the two
reads) must not produce a duck whose restore would undo their change. The
WARN says "volume did not decrease" rather than blaming the ROM, since the
read-back alone cannot distinguish those three causes.
2026-08-17 13:00:02 +02:00
d4rken-org-releaser[bot] 3af1af872e Release: 5.2.3-rc0 2026-08-16 19:51:18 +00:00
Matthias Urhahn 1f00925cd8 Merge pull request #680 from d4rken-org/crowdin-update-20260816
General: Update translations from Crowdin
2026-08-16 20:47:28 +02:00
darken 50079e2b7a fix(upgrade): Remove orphaned translation strings failing lint vitals
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.
2026-08-16 20:17:47 +02:00
darken 02ec76980c General: Update app translations from Crowdin 2026-08-16 19:52:33 +02:00
darken 52ad90f77e General: Update fastlane translations from Crowdin 2026-08-16 19:39:06 +02:00
darken 563c6e71f7 fix(upgrade): Invalidate a dead billing connection from refreshStrict too
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.
2026-08-16 17:44:02 +02:00
darken a357d77b00 test(upgrade): Cover pending purchases across the billing stack and screen
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.
2026-08-16 17:44:02 +02:00
darken abc82b0f09 fix(upgrade): Show supporter status on every upgrade screen route
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.
2026-08-16 17:44:02 +02:00
darken 3e6541deec feat(upgrade): Explain a pending Google Play payment on the upgrade screen
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.
2026-08-16 17:44:02 +02:00
darken 33a972001e feat(upgrade): Carry pending Google Play purchases through the billing stack
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.
2026-08-16 17:44:02 +02:00
Matthias Urhahn fe67ce0365 Shrink issue screenshots uploaded as HTML img tags 2026-08-11 14:43:10 +02:00
darken 511944c21c fix(upgrade): Share the settings row title with the upgrade screen
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.
2026-08-07 22:16:00 +02:00
478 changed files with 10353 additions and 7727 deletions
+2
View File
@@ -21,3 +21,5 @@ When adding new user-facing strings:
- `troubleshooter_ble_result_failure_title` (status/error title)
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.
+16 -5
View File
@@ -32,13 +32,15 @@ ScreenshotContent.kt (mock data + composables)
|------|---------|
| `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/PlayStoreScreenshots.kt` | `@PreviewTest` functions (currently: `DashboardLight`, `DashboardDark`, `CasePopUp`, `DeviceProfiles`, `AddProfile`, `DeviceSettingsReactions`, `WidgetConfiguration`) |
| `app/src/screenshotTest/kotlin/.../screenshots/PlayStoreLocales.kt` | Multi-preview annotations (auto-generated by batch script) |
| `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 the 6 smoke locales (en-US, de-DE, ja-JP, ar, zh-CN, pt-BR) have `phoneScreenshots/*.png` checked into the repo. Non-smoke locales are excluded by `.gitignore`. This mirrors permission-pilot and keeps repo size small (~7 MB vs ~67 MB for the full 68 locales).
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.
@@ -90,11 +92,20 @@ Periodic, manual operation — not per-PR:
```bash
./fastlane/generate_screenshots.sh # full, ~30 min, 476 PNGs (68 locales x 7)
./fastlane/copy_screenshots.sh --clean
bundle exec fastlane screenshots_only # uploads all 68 locales to Play Store
git checkout -- fastlane/metadata/android/ # discard non-smoke changes (gitignored anyway)
git add fastlane/metadata/android/en-US/images/phoneScreenshots/
if bundle exec fastlane screenshots_only; then
git checkout -- fastlane/metadata/android/ &&
git commit --only -m "chore(screenshots): Refresh Play Store screenshots" -- \
fastlane/metadata/android/en-US/images/phoneScreenshots/
else
git checkout -- fastlane/metadata/android/
echo "Upload failed; the refreshed en-US screenshots remain staged for retry."
fi
```
The `.gitignore` rule keeps non-smoke output unstaged automatically, so only the smoke locales' refreshed PNGs would show up as modifications and can be committed.
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
+6 -7
View File
@@ -7,13 +7,12 @@
"deny": []
},
"enabledPlugins": {
"android-translation@claude-code-cafe": true,
"debugbadger@claude-code-cafe": true,
"jvm-tools@claude-code-cafe": true,
"frontend-design@claude-plugins-official": true,
"kotlin-lsp@claude-plugins-official": true,
"support-investigator@claude-code-cafe": true,
"google-play@claude-code-cafe": true,
"devtools@claude-code-cafe": true
"android-translation@clanker-cafe": true,
"debugbadger@clanker-cafe": true,
"jvm-tools@clanker-cafe": true,
"support@clanker-cafe": true,
"google-play@clanker-cafe": true,
"devtools@clanker-cafe": true
}
}
+3 -3
View File
@@ -72,13 +72,13 @@ bats tools/release/bump.bats
| Tag suffix | FOSS APK | GitHub release | Fastlane lane | Play track | Rollout |
|---|---|---|---|---|---|
| `-beta*` | `assembleFossBeta` | pre-release | `beta` | `beta` | 10% |
| `-rc*` | `assembleFossRelease` | full release | `production` | **`beta`** | 10% |
| `-beta*` | `assembleFossBeta` | pre-release | `beta` | `beta` | 100% |
| `-rc*` | `assembleFossRelease` | full release | `production` | **`beta`** | 100% |
`release-tag.yml` accepts only `v<M.m.p>-rcN` or `v<M.m.p>-betaN` — any other suffix fails
`validate-tag` before a build starts. There is no third channel.
`lane :production` in `Fastfile` uploads to Play's **beta** track at 10% — manually promoted to production via Play Console.
`lane :production` in `Fastfile` uploads to Play's **beta** track at 100% — manually promoted to production via Play Console.
## Rollback
+12 -1
View File
@@ -193,4 +193,15 @@ jobs:
env:
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
# The Play flavor is obfuscated; the bundle embeds this mapping, but keep it reachable
# for retracing user-submitted logs without going through Play Console.
- name: Archive R8 mapping
if: always() && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: r8-mapping-${{ github.ref_name }}
path: app/build/outputs/mapping/gplay*/mapping.txt
if-no-files-found: warn
overwrite: true
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
thumbnail:
permissions:
issues: write
uses: d4rken-org/.github/.github/workflows/thumbnail-images.yml@3756372c3c844e4817c03d27983691f9991a19f4
uses: d4rken-org/.github/.github/workflows/thumbnail-images.yml@94e36d9a67a887e24338f95fd0429925cea21c98
+1 -6
View File
@@ -17,16 +17,11 @@
.kotlin
# Screenshot test reference images (ephemeral, regenerated on demand)
app/src/screenshotTest*/reference/
# Play Store phone screenshots: commit only the 6 smoke locales (mirrors permission-pilot).
# Play Store phone screenshots: commit only en-US.
# Full localization is uploaded by occasional manual regen + screenshots_only;
# Play Store retains previously-uploaded screenshots for locales not pushed.
fastlane/metadata/android/*/images/phoneScreenshots/*.png
!fastlane/metadata/android/en-US/images/phoneScreenshots/*.png
!fastlane/metadata/android/de-DE/images/phoneScreenshots/*.png
!fastlane/metadata/android/ja-JP/images/phoneScreenshots/*.png
!fastlane/metadata/android/ar/images/phoneScreenshots/*.png
!fastlane/metadata/android/zh-CN/images/phoneScreenshots/*.png
!fastlane/metadata/android/pt-BR/images/phoneScreenshots/*.png
.codex
protocol-research/
_site/
+1 -1
View File
@@ -1 +1 @@
5.2.2-rc1 50202010
5.2.6-rc0 50206000
+3
View File
@@ -55,10 +55,12 @@ android {
includeInApk = false
includeInBundle = false
}
proguardFiles("proguard-rules-foss.pro")
}
create("gplay") {
dimension = "version"
signingConfig = signingConfigs["releaseGplay"]
proguardFiles("proguard-rules-gplay.pro")
}
}
@@ -189,6 +191,7 @@ dependencies {
addCompose()
addGlance()
addWorkerManager()
addDataStore()
addNavigation3()
addSerialization()
+3
View File
@@ -0,0 +1,3 @@
# The FOSS build is open source and ships on GitHub/F-Droid; readable stack traces are worth more
# than smaller identifiers there. Only the Google Play flavor obfuscates (proguard-rules-gplay.pro).
-dontobfuscate
+21
View File
@@ -0,0 +1,21 @@
# Google Play scores the obfuscation share of every uploaded bundle and restricts listings that
# stay below its threshold, so the Play flavor obfuscates. The FOSS flavor does not
# (proguard-rules-foss.pro).
# Keep stack traces retraceable with the mapping file that the bundle embeds.
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# AAP session logs identify settings and commands by their simple class name
# (AapSessionEngine, AapAncController, AapOutboundController). Names only; unused classes are
# still removed.
-keepnames class eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
-keepnames class * extends eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
-keepnames class eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
-keepnames class * extends eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
# ViewModel1 derives its log tag from the subclass name (VM:OverviewViewModel).
-keepnames class * extends eu.darken.capod.common.uix.ViewModel1
# Error dialogs and log summaries show exceptions by class name (LocalizedError, asLogSummary).
-keepnames class * extends java.lang.Throwable
-1
View File
@@ -1,5 +1,4 @@
-keep class eu.darken.capod.BuildConfig { *; }
-dontobfuscate
# work-runtime 2.7.1 (pulled by Glance) uses Class.newInstance() reflection throughout.
# R8 full mode strips no-arg constructors not reachable by static analysis.
@@ -47,6 +47,13 @@ internal enum class FossUpgradeView {
STATUS_UPGRADED,
}
// The Settings row and the PITCH title both read this one resource, so they cannot name the tier
// differently — unlike the composed brand title, this stays a support ask ("Sponsor CAPod") rather
// than naming the flavor. gplay's counterpart in UpgradeScreen.kt composes from brandTitleText
// instead; each flavor implementation lives only in its own source set.
@Composable
internal fun settingsUpgradeStatusTitle(): String = stringResource(R.string.upgrade_foss_sponsor_label)
@Composable
fun UpgradeScreenHost(
route: Nav.Main.Upgrade = Nav.Main.Upgrade(),
@@ -115,7 +122,7 @@ internal fun UpgradeScreen(
// Status views describe the existing install, not a support ask — they get the composed
// flavor title, with the postfix highlighted for supporters like the dashboard does it.
title = if (view == FossUpgradeView.PITCH) {
AnnotatedString(stringResource(R.string.settings_upgrade_status_label))
AnnotatedString(settingsUpgradeStatusTitle())
} else {
// "CAPod FOSS", not "CAPod Pro": the FOSS flavor's own qualifier resource supplies the
// tier word, so the title names this build. The upgraded gate keeps the highlight for
@@ -42,8 +42,10 @@ class UpgradeViewModel @Inject constructor(
// Which presentation the screen shows. The manage route (settings "upgrade status" entry)
// gets a status view first; the pitch only appears once a free user asks for the upgrade
// options. Upgrading wins over that choice — completing the sponsor flow from the pitch must
// land on the upgraded status, not back on the ask. null until the route is bound.
// options. Upgrading wins on EVERY route, not just manage: forced routes (Pro-locked settings)
// stay open after the sponsor flow completes, and the pitch with its live sponsor button reads
// as "sponsoring didn't work" to a fresh supporter — the toast alone is too transient for a
// money moment without a receipt behind it. null until the route is bound.
internal val state: StateFlow<State?> = combine(
routeFlow,
upgradeRepo.upgradeInfo,
@@ -51,7 +53,7 @@ class UpgradeViewModel @Inject constructor(
) { route, info, showOptions ->
val view = when {
route == null -> null
route.manage && info.isPro -> FossUpgradeView.STATUS_UPGRADED
info.isPro -> FossUpgradeView.STATUS_UPGRADED
route.manage && !showOptions -> FossUpgradeView.STATUS_FREE
else -> FossUpgradeView.PITCH
}
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Skenk</string>
<string name="foss_upgrade_alreadydonated_label">Ek het reeds geskenk</string>
<string name="foss_upgrade_no_money_label">Ek spandeer al my geld op AirPods</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Borg ontwikkeling</string>
<string name="upgrade_foss_sponsor_subtitle">Geen advertensies. Geen opsporing. Geen Google Play-binding.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Ondersteuner sedert %s</string>
<string name="upgrade_foss_supporter_thanks">Dankie dat jy CAPod se ontwikkeling ondersteun!</string>
<string name="upgrade_foss_sponsor_again_action">Maak borgblad oop</string>
<string name="settings_upgrade_status_label">Ondersteun CAPod</string>
<string name="upgrade_foss_sponsor_label">Ondersteun CAPod</string>
<string name="settings_upgrade_status_description">Jou ondersteunersstatus.</string>
<string name="upgrade_screen_why_title">Opgraderingsvoordele</string>
<string name="upgrade_screen_how_title">Hoe om te help</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">ልገሳ</string>
<string name="foss_upgrade_alreadydonated_label">አስቀድሜ ልገሳ ፈጽሜአለሁ</string>
<string name="foss_upgrade_no_money_label">ገንዘቤን በሙሉ በAirPods ላይ አውጥቻለሁ</string>
<string name="upgrade_foss_preamble">CAPod FOSS ነፃ እና ክፍት ምንጭ ነው። ግኾሚ ሆኖ ካግኝት፣ ፕሮጀክቱ ቀጥሎ እንደይይድ ለመርዳት ላማቱን ማስደገፏ ያስቡ።</string>
<string name="upgrade_foss_sponsor_action">ልማትን ይደግፉ</string>
<string name="upgrade_foss_sponsor_subtitle">ምንም ማስታወቂያ የሉም። ምንም ክትትል የሉም። ምንም Google Play ቆለፋ የሉም።</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">ደጋፊ ከ%s ጀምሮ</string>
<string name="upgrade_foss_supporter_thanks">የCAPod ልማትን ስለደገፉ እናመሰግናለን!</string>
<string name="upgrade_foss_sponsor_again_action">የስፖንሰር ገጽን ክፈት</string>
<string name="settings_upgrade_status_label">CAPod ን ደግፍ</string>
<string name="upgrade_foss_sponsor_label">CAPod ን ደግፍ</string>
<string name="settings_upgrade_status_description">የእርስዎ ደጋፊ ሁኔታ።</string>
<string name="upgrade_screen_why_title">የማሻሻል ጥቅማጥቅሞች</string>
<string name="upgrade_screen_how_title">እንዴት መርዳት እንደሚችሉ</string>
+12 -15
View File
@@ -1,24 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">تبرع</string>
<string name="foss_upgrade_alreadydonated_label">لقد تبرعت بالفعل</string>
<string name="foss_upgrade_no_money_label">أنفق كل أموالي على سماعات أيربودز</string>
<string name="upgrade_foss_preamble">برنامج كابود FOSS مجاني ومفتوح المصدر. إذا وجدته مفيدًا، ففكر في دعم تطويره للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">دعم التطوير</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود من جوجل بلاي.</string>
<string name="upgrade_foss_preamble">برنامج كابود FOSS مجّاني ومفتوح المصدر. إذا وجدته مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">ادعم التطوير</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبّع. لا قيود من جوجل بلاي.</string>
<string name="upgrade_foss_sponsor_returned_early">هل عدت بالفعل؟ دعمك يبقي كابود حيًّا.</string>
<string name="upgrade_foss_supporter_since">داعم منذ %s</string>
<string name="upgrade_foss_supporter_thanks">شكرًا لدعمك تطوير CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">فتح صفحة الرعاية</string>
<string name="settings_upgrade_status_label">دعم كابود</string>
<string name="upgrade_foss_supporter_since">داعمٌ منذ %s</string>
<string name="upgrade_foss_supporter_thanks">شكرًا لدعمك تطوير كابود!</string>
<string name="upgrade_foss_sponsor_again_action">افتح صفحة الرعاية</string>
<string name="upgrade_foss_sponsor_label">دعم كابود</string>
<string name="settings_upgrade_status_description">حالة دعمك.</string>
<string name="upgrade_screen_why_title">مزايا الترقية</string>
<string name="upgrade_screen_how_title">كيفية المساعدة</string>
<string name="upgrade_screen_how_body">كُن راعيًا وساهم في تطوير المشروع! اضغط على الزرّ أدناه لتفعيل جميع الميزات الإضافية وفتح ملفّي الشخصي على GitHub Sponsors.</string>
<string name="upgrade_screen_how_body">كُن راعيًا وادعم تطوير المشروع! اضغط على الزرّ أدناه لتفعيل جميع المزايا الإضافية وفتح ملفّي الشخصي على GitHub Sponsors.</string>
<string name="upgrade_screen_status_free_title">النسخة المجّانية</string>
<string name="upgrade_screen_status_free_body">أنت تستخدم النسخة المجّانية من كابود. يمكنك فتح ميزات إضافية من خلال دعم التطوير.</string>
<string name="upgrade_screen_status_free_action">الاطّلاع على خيارات الترقية</string>
<string name="upgrade_screen_status_upgraded_title">الترقية نشطة</string>
<string name="upgrade_screen_status_free_body">أنت تستخدم النسخة المجّانية من كابود. يمكنك فتح مزايا إضافيةٍ عبر دعم التطوير.</string>
<string name="upgrade_screen_status_free_action">اطّلع على خيارات الترقية</string>
<string name="upgrade_screen_status_upgraded_title">الترقية نشيطة</string>
<string name="upgrade_screen_recurring_title">أبقِ التطبيق مستمرًّا</string>
<string name="upgrade_screen_recurring_body">يستمرّ كابود في التطوّر من خلال التحديثات والإصلاحات. إذا كنت ترغب في دعم هذا التطوّر، ففكّر في التبرّع بشكل دوري عبر GitHub Sponsors.</string>
<string name="upgrade_screen_recurring_body">يستمرّ كابود في التطوّر عبر التحديثات والإصلاحات. إذا كنت ترغب في دعم هذا التطوّر، ففكّر في التبرّع بشكلٍ دوريٍّ عبر GitHub Sponsors.</string>
</resources>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">İanə ver</string>
<string name="foss_upgrade_alreadydonated_label">Artıq ianə vermişəm</string>
<string name="foss_upgrade_no_money_label">Bütün pulumu AirPods-lara xərcləyirəm</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">İnkişafı sponsor edin</string>
<string name="upgrade_foss_sponsor_subtitle">Reklam yoxdur. İzləmə yoxdur. Google Play bağlılığı yoxdur.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s tarixindən bəri dəstəkçi</string>
<string name="upgrade_foss_supporter_thanks">CAPod-un inkişafını dəstəklədiyiniz üçün təşəkkürlər!</string>
<string name="upgrade_foss_sponsor_again_action">Sponsor səhifəsini aç</string>
<string name="settings_upgrade_status_label">CAPod-u sponsorlaşdırın</string>
<string name="upgrade_foss_sponsor_label">CAPod-u sponsorlaşdırın</string>
<string name="settings_upgrade_status_description">Sizin tərəfdar statusu.</string>
<string name="upgrade_screen_why_title">Yüksəltmə üstünləri</string>
<string name="upgrade_screen_how_title">Necə kömək etmək</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Ахвяраваць</string>
<string name="foss_upgrade_alreadydonated_label">Я ўжо ахвяраваў</string>
<string name="foss_upgrade_no_money_label">Я патраціў усе грошы на AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS бясплатны і з адкрытым зыходным кодам. Калі вы лічыце яго карысным, разгледзьце магчымасць спансіравання распрацоўкі, каб дапамагчы трымаць праект ў жывых.</string>
<string name="upgrade_foss_sponsor_action">Падтрымаць распрацоўку</string>
<string name="upgrade_foss_sponsor_subtitle">Ніякай рэкламы. Ніякай адсочкі. Ніякай залежнасці ад Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Спонсар з %s</string>
<string name="upgrade_foss_supporter_thanks">Дзякуй за падтрымку развіцця CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Адкрыць старонку спонсарства</string>
<string name="settings_upgrade_status_label">Спонсараваць CAPod</string>
<string name="upgrade_foss_sponsor_label">Спонсараваць CAPod</string>
<string name="settings_upgrade_status_description">Ваш статус прыхільніка.</string>
<string name="upgrade_screen_why_title">Перавагі абнаўлення</string>
<string name="upgrade_screen_how_title">Як дапамагчы</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Дарение</string>
<string name="foss_upgrade_alreadydonated_label">Вече съм дарил</string>
<string name="foss_upgrade_no_money_label">Харча всичките си пари за AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS е безплатен и с отворен код. Ако ви е полезен, размислете да подкрепите разработката, за да помогнете проектът да продължи.</string>
<string name="upgrade_foss_sponsor_action">Спонсорирайте разработката</string>
<string name="upgrade_foss_sponsor_subtitle">Без реклами. Без следене. Без заключване в Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Поддържник от %s</string>
<string name="upgrade_foss_supporter_thanks">Благодарим ви, че подкрепяте развитието на CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Отвори страницата за спонсорство</string>
<string name="settings_upgrade_status_label">Спонсорирайте CAPod</string>
<string name="upgrade_foss_sponsor_label">Спонсорирайте CAPod</string>
<string name="settings_upgrade_status_description">Вашият статус на поддръжник.</string>
<string name="upgrade_screen_why_title">Преимущества на надстройката</string>
<string name="upgrade_screen_how_title">Как да помогнете</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">দান করুন</string>
<string name="foss_upgrade_alreadydonated_label">আমি ইতিমধ্যে দান করেছি</string>
<string name="foss_upgrade_no_money_label">আমি আমার সব টাকা AirPods এর জন্য খরচ করি</string>
<string name="upgrade_foss_preamble">CAPod FOSS বিনামূল্যে এবং মুক্ত উৎস। আপনি যদি এটি উপযোগী মনে করেন, প্রকল্পটি চালু রাখতে সাহায্য করতে ডেভেলপমেন্ট স্পনসর করার কথা বিবেচনা করুন।</string>
<string name="upgrade_foss_sponsor_action">স্পনসর ডেভলপমেন্ট</string>
<string name="upgrade_foss_sponsor_subtitle">কোনো বিজ্ঞাপন নেই। কোনো ট্র্যাকিং নেই। Google Play-এর সাথে বাঁধানো নেই।</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s থেকে সমর্থক</string>
<string name="upgrade_foss_supporter_thanks">CAPod-এর উন্নয়নে সমর্থন করার জন্য আপনাকে ধন্যবাদ!</string>
<string name="upgrade_foss_sponsor_again_action">স্পনসর পৃষ্ঠা খুলুন</string>
<string name="settings_upgrade_status_label">CAPod-কে স্পন্সর করুন</string>
<string name="upgrade_foss_sponsor_label">CAPod-কে স্পন্সর করুন</string>
<string name="settings_upgrade_status_description">আপনার সমর্থক স্থিতি।</string>
<string name="upgrade_screen_why_title">আপগ্রেড সুবিধা</string>
<string name="upgrade_screen_how_title">কিভাবে সাহায্য করতে পারি</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Donacions</string>
<string name="foss_upgrade_alreadydonated_label">Ja he donat</string>
<string name="foss_upgrade_no_money_label">Gasto tots els meus diners en AirPods</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Patrocina el desenvolupament</string>
<string name="upgrade_foss_sponsor_subtitle">Sense anuncis. Sense seguiment. Sense dependència del Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Col·laborador des de %s</string>
<string name="upgrade_foss_supporter_thanks">Gràcies per donar suport al desenvolupament del CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Obre la pàgina del patrocinador</string>
<string name="settings_upgrade_status_label">Patrocineu el CAPod</string>
<string name="upgrade_foss_sponsor_label">Patrocineu el CAPod</string>
<string name="settings_upgrade_status_description">El vostre estat de col·laborador.</string>
<string name="upgrade_screen_why_title">Beneficis de la millora</string>
<string name="upgrade_screen_how_title">Com ajudar</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Přispět</string>
<string name="foss_upgrade_alreadydonated_label">Již jsem přispěl/a</string>
<string name="foss_upgrade_no_money_label">Všechny peníze jsem utratil/a za AirPods</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Sponzorovat vývoj</string>
<string name="upgrade_foss_sponsor_subtitle">Žádné reklamy. Žádné sledování. Žádná závislost na Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Podporovatel od %s</string>
<string name="upgrade_foss_supporter_thanks">Děkujeme za podporu vývoje CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Otevřít stránku sponzora</string>
<string name="settings_upgrade_status_label">Sponzorovat CAPod</string>
<string name="upgrade_foss_sponsor_label">Sponzorovat CAPod</string>
<string name="settings_upgrade_status_description">Váš stav podporovatele.</string>
<string name="upgrade_screen_why_title">Výhody upgradu</string>
<string name="upgrade_screen_how_title">Jak pomoci</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Doner</string>
<string name="foss_upgrade_alreadydonated_label">Jeg har allerede doneret</string>
<string name="foss_upgrade_no_money_label">Jeg bruger alle mine penge på AirPods</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Sponsorudvikling</string>
<string name="upgrade_foss_sponsor_subtitle">Ingen annoncer. Ingen sporing. Ingen Google Play-lås.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Støtte siden %s</string>
<string name="upgrade_foss_supporter_thanks">Tak fordi du støtter udviklingen af CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Åbn sponsorside</string>
<string name="settings_upgrade_status_label">Sponsor CAPod</string>
<string name="upgrade_foss_sponsor_label">Sponsor CAPod</string>
<string name="settings_upgrade_status_description">Din supporterstatus.</string>
<string name="upgrade_screen_why_title">Opgraderingens fordele</string>
<string name="upgrade_screen_how_title">Sådan hjælper du</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Spenden</string>
<string name="foss_upgrade_alreadydonated_label">Ich habe schon gespendet</string>
<string name="foss_upgrade_no_money_label">Hab alles für AirPods ausgegeben</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Fördere Entwicklung</string>
<string name="upgrade_foss_sponsor_subtitle">Keine Werbung. Kein Tracking. Keine Google-Play-Bindung.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Unterstützer seit %s</string>
<string name="upgrade_foss_supporter_thanks">Vielen Dank, dass du die Entwicklung von CAPod unterstützt!</string>
<string name="upgrade_foss_sponsor_again_action">Sponsoring-Seite öffnen</string>
<string name="settings_upgrade_status_label">CAPod unterstützen</string>
<string name="upgrade_foss_sponsor_label">CAPod unterstützen</string>
<string name="settings_upgrade_status_description">Dein Unterstützerstatus.</string>
<string name="upgrade_screen_why_title">Vorteile des Upgrades</string>
<string name="upgrade_screen_how_title">Wie man hilft</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Δωρεά</string>
<string name="foss_upgrade_alreadydonated_label">Ήδη δώρισα</string>
<string name="foss_upgrade_no_money_label">Ξόδεψα όλα τα χρήματά μου στα AirPods</string>
<string name="upgrade_foss_preamble">Το CAPod FOSS είναι δωρεάν και ανοιχτού κώδικα. Αν το βρίσκετε χρήσιμο, εξετάστε το ενδεχόμενο να χρηματοδοτήσετε την ανάπτυξη για να συνεχίσει το έργο.</string>
<string name="upgrade_foss_sponsor_action">Χορηγήστε την ανάπτυξη</string>
<string name="upgrade_foss_sponsor_subtitle">Χωρίς διαφημίσεις. Χωρίς παρακολούθηση. Χωρίς δέσμευση στο Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Υποστηρικτής από %s</string>
<string name="upgrade_foss_supporter_thanks">Σας ευχαριστούμε που υποστηρίζετε την ανάπτυξη του CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Άνοιγμα σελίδας χορηγίας</string>
<string name="settings_upgrade_status_label">Χορηγήστε το CAPod</string>
<string name="upgrade_foss_sponsor_label">Χορηγήστε το CAPod</string>
<string name="settings_upgrade_status_description">Η κατάστασή σας ως υποστηρικτή.</string>
<string name="upgrade_screen_why_title">Πλεονεκτήματα αναβάθμισης</string>
<string name="upgrade_screen_how_title">Πώς να βοηθήσετε</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Donar</string>
<string name="foss_upgrade_alreadydonated_label">Ya doné</string>
<string name="foss_upgrade_no_money_label">Gasto todo mi dinero en AirPods</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Sponsorear el desarrollo</string>
<string name="upgrade_foss_sponsor_subtitle">Sin publicidad. Sin rastreo. Sin dependencia de Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Colaborador desde %s</string>
<string name="upgrade_foss_supporter_thanks">¡Gracias por apoyar el desarrollo de CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Abrir página de patrocinio</string>
<string name="settings_upgrade_status_label">Apoyá CAPod</string>
<string name="upgrade_foss_sponsor_label">Apoyá CAPod</string>
<string name="settings_upgrade_status_description">Tu estado de patrocinador.</string>
<string name="upgrade_screen_why_title">Beneficios de la mejora</string>
<string name="upgrade_screen_how_title">Como ayudar</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Donar</string>
<string name="foss_upgrade_alreadydonated_label">Ya doné</string>
<string name="foss_upgrade_no_money_label">Gasto todo mi dinero en AirPods</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Patrocinar el desarrollo</string>
<string name="upgrade_foss_sponsor_subtitle">Sin anuncios. Sin rastreo. Sin dependencia de Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Colaborador desde %s</string>
<string name="upgrade_foss_supporter_thanks">¡Gracias por apoyar el desarrollo de CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Abrir página de patrocinio</string>
<string name="settings_upgrade_status_label">Patrocina CAPod</string>
<string name="upgrade_foss_sponsor_label">Patrocina CAPod</string>
<string name="settings_upgrade_status_description">Tu estado de patrocinador.</string>
<string name="upgrade_screen_why_title">Beneficios de la actualización</string>
<string name="upgrade_screen_how_title">Cómo ayudar</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Donar</string>
<string name="foss_upgrade_alreadydonated_label">Ya he donado</string>
<string name="foss_upgrade_no_money_label">Gasto todo mi dinero en AirPods</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Patrocinadores</string>
<string name="upgrade_foss_sponsor_subtitle">Sin anuncios. Sin rastreo. Sin dependencia de Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Colaborador desde %s</string>
<string name="upgrade_foss_supporter_thanks">¡Gracias por apoyar el desarrollo de CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Abrir página de patrocinio</string>
<string name="settings_upgrade_status_label">Patrocinar CAPod</string>
<string name="upgrade_foss_sponsor_label">Patrocinar CAPod</string>
<string name="settings_upgrade_status_description">Tu estado como patrocinador.</string>
<string name="upgrade_screen_why_title">Beneficios de la actualización</string>
<string name="upgrade_screen_how_title">Como ayudar</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Anneta</string>
<string name="foss_upgrade_alreadydonated_label">Juba annetasin</string>
<string name="foss_upgrade_no_money_label">Kulutasin kogu raha AirPodsidele</string>
<string name="upgrade_foss_preamble">CAPod FOSS on tasuta ja avatud lähtekoodiga. Kui sellest on sulle kasu, kaalu arenduse rahastamist, et aidata projekti arendada.</string>
<string name="upgrade_foss_sponsor_action">Rahasta arendamist</string>
<string name="upgrade_foss_sponsor_subtitle">Reklaami pole. Jälgimist pole. Google Play lukustust pole.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Toetaja alates %s</string>
<string name="upgrade_foss_supporter_thanks">Täname, et toetad CAPodi arendust!</string>
<string name="upgrade_foss_sponsor_again_action">Ava sponsorlehekülg</string>
<string name="settings_upgrade_status_label">Rahasta CAPodi</string>
<string name="upgrade_foss_sponsor_label">Rahasta CAPodi</string>
<string name="settings_upgrade_status_description">Sinu toetaja staatus.</string>
<string name="upgrade_screen_why_title">Uuendamise eelised</string>
<string name="upgrade_screen_how_title">Kuidas aidata?</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Eman dohaintza</string>
<string name="foss_upgrade_alreadydonated_label">Dagoeneko eman dut dohaintza</string>
<string name="foss_upgrade_no_money_label">Nire diru guztia AirPods-etan gastatzen dut</string>
<string name="upgrade_foss_preamble">CAPod FOSS doakoa eta kode irekikoa da. Erabilgarria iruditzen bazaizu, kontuan hartu garapena babestu proiektua aurrera jarraitzeko.</string>
<string name="upgrade_foss_sponsor_action">Garapena babestu</string>
<string name="upgrade_foss_sponsor_subtitle">Iragarkirik ez. Jarraipenik ez. Google Play-ren lotura ez.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Laguntzaile %s(e)tik aurrera</string>
<string name="upgrade_foss_supporter_thanks">Eskerrik asko CAPod-en garapena babesten duzulako!</string>
<string name="upgrade_foss_sponsor_again_action">Ireki babesle-orria</string>
<string name="settings_upgrade_status_label">CAPod babeslatu</string>
<string name="upgrade_foss_sponsor_label">CAPod babeslatu</string>
<string name="settings_upgrade_status_description">Zure babesle egoera</string>
<string name="upgrade_screen_why_title">Hobekuntza-onurak</string>
<string name="upgrade_screen_how_title">Nola lagundu</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">کمک مالی</string>
<string name="foss_upgrade_alreadydonated_label">کمک مالی کرده‌ام</string>
<string name="foss_upgrade_no_money_label">من تمام پولم را خرج ایرپاد می‌کنم</string>
<string name="upgrade_foss_preamble">CAPod FOSS رایگان و متن-باز است. اگر آن را مفید یافتید، حمایت مالی از توسعه را در نظر بگیرید تا پروژه ادامه یابد.</string>
<string name="upgrade_foss_sponsor_action">حامی توسعه دهنده</string>
<string name="upgrade_foss_sponsor_subtitle">بدون تبلیغ. بدون ردیابی. بدون وابستگی به Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">حامی از %s</string>
<string name="upgrade_foss_supporter_thanks">از حمایت شما از توسعه CAPod سپاسگزاریم!</string>
<string name="upgrade_foss_sponsor_again_action">باز کردن صفحه حمایت مالی</string>
<string name="settings_upgrade_status_label">حمایت از CAPod</string>
<string name="upgrade_foss_sponsor_label">حمایت از CAPod</string>
<string name="settings_upgrade_status_description">وضعیت حمایتی شما.</string>
<string name="upgrade_screen_why_title">مزایای ارتقا</string>
<string name="upgrade_screen_how_title">چگونه کمک کنیم</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Lahjoita</string>
<string name="foss_upgrade_alreadydonated_label">Olen jo lahjoittanut</string>
<string name="foss_upgrade_no_money_label">Käytän kaikki rahani AirPodeihin</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Sponsoroi kehitystä</string>
<string name="upgrade_foss_sponsor_subtitle">Ei mainoksia. Ei seurantaa. Ei Google Play -riippuvuutta.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Tukija %s lähtien</string>
<string name="upgrade_foss_supporter_thanks">Kiitos, että tuet CAPodin kehitystä!</string>
<string name="upgrade_foss_sponsor_again_action">Avaa sponsorisivu</string>
<string name="settings_upgrade_status_label">Tue CAPodia</string>
<string name="upgrade_foss_sponsor_label">Tue CAPodia</string>
<string name="settings_upgrade_status_description">Kannattajantila</string>
<string name="upgrade_screen_why_title">Päivityksen edut</string>
<string name="upgrade_screen_how_title">Kuinka auttaa</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Mag-donate</string>
<string name="foss_upgrade_alreadydonated_label">Nakapag-donate na ako</string>
<string name="foss_upgrade_no_money_label">Ginagastos ko lahat ng pera ko sa AirPods</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Mag-sponsor ng development</string>
<string name="upgrade_foss_sponsor_subtitle">Walang ads. Walang tracking. Walang Google Play lock-in.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Tagasuporta simula %s</string>
<string name="upgrade_foss_supporter_thanks">Salamat sa pagsuporta sa development ng CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Buksan ang sponsor page</string>
<string name="settings_upgrade_status_label">Suportahan ang CAPod</string>
<string name="upgrade_foss_sponsor_label">Suportahan ang CAPod</string>
<string name="settings_upgrade_status_description">Ang iyong status bilang supporter.</string>
<string name="upgrade_screen_why_title">Mga benepisyo ng upgrade</string>
<string name="upgrade_screen_how_title">Paano tumulong</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Faire un don</string>
<string name="foss_upgrade_alreadydonated_label">Jai déjà fait un don</string>
<string name="foss_upgrade_no_money_label">Jai dépensé tout mon argent sur des AirPods</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Soutenir le développement</string>
<string name="upgrade_foss_sponsor_subtitle">Pas de publicités. Pas de suivi à la trace. Pas de dépendance à Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Soutien depuis %s</string>
<string name="upgrade_foss_supporter_thanks">Merci de soutenir le développement de CAPod !</string>
<string name="upgrade_foss_sponsor_again_action">Ouvrir la page de parrainage</string>
<string name="settings_upgrade_status_label">Soutenir CAPod</string>
<string name="upgrade_foss_sponsor_label">Soutenir CAPod</string>
<string name="settings_upgrade_status_description">Votre statut de supporter.</string>
<string name="upgrade_screen_why_title">Avantages de la mise à niveau</string>
<string name="upgrade_screen_how_title">Comment aider</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Doar</string>
<string name="foss_upgrade_alreadydonated_label">Xa doei</string>
<string name="foss_upgrade_no_money_label">Gasto todos os meus cartos en AirPods</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Patrocinar desenvolvemento</string>
<string name="upgrade_foss_sponsor_subtitle">Sen anuncios. Sen rastrexo. Sen dependencia de Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Colaborador desde %s</string>
<string name="upgrade_foss_supporter_thanks">Grazas por apoiar o desenvolvemento de CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Abrir a páxina de patrocinio</string>
<string name="settings_upgrade_status_label">Apoia CAPod</string>
<string name="upgrade_foss_sponsor_label">Apoia CAPod</string>
<string name="settings_upgrade_status_description">O teu estado de apoiador.</string>
<string name="upgrade_screen_why_title">Vantaxes da actualización</string>
<string name="upgrade_screen_how_title">Como axudar</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">दान करें</string>
<string name="foss_upgrade_alreadydonated_label">मैंने पहले ही दान कर दिया है</string>
<string name="foss_upgrade_no_money_label">मैं अपना सारा पैसा AirPods पर खर्च करता हूं</string>
<string name="upgrade_foss_preamble">CAPod FOSS मुफ्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो प्रोजेक्ट को जारी रखने में मदद के लिए विकास को प्रायोजित करने पर विचार करें।</string>
<string name="upgrade_foss_sponsor_action">विकास को प्रायोजक करें</string>
<string name="upgrade_foss_sponsor_subtitle">कोई विज्ञापन नहीं। कोई ट्रैकिंग नहीं। Google Play पर निर्भरता नहीं।</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s से समर्थक</string>
<string name="upgrade_foss_supporter_thanks">CAPod के विकास का समर्थन करने के लिए धन्यवाद!</string>
<string name="upgrade_foss_sponsor_again_action">स्पॉन्सर पेज खोलें</string>
<string name="settings_upgrade_status_label">CAPod को प्रायोजित करें</string>
<string name="upgrade_foss_sponsor_label">CAPod को प्रायोजित करें</string>
<string name="settings_upgrade_status_description">आपकी समर्थक स्थिति।</string>
<string name="upgrade_screen_why_title">अपग्रेड के लाभ</string>
<string name="upgrade_screen_how_title">कैसे सहायता करें</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Donirajte</string>
<string name="foss_upgrade_alreadydonated_label">Već sam donirao</string>
<string name="foss_upgrade_no_money_label">Sav svoj novac trošim na AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS je besplatan i otvorenog koda. Ako vam je koristan, razmislite o sponzoriranju razvoja kako bi projekt ostao živ.</string>
<string name="upgrade_foss_sponsor_action">Podržite razvoj</string>
<string name="upgrade_foss_sponsor_subtitle">Bez oglasa. Bez praćenja. Bez zaključanosti na Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Podržavatelj od %s</string>
<string name="upgrade_foss_supporter_thanks">Hvala vam što podržavate razvoj CAPoda!</string>
<string name="upgrade_foss_sponsor_again_action">Otvori stranicu za sponzoriranje</string>
<string name="settings_upgrade_status_label">Sponzorirajte CAPod</string>
<string name="upgrade_foss_sponsor_label">Sponzorirajte CAPod</string>
<string name="settings_upgrade_status_description">Vaš status sponzora</string>
<string name="upgrade_screen_why_title">Prednosti nadogradnje</string>
<string name="upgrade_screen_how_title">Kako pomoći</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Adományoz</string>
<string name="foss_upgrade_alreadydonated_label">Már adományoztam</string>
<string name="foss_upgrade_no_money_label">Minden pénzemet az AirPods-ra költöttem</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Szponzori fejlesztés</string>
<string name="upgrade_foss_sponsor_subtitle">Nincs hirdetés. Nincs nyomkövetés. Nincs Google Play-függőség.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Támogató %s óta</string>
<string name="upgrade_foss_supporter_thanks">Köszönjük, hogy támogatod a CAPod fejlesztését!</string>
<string name="upgrade_foss_sponsor_again_action">Támogatói oldal megnyitása</string>
<string name="settings_upgrade_status_label">Támogasson CAPod-ot</string>
<string name="upgrade_foss_sponsor_label">Támogasson CAPod-ot</string>
<string name="settings_upgrade_status_description">Az Ön támogató státusza.</string>
<string name="upgrade_screen_why_title">Frissítési előnyök</string>
<string name="upgrade_screen_how_title">Hogyan lehet segíteni</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Նվիրաբերել</string>
<string name="foss_upgrade_alreadydonated_label">Ես արդեն նվիրաբերել եմ</string>
<string name="foss_upgrade_no_money_label">Ես իմ ամբողջ գումարը ծախսում եմ AirPods-ի վրա</string>
<string name="upgrade_foss_preamble">CAPod FOSS անվճար և բաց կոդով: Այն ոգտակար էք, խնդրում զարգացությունը կենդանի պահելու համար:</string>
<string name="upgrade_foss_sponsor_action">Հովանավորել զարգացումը</string>
<string name="upgrade_foss_sponsor_subtitle">Լրատնություն չկա: հետքխկություն չկա: Google Play-ի կապվածություն չկա:</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Հովանավոր է %s-ից</string>
<string name="upgrade_foss_supporter_thanks">Շնորհակալություն CAPod-ի զարգացումն աջակցելու համար!</string>
<string name="upgrade_foss_sponsor_again_action">Բացել հովանավորության էջը</string>
<string name="settings_upgrade_status_label">Աջակեք CAPod-ին</string>
<string name="upgrade_foss_sponsor_label">Աջակեք CAPod-ին</string>
<string name="settings_upgrade_status_description">Ձեր աջակցողի վիճակը:</string>
<string name="upgrade_screen_why_title">Թարմացման առավելությունները</string>
<string name="upgrade_screen_how_title">Ինչպես օգնել</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Menyumbang</string>
<string name="foss_upgrade_alreadydonated_label">Saya telah berdonasi</string>
<string name="foss_upgrade_no_money_label">Saya menghabiskan semua uang saya untuk AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS gratis dan sumber terbuka. Jika Anda merasa bermanfaat, pertimbangkan untuk mensponsori pengembangan guna menjaga proyek ini berjalan.</string>
<string name="upgrade_foss_sponsor_action">Pengembangan sponsor</string>
<string name="upgrade_foss_sponsor_subtitle">Tanpa iklan. Tanpa pelacakan. Tanpa ketergantungan Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Pendukung sejak %s</string>
<string name="upgrade_foss_supporter_thanks">Terima kasih karena telah mendukung pengembangan CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Buka halaman sponsor</string>
<string name="settings_upgrade_status_label">Sponsori CAPod</string>
<string name="upgrade_foss_sponsor_label">Sponsori CAPod</string>
<string name="settings_upgrade_status_description">Status pendukung Anda.</string>
<string name="upgrade_screen_why_title">Manfaat upgrade</string>
<string name="upgrade_screen_how_title">Cara membantu</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Gefa</string>
<string name="foss_upgrade_alreadydonated_label">Ég hef þegar gefið</string>
<string name="foss_upgrade_no_money_label">Ég eyði öllum peningunum mínum í AirPods</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Styrkja þróun</string>
<string name="upgrade_foss_sponsor_subtitle">Engar auglysíngar. Engin rakning. Ekkert Google Play-lás.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Styrktaraðili síðan %s</string>
<string name="upgrade_foss_supporter_thanks">Takk fyrir að styðja þróun CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Opna styrktarsíðu</string>
<string name="settings_upgrade_status_label">Styrktu CAPod</string>
<string name="upgrade_foss_sponsor_label">Styrktu CAPod</string>
<string name="settings_upgrade_status_description">Staða þín sem stuðningsmaður</string>
<string name="upgrade_screen_why_title">Uppfærslukostir</string>
<string name="upgrade_screen_how_title">Hvernig á að hjálpa</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Dona</string>
<string name="foss_upgrade_alreadydonated_label">Ho già donato</string>
<string name="foss_upgrade_no_money_label">Spendo tutti i miei soldi per gli AirPods</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Finanzia lo sviluppo</string>
<string name="upgrade_foss_sponsor_subtitle">Nessuna pubblicità. Nessun tracciamento. Nessun vincolo con Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Sostenitore dal %s</string>
<string name="upgrade_foss_supporter_thanks">Grazie per supportare lo sviluppo di CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Apri la pagina sponsor</string>
<string name="settings_upgrade_status_label">Sponsorizza CAPod</string>
<string name="upgrade_foss_sponsor_label">Sponsorizza CAPod</string>
<string name="settings_upgrade_status_description">Il tuo status di sostenitore.</string>
<string name="upgrade_screen_why_title">Vantaggi dell\'aggiornamento</string>
<string name="upgrade_screen_how_title">Come aiutare</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">תרומה</string>
<string name="foss_upgrade_alreadydonated_label">כבר תרמתי</string>
<string name="foss_upgrade_no_money_label">מיטב כספי מושקע ב-AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS הוא חינם וקוד פתוח. אם הוא שימושי לך, שקול לשקול על חסוי הפיתוח כדי לעזור להמשיך את הפרויקט.</string>
<string name="upgrade_foss_sponsor_action">פיתוח חסות</string>
<string name="upgrade_foss_sponsor_subtitle">אין פרסומות. אין מעקב. אין תלות ב-Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">תומך מאז %s</string>
<string name="upgrade_foss_supporter_thanks">תודה שאתה תומך בפיתוח של CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">פתח את דף התמיכה</string>
<string name="settings_upgrade_status_label">תמוך ב-CAPod</string>
<string name="upgrade_foss_sponsor_label">תמוך ב-CAPod</string>
<string name="settings_upgrade_status_description">סטטוס התומך שלך.</string>
<string name="upgrade_screen_why_title">יתרונות השדרוג</string>
<string name="upgrade_screen_how_title">איך לעזור</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">寄付</string>
<string name="foss_upgrade_alreadydonated_label">すでに寄付しました</string>
<string name="foss_upgrade_no_money_label">AirPodsに全財産をつぎ込んでいます</string>
<string name="upgrade_foss_preamble">CAPod FOSSは無料かつオープンソースです。便利だと思ったら、プロジェクトの継続に向けて開発をスポンサーすることをご検討ください。</string>
<string name="upgrade_foss_sponsor_action">開発支援者</string>
<string name="upgrade_foss_sponsor_subtitle">広告なし。追跡なし。Google Play依存なし。</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%sからのサポーター</string>
<string name="upgrade_foss_supporter_thanks">CAPodの開発を支援していただきありがとうございます!</string>
<string name="upgrade_foss_sponsor_again_action">スポンサーページを開く</string>
<string name="settings_upgrade_status_label">CAPod をスポンサーする</string>
<string name="upgrade_foss_sponsor_label">CAPod をスポンサーする</string>
<string name="settings_upgrade_status_description">サポーターステータス。</string>
<string name="upgrade_screen_why_title">アップグレードの特典</string>
<string name="upgrade_screen_how_title">協力するには</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">შემოწირულობა</string>
<string name="foss_upgrade_alreadydonated_label">მე უკვე შემოწირული მაქვს</string>
<string name="foss_upgrade_no_money_label">მთელ ჩემს ფულს AirPods-ებზე ვხარჯავ</string>
<string name="upgrade_foss_preamble">CAPod FOSS უფასოა და ღია წყაროა. თუ გსარგებლია, გაანაბრეთ განვითარება პროექტის გადასაგრელად.</string>
<string name="upgrade_foss_sponsor_action">სპონსორის განვითარება</string>
<string name="upgrade_foss_sponsor_subtitle">რეკლამა არა. თვალთვალება არა. Google Play-ზე დამოკიდება არა.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">მხარდამჭერი %s-დან</string>
<string name="upgrade_foss_supporter_thanks">მადლობა CAPod-ის განვითარების მხარდაჭერისთვის!</string>
<string name="upgrade_foss_sponsor_again_action">სპონსორის გვერდის გახსნა</string>
<string name="settings_upgrade_status_label">მხარი დაუჭიროთ CAPod-ს</string>
<string name="upgrade_foss_sponsor_label">მხარი დაუჭიროთ CAPod-ს</string>
<string name="settings_upgrade_status_description">თქვენი მხარდამჭერის მდგომარეობა.</string>
<string name="upgrade_screen_why_title">განახლების უპირატესობები</string>
<string name="upgrade_screen_how_title">როგორ დაგეხმაროთ</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">បរិច្ចាគ</string>
<string name="foss_upgrade_alreadydonated_label">ខ្ញុំបានបរិច្ចាគរួចហើយ</string>
<string name="foss_upgrade_no_money_label">ខ្ញុំចំណាយលុយទាំងអស់របស់ខ្ញុំលើ AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS ជាកម្មវិធីឥតគិតថ្លៃ និងប្រភពបើកចំហ។ ប្រសិនបើអ្នករកហើញថ្លៃវាមានប្រយោជន៍ សូមពិចារណាឧបត្ថម្ភការអភិវឌ្ឍន៍ ដើម្បីជួយឥ្សរិយគម្រោងបន្តដំណើរការ។</string>
<string name="upgrade_foss_sponsor_action">ឧបត្ថម្ភការអភិវឌ្ឍន៍</string>
<string name="upgrade_foss_sponsor_subtitle">គ្មានការផ្សាយពាណិជ្ជកម្ម។ គ្មានការតាមដាន។ គ្មានការចាក់សោ Google Play។</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">អ្នកគាំទ្រតាំងពី %s</string>
<string name="upgrade_foss_supporter_thanks">សូមអរគុណសម្រាប់ការគាំទ្រការអភិវឌ្ឍន៍ CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">បើកទំព័រឧបត្ថម្ភ</string>
<string name="settings_upgrade_status_label">ឧបត្ថម្ភ CAPod</string>
<string name="upgrade_foss_sponsor_label">ឧបត្ថម្ភ CAPod</string>
<string name="settings_upgrade_status_description">ស្ថានភាពអ្នកគាំទ្ររបស់អ្នក។</string>
<string name="upgrade_screen_why_title">អត្ថប្រយោជន៍លើកកម្ពស់</string>
<string name="upgrade_screen_how_title">របៀបជួយ</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Beşdar bike</string>
<string name="foss_upgrade_alreadydonated_label">Min berê bexş kir</string>
<string name="foss_upgrade_no_money_label">Ez hemû pereyê xwe li AirPods xerc dikim</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Pêşdebiriyê teref bike</string>
<string name="upgrade_foss_sponsor_subtitle">Reklam tune. Śopandin tune. Girêdana Google Play tune.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Piştgir ji %s ve</string>
<string name="upgrade_foss_supporter_thanks">Spas ji bo piştgiriya te ya pêşxistina CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Rûpela piştgiriyê veke</string>
<string name="settings_upgrade_status_label">CAPod-ê piştgirî bike</string>
<string name="upgrade_foss_sponsor_label">CAPod-ê piştgirî bike</string>
<string name="settings_upgrade_status_description">Rewşa piştgiriya te.</string>
<string name="upgrade_screen_why_title">Feydeyên Bardiyê</string>
<string name="upgrade_screen_how_title">Çawa Alîkarî Bike</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">ದಾನ ಮಾಡಿ</string>
<string name="foss_upgrade_alreadydonated_label">ನಾನು ಈಗಾಗಲೇ ದಾನ ಮಾಡಿದ್ದೇನೆ</string>
<string name="foss_upgrade_no_money_label">ನಾನು ನನ್ನ ಎಲ್ಲಾ ಹಣವನ್ನು AirPods ಗಾಗಿ ಖರ್ಚು ಮಾಡುತ್ತೇನೆ</string>
<string name="upgrade_foss_preamble">CAPod FOSS ಉಚಿತ ಮತ್ತು ತೆರೆದ ಮೂಲದ್ದಾಗಿದೆ. ನಿಮಗೆ ಉಪಯುಕ್ತವಾಗಿ ತೋರಿದರೆ, ಯೋಜನೆಯನ್ನು ಮುಂದುವರಿಸಲು ಅಭಿವೃದ್ಧಿಗೆ ಪ್ರಾಯೋಜಕತ್ವ ನೀಡಲು ಪರಿಗಣಿಸಿ.</string>
<string name="upgrade_foss_sponsor_action">ಅಭಿವೃದ್ಧಿಗೆ ಪ್ರಾಯೋಜಕತ್ವ ನೀಡಿ</string>
<string name="upgrade_foss_sponsor_subtitle">ಜಾಹೀರಾತುಗಳಿಲ್ಲ. ಭಕ್ಷಣೆ ಇಲ್ಲ. Google Play ಲಾಕ್-ಇನ್ ಇಲ್ಲ.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s ರಿಂದ ಬೆಂಬಲಿಗ</string>
<string name="upgrade_foss_supporter_thanks">CAPod ಅಭಿವೃದ್ಧಿಗೆ ಬೆಂಬಲ ನೀಡಿದ್ದಕ್ಕಾಗಿ ಧನ್ಯವಾದಗಳು!</string>
<string name="upgrade_foss_sponsor_again_action">ಪ್ರಾಯೋಜಕ ಪುಟ ತೆರೆಯಿರಿ</string>
<string name="settings_upgrade_status_label">CAPod ಬೆಂಬಲಿಸಿ</string>
<string name="upgrade_foss_sponsor_label">CAPod ಬೆಂಬಲಿಸಿ</string>
<string name="settings_upgrade_status_description">ನಿಮ್ಮ ಬೆಂಬಲಕಾರ ಸ್ಥಿತಿ.</string>
<string name="upgrade_screen_why_title">ನವೀಕರಣ ಪ್ರಯೋಜನಗಳು</string>
<string name="upgrade_screen_how_title">ಸಹಾಯ ಮಾಡುವುದು ಹೇಗೆ</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">후원하기</string>
<string name="foss_upgrade_alreadydonated_label">이미 후원했어요</string>
<string name="foss_upgrade_no_money_label">AirPods에 돈을 다 썼어요</string>
<string name="upgrade_foss_preamble">CAPod FOSS는 무료 오픈소스입니다. 유용하게 사용하고 계신다면 프로젝트 유지에 도움이 되도록 후원을 고려해 주세요.</string>
<string name="upgrade_foss_sponsor_action">개발 후원하기</string>
<string name="upgrade_foss_sponsor_subtitle">광고 없음. 추적 없음. Google Play 의존 없음.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s부터 후원자</string>
<string name="upgrade_foss_supporter_thanks">CAPod 개발을 후원해 주셔서 감사합니다!</string>
<string name="upgrade_foss_sponsor_again_action">후원 페이지 열기</string>
<string name="settings_upgrade_status_label">CAPod 후원하기</string>
<string name="upgrade_foss_sponsor_label">CAPod 후원하기</string>
<string name="settings_upgrade_status_description">후원자 상태</string>
<string name="upgrade_screen_why_title">업그레이드 혜택</string>
<string name="upgrade_screen_how_title">도움 주기</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Демөөрчүлүк кылуу</string>
<string name="foss_upgrade_alreadydonated_label">Мен буга чейин демөөрчүлүк кылгам</string>
<string name="foss_upgrade_no_money_label">Мен бүт акчамды AirPods үчүн короттум</string>
<string name="upgrade_foss_preamble">CAPod FOSS беплатан жана ачык каттулууда. Егер пайдалуу таапсаңыз, жобаны улантырууга колдоо көрсөтүңүз.</string>
<string name="upgrade_foss_sponsor_action">Иштелеп чыгууну колдоо</string>
<string name="upgrade_foss_sponsor_subtitle">Жарнама. Кадалоолоо жок. Google Playга байланма.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s\'дан бери колдоочу</string>
<string name="upgrade_foss_supporter_thanks">CAPod\'дун өнүгүшүн колдогонуңуз үчүн рахмат!</string>
<string name="upgrade_foss_sponsor_again_action">Демөөрчүлүк баракчасын ачуу</string>
<string name="settings_upgrade_status_label">CAPod-ды спонсорлоо</string>
<string name="upgrade_foss_sponsor_label">CAPod-ды спонсорлоо</string>
<string name="settings_upgrade_status_description">Колдоочу абалы</string>
<string name="upgrade_screen_why_title">Жогорулоонун пайдалары</string>
<string name="upgrade_screen_how_title">Жардам бергүүнүн жолу</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">ບໍລິຈາກ</string>
<string name="foss_upgrade_alreadydonated_label">ຂ້ອຍໄດ້ບໍລິຈາກແລ້ວ</string>
<string name="foss_upgrade_no_money_label">ຂ້ອຍໃຊ້ເງິນທັງໝົດຂອງຂ້ອຍໃສ່ AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS ແມ່ນຟຣີ ແລະ ໂອເພນຊອດ. ຖ້າທ່ານເຫັນວ່າມີປະໂຫຍດ, ພິຈາລະນາສະໜັບສະໜູນການພັດທະນາເພື່ອຊ່ວຍໃຫ້ໂຄງການດຳເນີນຕໍ່ໄປ.</string>
<string name="upgrade_foss_sponsor_action">ສະໜັບສະໜູນການພັດທະນາ</string>
<string name="upgrade_foss_sponsor_subtitle">ບໍ່ມີໂຄສະນາ. ບໍ່ມີການຕິດຕາມ. ບໍ່ຕິດ Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">ຜູ້ສະໜັບສະໜູນຕັ້ງແຕ່ %s</string>
<string name="upgrade_foss_supporter_thanks">ຂອບຊູ່ສຳລັບການສະໜັບສະໜູນການພັດທະນາຂອງ CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">ເປິດໝ້າສະໜັບສະໜູນ</string>
<string name="settings_upgrade_status_label">ສະໜັບສະໜູນ CAPod</string>
<string name="upgrade_foss_sponsor_label">ສະໜັບສະໜູນ CAPod</string>
<string name="settings_upgrade_status_description">ສະຖານະຜູ້ສະໜັບສະໜູນຂອງທ່ານ</string>
<string name="upgrade_screen_why_title">ປະໂຫຍດຂອງການອັບເກຣດ</string>
<string name="upgrade_screen_how_title">ວິທີການຊ່ວຍເຫຼືອ</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Aukoti</string>
<string name="foss_upgrade_alreadydonated_label">Aš jau paaukojau</string>
<string name="foss_upgrade_no_money_label">Visus savo pinigus išleidžiu „AirPods“ ausinėms</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Remti plėtrą</string>
<string name="upgrade_foss_sponsor_subtitle">Jokių reklamų. Jokio sekimo. Jokio „Google Play“ priklausomybės.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Rėmėjas nuo %s</string>
<string name="upgrade_foss_supporter_thanks">Ačiū, kad remiate CAPod kūrimą!</string>
<string name="upgrade_foss_sponsor_again_action">Atidaryti rėmėjo puslapį</string>
<string name="settings_upgrade_status_label">Remti CAPod</string>
<string name="upgrade_foss_sponsor_label">Remti CAPod</string>
<string name="settings_upgrade_status_description">Jūsų rėmėjo statusas.</string>
<string name="upgrade_screen_why_title">Atnaujinimo privalumai</string>
<string name="upgrade_screen_how_title">Kaip padėti</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Ziedot</string>
<string name="foss_upgrade_alreadydonated_label">Es jau ziedoju</string>
<string name="foss_upgrade_no_money_label">Es iztērēju visu savu naudu AirPods austiņām</string>
<string name="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>
<string name="upgrade_foss_sponsor_action">Sponsorēt attīstību</string>
<string name="upgrade_foss_sponsor_subtitle">Bez reklamām. Bez izsekošanas. Bez Google Play saistībām.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Atbalstītājs kopš %s</string>
<string name="upgrade_foss_supporter_thanks">Paldies, ka atbalsti CAPod izstrādi!</string>
<string name="upgrade_foss_sponsor_again_action">Atvērt sponsorēšanas lapu</string>
<string name="settings_upgrade_status_label">Atbalstīt CAPod</string>
<string name="upgrade_foss_sponsor_label">Atbalstīt CAPod</string>
<string name="settings_upgrade_status_description">Jūsu atbalstītāja statuss.</string>
<string name="upgrade_screen_why_title">Jaunināšanas priekšrocības</string>
<string name="upgrade_screen_how_title">Kā palīdzēt</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Донирај</string>
<string name="foss_upgrade_alreadydonated_label">Веќе донирав</string>
<string name="foss_upgrade_no_money_label">Ги трошам сите пари на AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS е бесплатен и отворен код. Ако ви е корисен, размислете да го спонзорирате развојот за да помогнете проектот да продолжи.</string>
<string name="upgrade_foss_sponsor_action">Спонзорирај развој</string>
<string name="upgrade_foss_sponsor_subtitle">Без реклами. Без пратење. Без зависност од Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Поддржувач од %s</string>
<string name="upgrade_foss_supporter_thanks">Ти благодариме што го поддржуваш развојот на CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Отвори ја страницата за спонзорство</string>
<string name="settings_upgrade_status_label">Поддржи CAPod</string>
<string name="upgrade_foss_sponsor_label">Поддржи CAPod</string>
<string name="settings_upgrade_status_description">Твојот статус на поддржувач.</string>
<string name="upgrade_screen_why_title">Предности на надградување</string>
<string name="upgrade_screen_how_title">Како да помогнете</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">സംഭാവന ചെയ്യുക</string>
<string name="foss_upgrade_alreadydonated_label">ഞാൻ ഇതിനകം സംഭാവന ചെയ്തു</string>
<string name="foss_upgrade_no_money_label">ഞാൻ എൻ്റെ എല്ലാ പണവും AirPods-നായി ചെലവഴിക്കുന്നു</string>
<string name="upgrade_foss_preamble">CAPod FOSS സൗജന്യവും ഓപ്പൻ സോഴ്സുമാണ്. നിങ്ങൾക്ക് ഇത് ഉപകാരപ്രദമാണെങ്കിൽ, പ്രോജെക്ട് തുടരാൻ സഹായിക്കുന്നതിന് ഡെവലപ്പ്മെന്റ് സ്പോൺസർ ചെയ്യുന്നത് പരിഗണിക്കുക.</string>
<string name="upgrade_foss_sponsor_action">വികസനം സ്പോൺസർ ചെയ്യുക</string>
<string name="upgrade_foss_sponsor_subtitle">പരസ്യങ്ങൾ ഇല്ല. ട്രാക്കിംഗ് ഇല്ല. Google Play ലോക്ക്-ഇൻ ഇല്ല.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s മുതൽ പിന്തുണക്കാൻ</string>
<string name="upgrade_foss_supporter_thanks">CAPod-ന്റെ വികസനത്തെ പിന്തുണച്ചതിന് നന്ദി!</string>
<string name="upgrade_foss_sponsor_again_action">സ്പോൺസർ പേജ് തുറക്കുക</string>
<string name="settings_upgrade_status_label">CAPod നെ സ്പോൺസർ ചെയ്യുക</string>
<string name="upgrade_foss_sponsor_label">CAPod നെ സ്പോൺസർ ചെയ്യുക</string>
<string name="settings_upgrade_status_description">നിങ്ങളുടെ പിന്തുണയ സ്ഥിതി.</string>
<string name="upgrade_screen_why_title">അപ്‌ഗ്രേഡ് നേട്ടങ്ങൾ</string>
<string name="upgrade_screen_how_title">എങ്ങനെ സഹായിക്കാം</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Хандивлах</string>
<string name="foss_upgrade_alreadydonated_label">Би аль хэдийн хандивласан</string>
<string name="foss_upgrade_no_money_label">Би бүх мөнгөө AirPods-д зарцуулдаг</string>
<string name="upgrade_foss_preamble">CAPod FOSS нь үнэгүй, нээлтэй эхийн програм. Хэрэв танд хэрэгтэй бол төслийг үргэлжүүлэхэд туслахын тулд хөгжүүлэлтийг дэмжих талаар бодоорой.</string>
<string name="upgrade_foss_sponsor_action">Хөгжлийг дэмжих</string>
<string name="upgrade_foss_sponsor_subtitle">Реклам байхгүй. Хяналт байхгүй. Google Play-д хамааралгүй.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s-с хойш дэмжигч</string>
<string name="upgrade_foss_supporter_thanks">CAPod-ын хөгжлийг дэмжсэнд баярлалаа!</string>
<string name="upgrade_foss_sponsor_again_action">Дэмжигчийн хуудсыг нээх</string>
<string name="settings_upgrade_status_label">CAPod-ыг спонсорлох</string>
<string name="upgrade_foss_sponsor_label">CAPod-ыг спонсорлох</string>
<string name="settings_upgrade_status_description">Таны дэмжигч статус.</string>
<string name="upgrade_screen_why_title">Давуу талууд</string>
<string name="upgrade_screen_how_title">Хэрхэн тусламж үзүүлэх</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">दान करा</string>
<string name="foss_upgrade_alreadydonated_label">मी आधीच दान केले आहे</string>
<string name="foss_upgrade_no_money_label">मी माझे सर्व पैसे AirPods वर खर्च करतो</string>
<string name="upgrade_foss_preamble">CAPod FOSS विनामूल्य आणि ओपन सोर्स आहे. जर तुम्हाला ते उपयुक्त वाटत असेल, तर प्रकल्प सुरू ठेवण्यासाठी विकासास प्रायोजित करण्याचा विचार करा.</string>
<string name="upgrade_foss_sponsor_action">विकासास प्रायोजित करा</string>
<string name="upgrade_foss_sponsor_subtitle">कोणत्याही जाहिराती नाहीत. कोणतेही ट्रॅकिंग नाही. Google Play चे बंधन नाही.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s पासून समर्थक</string>
<string name="upgrade_foss_supporter_thanks">CAPod च्या विकासाला पाठिंबा दिल्याबद्दल धन्यवाद!</string>
<string name="upgrade_foss_sponsor_again_action">प्रायोजक पृष्ठ उघडा</string>
<string name="settings_upgrade_status_label">CAPod ला प्रायोजित करा</string>
<string name="upgrade_foss_sponsor_label">CAPod ला प्रायोजित करा</string>
<string name="settings_upgrade_status_description">आपल्या समर्थक स्थिती.</string>
<string name="upgrade_screen_why_title">अपग्रेड लाभ</string>
<string name="upgrade_screen_how_title">मदत कसे करायची</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Sumbang</string>
<string name="foss_upgrade_alreadydonated_label">Saya telah menyumbang</string>
<string name="foss_upgrade_no_money_label">Saya belanjakan semua wang saya untuk AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS adalah percuma dan sumber terbuka. Jika anda mendapatinya berguna, pertimbangkan untuk menaja pembangunannya bagi membantu projek ini terus berjalan.</string>
<string name="upgrade_foss_sponsor_action">Pembangunan penaja</string>
<string name="upgrade_foss_sponsor_subtitle">Tiada iklan. Tiada penjejakan. Tiada ikatan Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Penyokong sejak %s</string>
<string name="upgrade_foss_supporter_thanks">Terima kasih kerana menyokong pembangunan CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Buka halaman penaja</string>
<string name="settings_upgrade_status_label">Menajakan CAPod</string>
<string name="upgrade_foss_sponsor_label">Menajakan CAPod</string>
<string name="settings_upgrade_status_description">Status penyokong anda.</string>
<string name="upgrade_screen_why_title">Manfaat peningkatan</string>
<string name="upgrade_screen_how_title">Bagaimana untuk membantu</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">လှူဒါန်းပါ</string>
<string name="foss_upgrade_alreadydonated_label">ကျွန်တော် လှူပြီးပါပြီ</string>
<string name="foss_upgrade_no_money_label">ကျွန်တော် ပိုက်ဆံအားလုံးကို AirPods မှာ သုံးတယ်</string>
<string name="upgrade_foss_preamble">CAPod FOSS သည် အခမဲ့နှင့် open source ဖြစ်သည်။ အသုံးပြုခုပ်ဆည်းနိုင် အသားဖြစ်ပါက ပရာဗက်ဆက်လက်ရန် ဖွဲ့ဖြီးရေးကို ပံ့ပိုးပေးသည်။</string>
<string name="upgrade_foss_sponsor_action">ဖွံ့ဖြိုးတိုးတက်မှုကို ကမကထပံ့ပိုးရန်</string>
<string name="upgrade_foss_sponsor_subtitle">ကြောင်ညာမရှိ။ ခြေရာမခံ။ Google Play ချုပ်ချယ်မြုမရှိ။</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s မှစ၍ ပံ့ပိုးသူ</string>
<string name="upgrade_foss_supporter_thanks">CAPod ၏ ဖွံ့ဖြိုးတိုးတက်မှုကို ပံ့ပိုးပေးသည့်အတွက် ကျေးဇူးတင်ပါသည်!</string>
<string name="upgrade_foss_sponsor_again_action">ပံ့ပိုးသူစာမျက်နှာကို ဖွင့်ပါ</string>
<string name="settings_upgrade_status_label">CAPod ကို ပံ့ပိုးပါ</string>
<string name="upgrade_foss_sponsor_label">CAPod ကို ပံ့ပိုးပါ</string>
<string name="settings_upgrade_status_description">သင်၏ ပံ့ပိုးသူ အခြေအနေ။</string>
<string name="upgrade_screen_why_title">အဆင့်မြှင့်တင်မှု အကျိုးအကျေးမများ</string>
<string name="upgrade_screen_how_title">ကူညီနည်းလမ်း</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Doner</string>
<string name="foss_upgrade_alreadydonated_label">Jeg har allerede donert</string>
<string name="foss_upgrade_no_money_label">Jeg bruker alle pengene mine på AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS er gratis og åpen kildekode. Hvis du synes den er nyttig, kan du vurdere å sponse utviklingen for å bidra til at prosjektet går videre.</string>
<string name="upgrade_foss_sponsor_action">Støtt utviklingen</string>
<string name="upgrade_foss_sponsor_subtitle">Ingen reklame. Ingen sporing. Ingen Google Play-avhengighet.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Støttespiller siden %s</string>
<string name="upgrade_foss_supporter_thanks">Takk for at du støtter utviklingen av CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Åpne sponsorside</string>
<string name="settings_upgrade_status_label">Støtt CAPod</string>
<string name="upgrade_foss_sponsor_label">Støtt CAPod</string>
<string name="settings_upgrade_status_description">Din status som støtter.</string>
<string name="upgrade_screen_why_title">Oppgraderingsfordeler</string>
<string name="upgrade_screen_how_title">Hvordan hjelpe</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">दान गर्नुहोस्</string>
<string name="foss_upgrade_alreadydonated_label">मैले पहिले नै दान गरिसकेको छु</string>
<string name="foss_upgrade_no_money_label">म आफ्नो सबै पैसा AirPods मा खर्च गर्छु</string>
<string name="upgrade_foss_preamble">CAPod FOSS निःशुल्क र खुला स्रोत छ। यदि तपाईंले उपयोगी पाउनुभएको छ भने, प्राजेक्ट चलाउन राख्न विकासमा प्रायोजन गर्न विचार गर्नुहोस्।</string>
<string name="upgrade_foss_sponsor_action">विकासलाई प्रायोजन गर्नुहोस्</string>
<string name="upgrade_foss_sponsor_subtitle">कुनै विज्ञापन छैन। कुनै ट्रैकिङ छैन। Google Playमा बाँधिएको छैन।</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s देखि समर्थक</string>
<string name="upgrade_foss_supporter_thanks">CAPod को विकासलाई समर्थन गर्नुभएकोमा धन्यवाद!</string>
<string name="upgrade_foss_sponsor_again_action">प्रायोजक पृष्ठ खोल्नुहोस्</string>
<string name="settings_upgrade_status_label">CAPod को प्रायोजन गर्नुहोस्</string>
<string name="upgrade_foss_sponsor_label">CAPod को प्रायोजन गर्नुहोस्</string>
<string name="settings_upgrade_status_description">आफ्नो समर्थक स्थिति।</string>
<string name="upgrade_screen_why_title">अपग्रेड लाभहरु</string>
<string name="upgrade_screen_how_title">कसरी मद्दत गर्ने</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Doneren</string>
<string name="foss_upgrade_alreadydonated_label">Ik heb al gedoneerd</string>
<string name="foss_upgrade_no_money_label">Ik besteed al mijn geld aan AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS is gratis en open source. Als je het nuttig vindt, overweeg dan om de ontwikkeling te sponsoren om het project gaande te houden.</string>
<string name="upgrade_foss_sponsor_action">Sponsor ontwikkeling</string>
<string name="upgrade_foss_sponsor_subtitle">Geen advertenties. Geen tracking. Geen Google Play-vergrendeling.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Supporter sinds %s</string>
<string name="upgrade_foss_supporter_thanks">Bedankt voor je steun aan de ontwikkeling van CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Open de sponsorpagina</string>
<string name="settings_upgrade_status_label">Sponsor CAPod</string>
<string name="upgrade_foss_sponsor_label">Sponsor CAPod</string>
<string name="settings_upgrade_status_description">Je ondersteunersstatus.</string>
<string name="upgrade_screen_why_title">Voordelen van de upgrade</string>
<string name="upgrade_screen_how_title">Hoe kun je helpen</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Wesprzyj</string>
<string name="foss_upgrade_alreadydonated_label">Już wsparłem</string>
<string name="foss_upgrade_no_money_label">Wydałem wszystkie pieniądze na AirPods</string>
<string name="upgrade_foss_preamble">CAPod Foss jest bezpłatny i otwarto-źródłowy. Jeśli uważasz go za użyteczny, rozważ sponsorowanie rozwoju, aby pomóc kontynuować projekt.</string>
<string name="upgrade_foss_sponsor_action">Wesprzyj rozwój</string>
<string name="upgrade_foss_sponsor_subtitle">Bez reklam. Bez śledzenia. Bez uzależnienia od Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Wspierający od %s</string>
<string name="upgrade_foss_supporter_thanks">Dziękuję za wsparcie rozwoju CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Otwórz stronę wspierającego</string>
<string name="settings_upgrade_status_label">Sponsoruj CAPod</string>
<string name="upgrade_foss_sponsor_label">Sponsoruj CAPod</string>
<string name="settings_upgrade_status_description">Status twojego wsparcia.</string>
<string name="upgrade_screen_why_title">Korzyści z uaktualnienia</string>
<string name="upgrade_screen_how_title">Jak pomóc</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Doar</string>
<string name="foss_upgrade_alreadydonated_label">Eu já doei</string>
<string name="foss_upgrade_no_money_label">Eu gasto todo o meu dinheiro em AirPods</string>
<string name="upgrade_foss_preamble">O CAPod FOSS é gratuito e de código aberto. Se você o achar útil, considere patrocinar o desenvolvimento para ajudar a manter o projeto ativo.</string>
<string name="upgrade_foss_sponsor_action">Patrocinador do desenvolvimento</string>
<string name="upgrade_foss_sponsor_subtitle">Sem anúncios. Sem rastreamento. Sem dependência do Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Apoiador desde %s</string>
<string name="upgrade_foss_supporter_thanks">Obrigado por apoiar o desenvolvimento do CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Abrir página de patrocínio</string>
<string name="settings_upgrade_status_label">Patrocine o CAPod</string>
<string name="upgrade_foss_sponsor_label">Patrocine o CAPod</string>
<string name="settings_upgrade_status_description">Seu status de apoiador.</string>
<string name="upgrade_screen_why_title">Benefícios da atualização</string>
<string name="upgrade_screen_how_title">Como ajudar</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Doar</string>
<string name="foss_upgrade_alreadydonated_label">Já doei</string>
<string name="foss_upgrade_no_money_label">Gastei todo o meu dinheiro em AirPods</string>
<string name="upgrade_foss_preamble">O CAPod FOSS é gratuito e de código aberto. Se o achar útil, considere patrocinar o desenvolvimento para ajudar a manter o projeto ativo.</string>
<string name="upgrade_foss_sponsor_action">Patrocine o desenvolvimento</string>
<string name="upgrade_foss_sponsor_subtitle">Sem anúncios. Sem rastreamento. Sem dependência do Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Apoiante desde %s</string>
<string name="upgrade_foss_supporter_thanks">Obrigado por apoiares o desenvolvimento do CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Abrir página de patrocínio</string>
<string name="settings_upgrade_status_label">Apoie o CAPod</string>
<string name="upgrade_foss_sponsor_label">Apoie o CAPod</string>
<string name="settings_upgrade_status_description">O seu estado de apoiante.</string>
<string name="upgrade_screen_why_title">Benefícios da atualização</string>
<string name="upgrade_screen_how_title">Como ajudar</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Donar</string>
<string name="foss_upgrade_alreadydonated_label">Hai gia donat</string>
<string name="foss_upgrade_no_money_label">Mett tut mes daners en AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS è gratuit ed open source. Sche ti chattast ch\'el è util, considerescha da sponsurisar il sviluppament per agidà a mantegnair il project.</string>
<string name="upgrade_foss_sponsor_action">Sponsurisar il sviluppament</string>
<string name="upgrade_foss_sponsor_subtitle">Nagins annunzis. Nagin tracking. Nagin blocadi da Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Sustegnider dapi %s</string>
<string name="upgrade_foss_supporter_thanks">Grazia per sustegnair il svilup da CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Avrir la pagina da sponsurisaziun</string>
<string name="settings_upgrade_status_label">Sponsurar CAPod</string>
<string name="upgrade_foss_sponsor_label">Sponsurar CAPod</string>
<string name="settings_upgrade_status_description">Tes status da supporter.</string>
<string name="upgrade_screen_why_title">Bials da l\'upgrade</string>
<string name="upgrade_screen_how_title">Sco gidar</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Donează</string>
<string name="foss_upgrade_alreadydonated_label">Am donat deja</string>
<string name="foss_upgrade_no_money_label">Mi-am cheltuit toți banii pe AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS este gratuit si open source. Daca il gasiti util, luati in considerare sponsorizarea dezvoltarii pentru a ajuta la continuarea proiectului.</string>
<string name="upgrade_foss_sponsor_action">Sponsorizează dezvoltarea</string>
<string name="upgrade_foss_sponsor_subtitle">Fara reclame. Fara urmarire. Fara dependenta de Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Susținător din %s</string>
<string name="upgrade_foss_supporter_thanks">Mulțumim că sprijini dezvoltarea CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Deschide pagina de sponsorizare</string>
<string name="settings_upgrade_status_label">Sponsorizează CAPod</string>
<string name="upgrade_foss_sponsor_label">Sponsorizează CAPod</string>
<string name="settings_upgrade_status_description">Statusul tău de susținător.</string>
<string name="upgrade_screen_why_title">Beneficiile upgrade-ului</string>
<string name="upgrade_screen_how_title">Cum să ajuți</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Пожертвовать</string>
<string name="foss_upgrade_alreadydonated_label">Я уже пожертвовал</string>
<string name="foss_upgrade_no_money_label">Я потратил все свои деньги на AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS бесплатный и с открытым исходным кодом. Если Вам полезно приложение, рассмотрите возможность спонсировать разработку, чтобы помочь проекту продолжать развиваться.</string>
<string name="upgrade_foss_sponsor_action">Поддержать разработку</string>
<string name="upgrade_foss_sponsor_subtitle">Без рекламы. Без слежки. Без привязки к Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Спонсор с %s</string>
<string name="upgrade_foss_supporter_thanks">Спасибо за поддержку разработки CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Открыть страницу спонсорства</string>
<string name="settings_upgrade_status_label">Поддержать CAPod</string>
<string name="upgrade_foss_sponsor_label">Поддержать CAPod</string>
<string name="settings_upgrade_status_description">Статус Вашей поддержки.</string>
<string name="upgrade_screen_why_title">Преимущества обновления</string>
<string name="upgrade_screen_how_title">Как помочь</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Dona</string>
<string name="foss_upgrade_alreadydonated_label">Apo giai donadu</string>
<string name="foss_upgrade_no_money_label">Apo spèndidu totu is soddos meos pro is AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS est gratis e fonte aberta. Si test de agiudu, considera de patrotzinare su isviluppu pro agiudare a mantennere su progetu.</string>
<string name="upgrade_foss_sponsor_action">Sponsoriza s\'isvilupu</string>
<string name="upgrade_foss_sponsor_subtitle">Peroe publicidade. Peroe trachiamentu. Peroe dipendentzia de Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Sustentadore dae su %s</string>
<string name="upgrade_foss_supporter_thanks">Gràtzias pro sustènnere s\'isvilupu de CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Aberi sa pàgina de sponsorizatzione</string>
<string name="settings_upgrade_status_label">Sostene CAPod</string>
<string name="upgrade_foss_sponsor_label">Sostene CAPod</string>
<string name="settings_upgrade_status_description">S\'istadu tuo de sustentadore.</string>
<string name="upgrade_screen_why_title">Benefitzios de s\'agiornamentu</string>
<string name="upgrade_screen_how_title">Comente agiudare</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">පරිත්‍යාග</string>
<string name="foss_upgrade_alreadydonated_label">මම දැනටමත් පරිත්‍යාග කර ඇත</string>
<string name="foss_upgrade_no_money_label">මම මගේ මුදල් සියල්ල AirPods සඳහා වියදම් කරමි</string>
<string name="upgrade_foss_preamble">CAPod FOSS නියමිත සහ විවෘත මූලාශ්‍රයි. ඔබට ඉත උපකාරී නම්, ආයෝජනය ගිණුම්‍ සහය ගෙවිපීම කිරීමට අර්වමයක් ගෙවිපීම් ප්‍රස්තා කරන්නට සංවර්ධන කරන්න.</string>
<string name="upgrade_foss_sponsor_action">සංවර්ධනයට අනුග්‍රහය දෙන්න</string>
<string name="upgrade_foss_sponsor_subtitle">අළු නැත. ට්‍රැකිං නැත. Google Play සලක඾ත්වය නැත.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s සිට අනුග්‍රාහකයෙකි</string>
<string name="upgrade_foss_supporter_thanks">CAPod හි සංවර්ධනයට සහාය දැක්වූ ඔබට ස්තූතියි!</string>
<string name="upgrade_foss_sponsor_again_action">අනුග්‍රාහක පිටුව විවෘත කරන්න</string>
<string name="settings_upgrade_status_label">CAPod සඳහා අනුග්‍රහක්</string>
<string name="upgrade_foss_sponsor_label">CAPod සඳහා අනුග්‍රහක්</string>
<string name="settings_upgrade_status_description">ඔබේ සහයෝගිතා තත්‍වය.</string>
<string name="upgrade_screen_why_title">උඩ්ග්‍රේඩ් ප්‍රතිලාභ</string>
<string name="upgrade_screen_how_title">උදව් කරන්නේ කෙසේද</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Prispieť</string>
<string name="foss_upgrade_alreadydonated_label">Už som prispel/a</string>
<string name="foss_upgrade_no_money_label">Všetky peniaze som minul na AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS je zadarmo a open source. Ak ho nájdete užitočným, zvažte sponzorovanie vývoja, aby ste pomohli udržiavať projekt v chode.</string>
<string name="upgrade_foss_sponsor_action">Sponzorovaný vývoj</string>
<string name="upgrade_foss_sponsor_subtitle">Žiadne reklamy. Žiadne sledovanie. Žiadna závislosť od Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Podporovateľ od %s</string>
<string name="upgrade_foss_supporter_thanks">Ďakujeme, že podporujete vývoj CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Otvoriť stránku sponzorstva</string>
<string name="settings_upgrade_status_label">Podporiť CAPod</string>
<string name="upgrade_foss_sponsor_label">Podporiť CAPod</string>
<string name="settings_upgrade_status_description">Váš status podporovateľa.</string>
<string name="upgrade_screen_why_title">Výhody upgradu</string>
<string name="upgrade_screen_how_title">Ako pomôcť</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Doniraj</string>
<string name="foss_upgrade_alreadydonated_label">Sem že doniral</string>
<string name="foss_upgrade_no_money_label">Ves svoj denar porabim za AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS je brezplačen in odprtokoden. Če se vam zdi koristen, razmislite o sponzoriranju razvoja, da pomagate ohranjati projekt.</string>
<string name="upgrade_foss_sponsor_action">Podpri razvoj</string>
<string name="upgrade_foss_sponsor_subtitle">Brez oglasov. Brez sledenja. Brez vezave na Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Podpornik od %s</string>
<string name="upgrade_foss_supporter_thanks">Hvala, ker podpirate razvoj CAPod-a!</string>
<string name="upgrade_foss_sponsor_again_action">Odpri stran za sponzorstvo</string>
<string name="settings_upgrade_status_label">Podpri CAPod</string>
<string name="upgrade_foss_sponsor_label">Podpri CAPod</string>
<string name="settings_upgrade_status_description">Tvoj status podpornika.</string>
<string name="upgrade_screen_why_title">Prednosti nadgradnje</string>
<string name="upgrade_screen_how_title">Kako pomagati.</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Dhuro</string>
<string name="foss_upgrade_alreadydonated_label">Unë tashmë kam dhuruar</string>
<string name="foss_upgrade_no_money_label">Unë i shpenzoj të gjitha paratë e mia për AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS është falas dhe me burim të hapur. Nëse e gjeni të dobishmëm, konsideroni sponsorizimin e zhvillimit për ta mbajtur projektin gjallë.</string>
<string name="upgrade_foss_sponsor_action">Zhvillimi i sponsorëve</string>
<string name="upgrade_foss_sponsor_subtitle">Pa reklama. Pa gjurmim. Pa varësi nga Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Mbështetës që nga %s</string>
<string name="upgrade_foss_supporter_thanks">Faleminderit për mbështetjen e zhvillimit të CAPod-it!</string>
<string name="upgrade_foss_sponsor_again_action">Hap faqen e sponsorizimit</string>
<string name="settings_upgrade_status_label">Mbështesni CAPod</string>
<string name="upgrade_foss_sponsor_label">Mbështesni CAPod</string>
<string name="settings_upgrade_status_description">Statusi juaj si mbështetës.</string>
<string name="upgrade_screen_why_title">Përfitime të përmirësimit</string>
<string name="upgrade_screen_how_title">Si të ndihmoj</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Донације</string>
<string name="foss_upgrade_alreadydonated_label">Већ сам донирао/ла</string>
<string name="foss_upgrade_no_money_label">Сав новац трошим на АирПодс</string>
<string name="upgrade_foss_preamble">CAPod FOSS је бесплатан и отвореног кода. Ако вам је користан, размотрите спонзорисање развоја да би пројекат остао жив.</string>
<string name="upgrade_foss_sponsor_action">Спонзориши развој</string>
<string name="upgrade_foss_sponsor_subtitle">Без реклама. Без праћења. Без зависности од Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Подржавалац од %s</string>
<string name="upgrade_foss_supporter_thanks">Хвала вам што подржавате развој CAPod-а!</string>
<string name="upgrade_foss_sponsor_again_action">Отворите страницу за спонзорство</string>
<string name="settings_upgrade_status_label">Подржите CAPod</string>
<string name="upgrade_foss_sponsor_label">Подржите CAPod</string>
<string name="settings_upgrade_status_description">Статус вашег подржавања.</string>
<string name="upgrade_screen_why_title">Предности надградње</string>
<string name="upgrade_screen_how_title">Како помоћи</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Donera</string>
<string name="foss_upgrade_alreadydonated_label">Jag har redan donerat</string>
<string name="foss_upgrade_no_money_label">Jag spenderar alla mina pengar på AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS är gratis och öppen källkod. Om du tycker att det är användbart kan du överväga att sponsra utvecklingen för att hjälpa till att hålla projektet vid liv.</string>
<string name="upgrade_foss_sponsor_action">Sponsra utvecklingen</string>
<string name="upgrade_foss_sponsor_subtitle">Inga annonser. Ingen spårning. Ingen bindning till Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Supporter sedan %s</string>
<string name="upgrade_foss_supporter_thanks">Tack för att du stödjer utvecklingen av CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Öppna sponsorsidan</string>
<string name="settings_upgrade_status_label">Sponsra CAPod</string>
<string name="upgrade_foss_sponsor_label">Sponsra CAPod</string>
<string name="settings_upgrade_status_description">Din supporterstatus.</string>
<string name="upgrade_screen_why_title">Uppgraderingsfördelar</string>
<string name="upgrade_screen_how_title">Hur du kan hjälpa</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Changia</string>
<string name="foss_upgrade_alreadydonated_label">Nimeshachangia tayari</string>
<string name="foss_upgrade_no_money_label">Natumia pesa zangu zote kwenye AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS ni bure na chanzo huria. Ikiwa unaikuta ni muhimu, fikiria kuchangia maendeleo ili kusaidia mradi kuendelea.</string>
<string name="upgrade_foss_sponsor_action">Faidhi maendeleo</string>
<string name="upgrade_foss_sponsor_subtitle">Hakuna matangazo. Hakuna ufuatiliaji. Hakuna kufungwa kwa Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Msaidizi tangu %s</string>
<string name="upgrade_foss_supporter_thanks">Asante kwa kuunga mkono maendeleo ya CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Fungua ukurasa wa udhamini</string>
<string name="settings_upgrade_status_label">Kuunga mkono CAPod</string>
<string name="upgrade_foss_sponsor_label">Kuunga mkono CAPod</string>
<string name="settings_upgrade_status_description">Hali yako ya kuunga mkono.</string>
<string name="upgrade_screen_why_title">Faida za kuboreshwa</string>
<string name="upgrade_screen_how_title">Jinsi ya kusaidia</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">நன்கொடை</string>
<string name="foss_upgrade_alreadydonated_label">நான் ஏற்கனவே நன்கொடை அளித்துவிட்டேன்</string>
<string name="foss_upgrade_no_money_label">எனது பணத்தை எல்லாம் ஏர்பாட்களில் செலவிடுகிறேன்</string>
<string name="upgrade_foss_preamble">CAPod FOSS இலவசமாகவும் திறழ் மூலக்கோடும் உள்ளது. இது பயனுள்ளதாக கணித்தால், தொடர்பு பொறுத்துக்க அம்சத்தை நிச்சயிக்கவும்.</string>
<string name="upgrade_foss_sponsor_action">மேம்பாட்டை ஸ்பான்சர் செய்யுங்கள்</string>
<string name="upgrade_foss_sponsor_subtitle">ஐவிளம்பரம் இல்லை. கண்காணிப்பு இல்லை. Google Play கட்டுப்பாட்டு இல்லை.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s முதல் ஆதரவாளர்</string>
<string name="upgrade_foss_supporter_thanks">CAPod-இன் மேம்பாட்டை ஆதரித்ததற்கு நன்றி!</string>
<string name="upgrade_foss_sponsor_again_action">ஸ்பான்சர் பக்கத்தைத் திற</string>
<string name="settings_upgrade_status_label">CAPod ஐ ஆதரிக்கவும்</string>
<string name="upgrade_foss_sponsor_label">CAPod ஐ ஆதரிக்கவும்</string>
<string name="settings_upgrade_status_description">உங்கள் ஆதரவாளர் நிலை.</string>
<string name="upgrade_screen_why_title">மேம்படுத்தல் நன்மைகள்</string>
<string name="upgrade_screen_how_title">எப்படி உதவுவது</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">విరాళం ఇవ్వండి</string>
<string name="foss_upgrade_alreadydonated_label">నేను ఇప్పటికే విరాళం ఇచ్చాను</string>
<string name="foss_upgrade_no_money_label">నా డబ్బు అంతా ఎయిర్‌పాడ్స్‌కే ఖర్చు చేస్తాను</string>
<string name="upgrade_foss_preamble">CAPod FOSS ఉచిత మరియు ముక్త మూలం. ఇది ఉపయోగకరంగా అనిపిస్తే, ప్రాజెక్ట్ కొనసాగించడానికి అభివృద్ధిని స్పాన్సర్ చేయబక్తా పరిగణించండి.</string>
<string name="upgrade_foss_sponsor_action">అభివృద్ధిని స్పాన్సర్ చేయండి</string>
<string name="upgrade_foss_sponsor_subtitle">జాహీరాతులు లేవు. ట్రాకింగ్ లేదు. Google Play ఒక్కటికి పరిమితం కాదు.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s నుండి సపోర్టర్</string>
<string name="upgrade_foss_supporter_thanks">CAPod అభివృద్ధికి మద్దతు ఇచ్చినందుకు ధన్యవాదాలు!</string>
<string name="upgrade_foss_sponsor_again_action">స్పాన్సర్ పేజీని తెరవండి</string>
<string name="settings_upgrade_status_label">CAPodకు స్పాన్సర్ చేయండి</string>
<string name="upgrade_foss_sponsor_label">CAPodకు స్పాన్సర్ చేయండి</string>
<string name="settings_upgrade_status_description">మీ సపోర్టర్ స్థితి.</string>
<string name="upgrade_screen_why_title">అప్‌గ్రేడ్ ప్రయోజనాలు</string>
<string name="upgrade_screen_how_title">ఎలా సహాయం చేయాలి</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">บริจาค</string>
<string name="foss_upgrade_alreadydonated_label">ฉันบริจาคแล้ว</string>
<string name="foss_upgrade_no_money_label">ฉันใช้เงินทั้งหมดไปกับ AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS เป็นฟรีและโอเพนซอร์ส หากคุณเห็นว่ามีประโยชน์ ลองสนับสนุนการพัฒนาเพื่อช่วยให้โครงการดำเนินต่อไป</string>
<string name="upgrade_foss_sponsor_action">สปอนเซอร์การพัฒนา</string>
<string name="upgrade_foss_sponsor_subtitle">ไม่มีโฆษณา ไม่ติดตาม ไม่ผูกขาด Google Play</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">ผู้สนับสนุนตั้งแต่ %s</string>
<string name="upgrade_foss_supporter_thanks">ขอบคุณที่สนับสนุนการพัฒนา CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">เปิดหน้าสนับสนุน</string>
<string name="settings_upgrade_status_label">สนับสนุน CAPod</string>
<string name="upgrade_foss_sponsor_label">สนับสนุน CAPod</string>
<string name="settings_upgrade_status_description">สถานะผู้สนับสนุนของคุณ</string>
<string name="upgrade_screen_why_title">ประโยชน์ของการอัปเกรด</string>
<string name="upgrade_screen_how_title">วิธีการช่วยเหลือ</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Bağış yap</string>
<string name="foss_upgrade_alreadydonated_label">Zaten bağış yaptım</string>
<string name="foss_upgrade_no_money_label">Tüm paramı AirPods\'a harcıyorum</string>
<string name="upgrade_foss_preamble">CAPod FOSS ücretsiz ve açık kaynaklıdır. Eğer faydalı bulduysan, projenin devam edebilmesi için lütfen geliştirmeye destek olmayı düşün.</string>
<string name="upgrade_foss_sponsor_action">Geliştirmeyi destekleyin</string>
<string name="upgrade_foss_sponsor_subtitle">Reklam yok. Takip yok. Google Play sınırlaması yok.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s tarihinden beri destekçi</string>
<string name="upgrade_foss_supporter_thanks">CAPod\'un geliştirilmesini desteklediğin için teşekkürler!</string>
<string name="upgrade_foss_sponsor_again_action">Sponsor sayfasını</string>
<string name="settings_upgrade_status_label">CAPod\'u destekle</string>
<string name="upgrade_foss_sponsor_label">CAPod\'u destekle</string>
<string name="settings_upgrade_status_description">Destekçi durumun.</string>
<string name="upgrade_screen_why_title">Yükseltme Avantajları</string>
<string name="upgrade_screen_how_title">Nasıl yardım edilir</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Пожертва</string>
<string name="foss_upgrade_alreadydonated_label">Вже пожертвував</string>
<string name="foss_upgrade_no_money_label">Витратив усі гроші на AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS є безкоштовним та відкритим програмним забезпеченням. Якщо він вам корисний, розгляньте можливість спонсорування розробки, щоб допомогти проєкту продовжувати роботу.</string>
<string name="upgrade_foss_sponsor_action">Розвиток спонсорів</string>
<string name="upgrade_foss_sponsor_subtitle">Без реклами. Без стеження. Без прив\'язки до Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Підтримує з %s</string>
<string name="upgrade_foss_supporter_thanks">Дякуємо за підтримку розробки CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Відкрити сторінку спонсорства</string>
<string name="settings_upgrade_status_label">Спонсорувати CAPod</string>
<string name="upgrade_foss_sponsor_label">Спонсорувати CAPod</string>
<string name="settings_upgrade_status_description">Ваш статус прихильника.</string>
<string name="upgrade_screen_why_title">Переваги оновлення</string>
<string name="upgrade_screen_how_title">Як допомогти</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">عطیہ کریں۔</string>
<string name="foss_upgrade_alreadydonated_label">میں نے پہلے ہی عطیہ کر دیا ہے۔</string>
<string name="foss_upgrade_no_money_label">میں اپنی تمام رقم ایئر پوڈز پر خرچ کرتا ہوں۔</string>
<string name="upgrade_foss_preamble">CAPod FOSS مفت اور اوپن سورس ہے۔ اگر آپ اسے مفید پاتے ہیں، تو منصوبے کو سپانسر کرنے کا خیال کریں تاکہ پراجیکٹ جاری رہے۔</string>
<string name="upgrade_foss_sponsor_action">ترقی کو اسپانسر کریں</string>
<string name="upgrade_foss_sponsor_subtitle">کوئی اشتہار نہیں۔ کوئی ٹریکنگ نہیں۔ کوئی Google Play کی قید نہیں۔</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s سے سپورٹر</string>
<string name="upgrade_foss_supporter_thanks">CAPod کی ترقی کی حمایت کرنے کا شکریہ!</string>
<string name="upgrade_foss_sponsor_again_action">سپانسر پیج کھولیں</string>
<string name="settings_upgrade_status_label">CAPod کی سرپرستی کریں</string>
<string name="upgrade_foss_sponsor_label">CAPod کی سرپرستی کریں</string>
<string name="settings_upgrade_status_description">آپ کی حامی حالت۔</string>
<string name="upgrade_screen_why_title">اپ گریڈ کے فوائد</string>
<string name="upgrade_screen_how_title">کیسے مدد کریں</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Xayriya qilish</string>
<string name="foss_upgrade_alreadydonated_label">Men allaqachon xayriya qildim</string>
<string name="foss_upgrade_no_money_label">Men butun pulimni AirPods uchun sarflayman</string>
<string name="upgrade_foss_preamble">CAPod FOSS bepul va ochiq manbali. Agar foydali deb topsangiz, loyihani davom ettirishga yordam berish uchun rivojlantiruvchini qo\'llab-quvvatlashni ko\'rib chiqing.</string>
<string name="upgrade_foss_sponsor_action">Rivojlanishni sponsorlik qilish</string>
<string name="upgrade_foss_sponsor_subtitle">Reklamasiz. Kuzatishsiz. Google Play cheklovlarisiz.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">%s dan beri homiy</string>
<string name="upgrade_foss_supporter_thanks">CAPod rivojlanishini qo\'llab-quvvatlaganingiz uchun rahmat!</string>
<string name="upgrade_foss_sponsor_again_action">Homiylik sahifasini ochish</string>
<string name="settings_upgrade_status_label">CAPod ni homiylik qiling</string>
<string name="upgrade_foss_sponsor_label">CAPod ni homiylik qiling</string>
<string name="settings_upgrade_status_description">Sizning homiy holati.</string>
<string name="upgrade_screen_why_title">Yangilashning afzalliklari</string>
<string name="upgrade_screen_how_title">Qanday yordam berish</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Quyên tặng</string>
<string name="foss_upgrade_alreadydonated_label">Tôi đã quyên góp</string>
<string name="foss_upgrade_no_money_label">Tôi tiêu hết tiền cho AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS miễn phí và mã nguồn mở. Nếu bạn thấy nó hữu ích, hãy cân nhắc tài trợ cho việc phát triển để giúp duy trì dự án.</string>
<string name="upgrade_foss_sponsor_action">Tài trợ nhà phát triển</string>
<string name="upgrade_foss_sponsor_subtitle">Không quảng cáo. Không theo dõi. Không phụ thuộc Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Người ủng hộ từ %s</string>
<string name="upgrade_foss_supporter_thanks">Cảm ơn bạn đã ủng hộ sự phát triển của CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Mở trang tài trợ</string>
<string name="settings_upgrade_status_label">Tài trợ cho CAPod</string>
<string name="upgrade_foss_sponsor_label">Tài trợ cho CAPod</string>
<string name="settings_upgrade_status_description">Trạng thái nhà tài trợ của bạn.</string>
<string name="upgrade_screen_why_title">Lợi ích nâng cấp</string>
<string name="upgrade_screen_how_title">Làm thế nào để giúp đỡ</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">捐赠</string>
<string name="foss_upgrade_alreadydonated_label">已捐赠过了</string>
<string name="foss_upgrade_no_money_label">钱都用来买 AirPods 了</string>
<string name="upgrade_foss_preamble">CAPod FOSS 是免费开源的。如果您觉得它有用,请考虑赞助开发以帮助项目持续下去。</string>
<string name="upgrade_foss_sponsor_action">赞助开发</string>
<string name="upgrade_foss_sponsor_subtitle">无广告。无追踪。无 Google Play 绑定。</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">自 %s 起成为支持者</string>
<string name="upgrade_foss_supporter_thanks">感谢您支持 CAPod 的开发!</string>
<string name="upgrade_foss_sponsor_again_action">打开赞助页面</string>
<string name="settings_upgrade_status_label">赞助 CAPod</string>
<string name="upgrade_foss_sponsor_label">赞助 CAPod</string>
<string name="settings_upgrade_status_description">您的赞助者身份。</string>
<string name="upgrade_screen_why_title">升级优势</string>
<string name="upgrade_screen_how_title">如何帮助我们</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">資助</string>
<string name="foss_upgrade_alreadydonated_label">我已經資助了</string>
<string name="foss_upgrade_no_money_label">我已經為 AirPods 傾家蕩產了</string>
<string name="upgrade_foss_preamble">CAPod FOSS 是免費且開源的。如果您覺得它對您有幫助,請考慮贊助開發以支持項目繼續運行。</string>
<string name="upgrade_foss_sponsor_action">贊助開發</string>
<string name="upgrade_foss_sponsor_subtitle">沒有廣告。沒有追蹤。沒有 Google Play 鎖定。</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">自 %s 起成為贊助者</string>
<string name="upgrade_foss_supporter_thanks">感謝你支持 CAPod 的開發!</string>
<string name="upgrade_foss_sponsor_again_action">開啟贊助頁面</string>
<string name="settings_upgrade_status_label">贊助 CAPod</string>
<string name="upgrade_foss_sponsor_label">贊助 CAPod</string>
<string name="settings_upgrade_status_description">您的贊助者身份。</string>
<string name="upgrade_screen_why_title">升級優勢</string>
<string name="upgrade_screen_how_title">如何協助</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">抖內</string>
<string name="foss_upgrade_alreadydonated_label">我已經抖內了</string>
<string name="foss_upgrade_no_money_label">我已經為 AirPods 傾家蕩產了</string>
<string name="upgrade_foss_preamble">CAPod FOSS 是免費且開源的。如果您覺得它對您有幫助,請考慮贊助開發以支持專案繼續運行。</string>
<string name="upgrade_foss_sponsor_action">贊助開發</string>
<string name="upgrade_foss_sponsor_subtitle">無廣告。無追蹤。無 Google Play 鎖定。</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">自 %s 起成為贊助者</string>
<string name="upgrade_foss_supporter_thanks">感謝您支持 CAPod 的開發!</string>
<string name="upgrade_foss_sponsor_again_action">開啟贊助頁面</string>
<string name="settings_upgrade_status_label">贊助 CAPod</string>
<string name="upgrade_foss_sponsor_label">贊助 CAPod</string>
<string name="settings_upgrade_status_description">您的贊助狀態。</string>
<string name="upgrade_screen_why_title">升級優勢</string>
<string name="upgrade_screen_how_title">如何協助</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Nikela</string>
<string name="foss_upgrade_alreadydonated_label">Senginikele kakade</string>
<string name="foss_upgrade_no_money_label">Ngisebenzisa yonke imali yami kuma-AirPods</string>
<string name="upgrade_foss_preamble">I-CAPod FOSS imahhala futhi ivulelwe umphakathi. Uma uyithola iyasiza, cabanga ngokuxhasa ukuthuthukiswa ukuze usizo kugcina iphrojekthi iqhubeka.</string>
<string name="upgrade_foss_sponsor_action">Xhasa ukuthuthukiswa</string>
<string name="upgrade_foss_sponsor_subtitle">Azikho izibhangeli. Akunakulandelelwa. Ayikho ukuvinjwa kwe-Google Play.</string>
@@ -10,7 +7,7 @@
<string name="upgrade_foss_supporter_since">Umsekeli kusukela ngo-%s</string>
<string name="upgrade_foss_supporter_thanks">Ngiyabonga ngokusekela ukuthuthukiswa kwe-CAPod!</string>
<string name="upgrade_foss_sponsor_again_action">Vula ikhasi lomxhasi</string>
<string name="settings_upgrade_status_label">Uxhasa i-CAPod</string>
<string name="upgrade_foss_sponsor_label">Uxhasa i-CAPod</string>
<string name="settings_upgrade_status_description">Isimo sakho sokusekela.</string>
<string name="upgrade_screen_why_title">Imikhulu yokuphucula</string>
<string name="upgrade_screen_how_title">Indlela yokusiza</string>
+1 -4
View File
@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Donate</string>
<string name="foss_upgrade_alreadydonated_label">I already donated</string>
<string name="foss_upgrade_no_money_label">I spend all my money on AirPods</string>
<string name="upgrade_foss_preamble">CAPod FOSS is free and open source. If you find it useful, consider sponsoring development to help keep the project going.</string>
<string name="upgrade_foss_sponsor_action">Sponsor development</string>
<string name="upgrade_foss_sponsor_subtitle">No ads. No tracking. No Google Play lock-in.</string>
@@ -11,7 +8,7 @@
<string name="upgrade_foss_supporter_since">Supporter since %s</string>
<string name="upgrade_foss_supporter_thanks">Thank you for supporting CAPod\'s development!</string>
<string name="upgrade_foss_sponsor_again_action">Open sponsor page</string>
<string name="settings_upgrade_status_label">Sponsor CAPod</string>
<string name="upgrade_foss_sponsor_label">Sponsor CAPod</string>
<string name="settings_upgrade_status_description">Your supporter status.</string>
<string name="upgrade_screen_why_title">Upgrade benefits</string>
<string name="upgrade_screen_how_title">How to help</string>
@@ -2,7 +2,6 @@ package eu.darken.capod.common.upgrade.core
import android.app.Activity
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.datastore.value
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
@@ -18,11 +17,13 @@ import eu.darken.capod.common.upgrade.core.billing.BillingData
import eu.darken.capod.common.upgrade.core.billing.BillingManager
import eu.darken.capod.common.upgrade.core.billing.GplayServiceUnavailableException
import eu.darken.capod.common.upgrade.core.billing.ItemAlreadyOwnedBillingException
import eu.darken.capod.common.upgrade.core.billing.PendingPurchaseBillingException
import eu.darken.capod.common.upgrade.core.billing.PurchasedSku
import eu.darken.capod.common.upgrade.core.billing.Sku
import eu.darken.capod.common.upgrade.core.billing.SkuDetails
import eu.darken.capod.common.upgrade.core.billing.UserCanceledBillingException
import eu.darken.capod.common.upgrade.core.billing.client.redacted
import eu.darken.capod.common.upgrade.core.billing.work.PurchaseAckScheduler
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
@@ -58,6 +59,7 @@ class UpgradeRepoGplay @Inject constructor(
private val billingManager: BillingManager,
private val billingCache: BillingCache,
private val curriculumVitae: CurriculumVitae,
private val ackScheduler: PurchaseAckScheduler,
) : UpgradeRepo {
override val storeSite: String = STORE_SITE
@@ -137,6 +139,7 @@ class UpgradeRepoGplay @Inject constructor(
.onEach { failedAt -> recordProUnconfirmed(failedAt) }
.setupCommonEventHandlers(TAG) { "connectionFailureRecorder" }
.launchIn(scope)
}
// Settledness travels WITH the ownership data (Info.isSettled), never on a parallel flow —
@@ -255,6 +258,18 @@ class UpgradeRepoGplay @Inject constructor(
return
}
try {
// Persistent ack safety net, launch trigger: armed and AWAITED before the Play sheet
// can open, so the WorkManager DB transaction lands even if the process dies around
// the sheet — the exact window behind Play's unacknowledged-purchase auto-refunds.
// Failure to arm never blocks the purchase; the foreground ack path still exists.
try {
ackScheduler.armForBillingFlowLaunch()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Failed to arm ack safety net for launch: ${e.asLog()}" }
}
// Bounded, like every other Play path (refresh, restore, SKU query, ack). useConnection
// waits for a healthy connection indefinitely, so a Play outage between rendering the
// offers and this tap would park the launch forever — with launchBusySku still held,
@@ -284,9 +299,18 @@ class UpgradeRepoGplay @Inject constructor(
// Reconciled only if the restore actually returned the SKU Play claims is
// owned — a grace-only isPro doesn't count, the entitlement is still missing.
if (restored?.upgrades?.any { it.sku == sku } != true) {
// Couldn't reconcile the entitlement (pending purchase, account mismatch,
// Play quirk) — fall back to the already-owned dialog with restore tips.
onError(e)
if (restored?.pendingSkus?.isNotEmpty() == true) {
// Play blocks re-purchasing a product whose payment it is still
// processing. "Already owned" is technically what Play said, but the
// dialog's restore tips are the wrong advice: nothing to restore, and
// the entitlement lands by itself once the payment clears.
log(TAG, INFO) { "Already-owned recovery found a pending payment" }
onError(PendingPurchaseBillingException(e))
} else {
// Couldn't reconcile the entitlement (account mismatch, Play quirk) —
// fall back to the already-owned dialog with restore tips.
onError(e)
}
}
}
@@ -329,12 +353,15 @@ class UpgradeRepoGplay @Inject constructor(
suspend fun querySkus(vararg skus: Sku): Collection<SkuDetails> = billingManager.querySkus(*skus)
// Strict subscription lookup for the pre-purchase gate: fresh SUBS-only query with explicit
// failure. No grace substitution and no cross-product-type tolerance (unlike refresh() and
// Strict purchase-state lookup for the pre-purchase gates: a fresh COMPLETE round-trip with
// explicit failure. No grace substitution and no partial tolerance (unlike refresh() and
// restorePurchaseNow()) — callers must treat any error as "couldn't verify" and fail closed.
suspend fun queryCurrentSubscriptions(): Collection<Purchase> {
log(TAG) { "queryCurrentSubscriptions()" }
return billingManager.querySubscriptions()
// Covers both product types and pending payments, because both can make a purchase wrong: a
// renewing subscription (double billing) and a payment Play is still processing (Play rejects
// the re-purchase).
suspend fun verifyPurchaseStateNow(): Info {
log(TAG) { "verifyPurchaseStateNow()" }
return Info(billingData = billingManager.refreshStrict(), isSettled = true)
}
override suspend fun refresh() {
@@ -496,8 +523,8 @@ class UpgradeRepoGplay @Inject constructor(
// Shared Pro/grace mapping used by both the reactive upgradeInfo flow and restorePurchaseNow().
// Only relinquishes Pro if we haven't had it for a while (grace period). READ-ONLY: this runs on
// replayed shared-flow data too, so it must never stamp the grace cache — see recordProState().
// settled comes from the caller, never from billingData nullness: the grace branch returns an
// Info with billingData = null that may well be settled (built from a real empty snapshot).
// settled comes from the caller, never from billingData nullness: a null-data Info can be
// perfectly settled (a real empty snapshot), and the grace branch carries data through anyway.
private suspend fun BillingData?.toUpgradeInfo(settled: Boolean): Info {
// Branch on MAPPED upgrades, not raw purchases: a purchase list containing only products
// this app doesn't know maps to zero upgrades and must fall through to the grace check —
@@ -513,7 +540,11 @@ class UpgradeRepoGplay @Inject constructor(
return when {
(now - lastProStateAt) < graceWindowMs() -> {
log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" }
Info(gracePeriod = true, billingData = null, isSettled = settled)
// billingData is carried through, not dropped: this branch is only reached when the
// mapped upgrades are empty, so entitlement stays empty either way — but a pending
// payment must stay visible, and a grace user waiting on one is exactly who needs
// the explanation.
Info(gracePeriod = true, billingData = this, isSettled = settled)
}
else -> mapped
@@ -558,6 +589,34 @@ class UpgradeRepoGplay @Inject constructor(
?.flatten()
?: emptySet()
// Products with a payment Play is still processing. Deliberately NOT part of [isPro] or
// [upgrades]: a pending payment grants nothing. It exists so the UI can explain the wait
// and lock the purchase buttons — buying the alternative product now would double-charge.
val pendingSkus: Collection<Sku> = billingData?.pendingPurchases
?.map { purchase ->
purchase.products.mapNotNull { productId ->
val sku = OurSku.PRO_SKUS.singleOrNull { it.id == productId }
if (sku == null) {
log(TAG, WARN) { "Unknown pending product: $productId (${purchase.redacted()})" }
return@mapNotNull null
}
sku
}
}
?.flatten()
?: emptySet()
// Any owned purchase Play still bills on a schedule. Deliberately computed from the RAW
// PURCHASED purchases instead of [upgrades]: the mapping drops products this app doesn't
// know, and the pre-purchase gate must keep blocking on a renewing subscription with an
// unknown or legacy product ID — being wrong there means billing the user twice for Pro.
// Both product types are scanned; a one-time purchase reports isAutoRenewing = false, so
// the broader input cannot produce a false positive.
// Computed on access, not in the initializer: an Info is built for every mapping pass, and
// only the purchase gate needs this.
val hasAutoRenewingSubscription: Boolean
get() = billingData?.purchases?.any { it.isAutoRenewing } == true
override val isPro: Boolean = upgrades.isNotEmpty() || gracePeriod
override val upgradedAt: Instant? = upgrades
@@ -1,7 +1,28 @@
package eu.darken.capod.common.upgrade.core.billing
import com.android.billingclient.api.Purchase
import eu.darken.capod.common.upgrade.core.billing.client.isPurchased
import eu.darken.capod.common.upgrade.core.billing.client.isRelevant
/**
* Play's purchase state, split by what it may be used for. [purchases] is the entitlement carrier
* and PURCHASED-only by construction, so no consumer can grant Pro (or stamp the grace cache) from
* a payment Play is still processing; [pendingPurchases] keeps that payment visible to the UI.
*/
data class BillingData(
val purchases: Collection<Purchase>
)
val purchases: Collection<Purchase>,
val pendingPurchases: Collection<Purchase> = emptyList(),
) {
companion object {
// The one place raw Play data becomes a BillingData: splitting here (instead of at each
// consumer) is what keeps "pending never grants Pro" a property of the type. Anything
// that is neither PURCHASED nor PENDING is dropped — it is not an entitlement and not a
// payment in progress.
fun from(raw: Collection<Purchase>): BillingData {
val relevant = raw.filter { it.isRelevant }
val (purchased, pending) = relevant.partition { it.isPurchased }
return BillingData(purchases = purchased, pendingPurchases = pending)
}
}
}
@@ -14,7 +14,9 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.upgrade.core.billing.client.BillingClientException
import eu.darken.capod.common.upgrade.core.billing.client.BillingConnection
import eu.darken.capod.common.upgrade.core.billing.client.BillingConnectionProvider
import eu.darken.capod.common.upgrade.core.billing.client.isPurchased
import eu.darken.capod.common.upgrade.core.billing.client.redacted
import eu.darken.capod.common.upgrade.core.billing.work.PurchaseAckScheduler
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
@@ -24,6 +26,8 @@ import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
@@ -32,6 +36,7 @@ import javax.inject.Singleton
class BillingManager @Inject constructor(
@AppScope private val scope: CoroutineScope,
connectionProvider: BillingConnectionProvider,
private val ackScheduler: PurchaseAckScheduler,
) {
// Fresh Play data plus its provenance: a query result covers owned products of the queried
@@ -73,13 +78,13 @@ class BillingManager @Inject constructor(
private val failedOnce = MutableStateFlow(false)
val isFailureSettled: Flow<Boolean> = failedOnce
// Fires once per failed connect-loop iteration: connection setup failure, the mandatory initial
// refreshPurchases erroring or timing out, an established connection dropping, an action-level
// invalidation (SERVICE_DISCONNECTED/SERVICE_TIMEOUT from any useConnection call), or an
// unexpected provider completion. Every one is a fresh reconciliation that couldn't confirm Pro.
// The connect loop retries these internally and downstream flows just go quiet, so without this
// explicit signal the grace episode clock (UpgradeRepoGplay.proUnconfirmedSince) would only
// advance on an explicit ON_RESUME refresh().
// Fires once per reconciliation that couldn't confirm Pro: a failed connect-loop iteration
// (connection setup failure, the mandatory initial refreshPurchases erroring or timing out, an
// established connection dropping, an action-level invalidation, an unexpected provider
// completion) — and, via processReconciliation, a refresh that COMPLETED but only partially,
// without a confirmed Pro purchase. The connect loop retries its failures internally and
// downstream flows just go quiet, so without this explicit signal the grace episode clock
// (UpgradeRepoGplay.proUnconfirmedSince) would only advance on an explicit ON_RESUME refresh().
//
// Each value is the failure's OCCURRENCE time (epoch millis). It has to be, not a bare Unit: the
// channel buffers, and this feed and freshBillingData are separate flows with no cross-stream
@@ -121,13 +126,18 @@ class BillingManager @Inject constructor(
// isFailureSettled forever with no retry. withTimeoutOrNull, NOT
// withTimeout: TimeoutCancellationException is a
// CancellationException and would kill this loop.
withTimeoutOrNull(INITIAL_REFRESH_TIMEOUT_MS) {
val initialRefresh = withTimeoutOrNull(INITIAL_REFRESH_TIMEOUT_MS) {
connection.refreshPurchases()
} ?: throw BillingException("Initial purchase refresh timed out")
failStreak = 0
connectionHolder.value = connection
log(TAG, INFO) { "Billing connection established" }
// AFTER publishing: a partial refresh is still a usable connection
// (a pending-only cold start must not starve billingData), but its
// bookkeeping — episode clock, dead-binder teardown — has to run,
// and an invalidation may only tear down an INSTALLED connection.
processReconciliation(initialRefresh)
}
// The provider flow stays open for the connection's lifetime; a normal
// completion means the connection is gone without an error — treat it
@@ -168,6 +178,11 @@ class BillingManager @Inject constructor(
}
}
// Serializes acknowledgement work between the reactive ack collector and explicit
// ensureAllAcknowledged() sweeps (PurchaseAckWorker): both paths mutate the token bookkeeping
// sets and both must never double-drive the same purchase's inline retry sequence.
private val ackMutex = Mutex()
// Re-drives the ack pass WITHOUT a new purchases emission: `purchases` is distinctUntilChanged,
// so a refresh returning a byte-identical (still unacknowledged) list is deduped and could never
// retry a failed ack -- the pipeline starved until Play sent something different. Declared ahead
@@ -214,7 +229,7 @@ class BillingManager @Inject constructor(
.shareIn(scope, WhileSubscribed(3000L, 0L), replay = 1)
val billingData: Flow<BillingData> = purchases
.map { BillingData(purchases = it) }
.map { BillingData.from(it) }
.shareIn(scope, WhileSubscribed(3000L, 0L), replay = 1)
val purchaseFailures: Flow<BillingResult> = connectionHolder
@@ -235,7 +250,10 @@ class BillingManager @Inject constructor(
// every emission here is a real Play round-trip the grace bookkeeping needs.
.resubscribeOnFailure("freshBillingData")
}
.map { FreshData(data = BillingData(purchases = it.purchases), isFullSnapshot = it.isFullSnapshot, occurredAt = it.occurredAt) }
// Through from() like every other exit, although the connection only ever puts PURCHASED
// purchases on this stream: if that invariant ever broke, splitting keeps the grace
// bookkeeping from stamping a pending payment as a confirmation.
.map { FreshData(data = BillingData.from(it.purchases), isFullSnapshot = it.isFullSnapshot, occurredAt = it.occurredAt) }
.setupCommonEventHandlers(TAG) { "freshBillingData" }
// Same belt as `purchases`: an Eagerly shared flow that dies stays dead, and this one feeds
// both the grace bookkeeping and the ack collector's re-drive signal.
@@ -247,13 +265,13 @@ class BillingManager @Inject constructor(
// below. The immutable Purchase snapshot keeps reporting isAcknowledged=false until a fresh Play
// query supersedes it, so the ack re-fires every emission until then; re-acking is a documented
// no-op on Play's side, whereas skipping a needed ack gets the purchase auto-refunded after 3
// days -- so the ack stays unconditional and this set only quiets the log spam. Single
// sequential collector (the ack pass below), no locking needed.
// days -- so the ack stays unconditional and this set only quiets the log spam. Confined by
// ackMutex (the collector's pass and explicit sweeps both run under it).
private val loggedAckTokens = mutableSetOf<String>()
// Tokens whose PERMANENT ack failure was already reported. Play will keep rejecting these
// (developer error, item not owned, unsupported feature), so the bug report fires once per token
// instead of once per pass. Same single-collector confinement as loggedAckTokens.
// instead of once per pass. Same ackMutex confinement as loggedAckTokens.
private val reportedAckFailures = mutableSetOf<String>()
// At most one reschedule timer in flight: repeated failures must not stack timers.
@@ -287,11 +305,22 @@ class BillingManager @Inject constructor(
// .isAcknowledged, whose immutable snapshot stays false until a fresh Play query.
private enum class AckOutcome { SUCCESS, TRANSIENT, PERMANENT }
// Aggregate outcome of one ack pass; ensureAllAcknowledged() maps it to a sweep result.
data class AckPassOutcome(val transient: Int, val permanent: Int)
// One acknowledgement pass over the canonical purchase list. Never throws except cancellation:
// transient failures schedule a re-drive, permanent ones are reported and left to organic fresh
// -data signals.
private suspend fun runAckPass(purchases: Collection<Purchase>) {
private suspend fun runAckPass(purchases: Collection<Purchase>): AckPassOutcome = ackMutex.withLock {
val needAck = purchases.filter {
// The canonical list carries pending payments too. Play rejects acknowledging one
// PERMANENTLY, so an unfiltered pass would fire a bug report for every pending purchase,
// every pass — and there is nothing to acknowledge until the payment completes anyway.
if (!it.isPurchased) {
log(TAG) { "Not acknowledgeable yet: ${it.redacted()}" }
return@filter false
}
val needsAck = !it.isAcknowledged
if (needsAck) log(TAG) { "Needs ACK: ${it.redacted()}" }
@@ -300,7 +329,23 @@ class BillingManager @Inject constructor(
needsAck
}
if (needAck.isNotEmpty()) {
// Arm the persistent safety net BEFORE attempting anything, and AWAIT the enqueue (the
// scheduler bounds it): the inline retries below can span minutes, and a process death
// inside them must not strand the purchase until Play's 3-day auto-refund. A deferred
// signal (channel + collector) would reintroduce exactly that window. Fail-open: the
// net is an extra layer, never a reason to skip the acks themselves.
try {
ackScheduler.armForUnackedPurchases(needAck.maxOf { it.purchaseTime } + ACK_SAFETY_NET_DEADLINE_MS)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Failed to arm ack safety net: ${e.asLog()}" }
}
}
var transientFailures = 0
var permanentFailures = 0
for (purchase in needAck) {
// First ack of a token is INFO; idempotent repeats drop to DEBUG. This never gates the
@@ -364,6 +409,7 @@ class BillingManager @Inject constructor(
}
if (outcome == AckOutcome.TRANSIENT) transientFailures++
if (outcome == AckOutcome.PERMANENT) permanentFailures++
if (abortPass) break
}
@@ -374,6 +420,41 @@ class BillingManager @Inject constructor(
}
scheduleAckRetry()
}
AckPassOutcome(transient = transientFailures, permanent = permanentFailures)
}
// Outcome of an explicit safety-net sweep, see ensureAllAcknowledged().
enum class AckSweepResult { COMPLETE, RETRY, PERMANENT_FAILURE }
/**
* One self-contained acknowledgement sweep for the persistent safety net (PurchaseAckWorker):
* refresh from Play, then acknowledge everything unacknowledged IN THIS COROUTINE. The reactive
* ack collector consumes purchase state asynchronously, so a caller that needs proof the acks
* actually happened before it reports success (a worker deciding success vs retry) cannot rely
* on it. Never throws except cancellation.
*/
suspend fun ensureAllAcknowledged(): AckSweepResult {
log(TAG) { "ensureAllAcknowledged()" }
val fresh = try {
useConnection { refreshPurchases() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "ensureAllAcknowledged(): refresh failed: ${e.asLog()}" }
return AckSweepResult.RETRY
}
// Same bookkeeping every other refresh exit owes: grace episode clock + dead-binder teardown.
processReconciliation(fresh)
val outcome = runAckPass(fresh.purchases)
return when {
// An incomplete refresh may be hiding an unacknowledged purchase of the failed type,
// and a transient ack failure is retriable by definition.
outcome.transient > 0 || !fresh.isComplete -> AckSweepResult.RETRY
// Play will keep rejecting these no matter how often the worker comes back.
outcome.permanent > 0 -> AckSweepResult.PERMANENT_FAILURE
else -> AckSweepResult.COMPLETE
}
}
// A purchase Play will keep rejecting: report it once per token, then stay quiet. The pass still
@@ -401,6 +482,39 @@ class BillingManager @Inject constructor(
}
}
// A partial refresh no longer reaches useConnection's dead-binder detection (it returns instead
// of throwing), so the teardown that used to ride the throw path happens here. Cause chain, not
// the exception itself: the failure arrives user-friendly-mapped. Deliberately no holder CAS:
// the failing connection may already have been replaced, and the accepted cost of that rare
// race is one extra failed action while the loop reconnects.
private fun invalidateOnDeadConnection(refresh: BillingConnection.PurchaseRefresh) {
val clientError = refresh.partialError?.let {
(it as? BillingClientException) ?: (it.cause as? BillingClientException)
}
if (clientError != null && clientError.result.responseCode in INVALIDATING_CODES) {
log(TAG, WARN) { "Refresh reported the connection dead (${clientError.result.responseCode}), invalidating." }
invalidations.trySend(Unit)
}
}
// Everything a COMPLETED refresh owes the rest of the app, in one place: both the connect loop's
// initial refresh and manual refresh() calls run through it, so a Restore tap during an outage
// feeds the same bookkeeping the connect loop does. Only reached when refreshPurchases returned
// (it still throws when it found nothing AND a query failed — that path is the connect loop's /
// useConnection's).
private fun processReconciliation(refresh: BillingConnection.PurchaseRefresh) {
invalidateOnDeadConnection(refresh)
if (!refresh.isComplete && !refresh.hasConfirmedProPurchase) {
// A reconciliation that couldn't confirm Pro. Stamped with the refresh's COMMIT time,
// never now-at-send: a confirmation that committed between this refresh and the send
// (e.g. a pending payment completing) must stay NEWER than this failure, or the grace
// episode it closed would be reopened.
log(TAG, WARN) { "Partial refresh without a confirmed Pro purchase at ${refresh.occurredAt}" }
connectionFailuresChannel.trySend(refresh.occurredAt)
}
}
private suspend fun <T> useConnection(action: suspend BillingConnection.() -> T): T {
// Every caller here is active demand (opening the upgrade screen, restore/buy taps,
// purchase acks) — cut a pending reconnect backoff short. A no-op while healthy.
@@ -479,18 +593,29 @@ class BillingManager @Inject constructor(
// shared upgradeInfo replay cache. The freshBillingData emission happens inside the
// reducer's commit, in commit order — not here.
val fresh = useConnection { refreshPurchases() }
return BillingData(purchases = fresh.purchases)
processReconciliation(fresh)
return BillingData.from(fresh.purchases)
}
// Strict SUBS-only query for the pre-purchase subscription gate: unlike refresh(), a failure
// here propagates (user-friendly-mapped) instead of being masked by the other product type.
suspend fun querySubscriptions(): Collection<Purchase> = try {
useConnection { querySubscriptions() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "querySubscriptions() failed: ${e.asLog()}" }
throw e.tryMapUserFriendly()
// Strict variant for the pre-purchase gates: unlike refresh(), anything short of a COMPLETE
// reconciliation throws (user-friendly-mapped) instead of returning what it happened to find —
// a gate must be able to tell "not owned" apart from "couldn't verify" and fail closed.
suspend fun refreshStrict(): BillingData {
log(TAG) { "refreshStrict()" }
val fresh = useConnection { refreshPurchases() }
// A gate that hit a dying connection must still trigger the reconnect (the throw below
// bypasses useConnection's detection, which already returned), or every later purchase
// check keeps reusing the corpse. No-op on a complete refresh. The episode clock stays out
// of this path: an aborted gate is not a reconciliation outcome.
invalidateOnDeadConnection(fresh)
if (!fresh.isComplete) {
// partialError is set for every incomplete refresh; the fallback only exists so a
// future incompleteness without a captured cause still fails closed instead of passing.
val error = fresh.partialError ?: BillingException("Purchase refresh was incomplete")
log(TAG, WARN) { "refreshStrict() incomplete: ${error.asLog()}" }
throw error.tryMapUserFriendly()
}
return BillingData.from(fresh.purchases)
}
companion object {
@@ -525,6 +650,10 @@ class BillingManager @Inject constructor(
BillingResponseCode.ITEM_NOT_OWNED,
)
// Play auto-refunds purchases not acknowledged within 3 days; every safety-net deadline
// derives from this.
const val ACK_SAFETY_NET_DEADLINE_MS = 3 * 24 * 60 * 60 * 1000L
private const val INITIAL_REFRESH_TIMEOUT_MS = 30_000L
private const val MAX_BACKOFF_MS = 300_000L
@@ -10,6 +10,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.error.HasLocalizedError
import eu.darken.capod.common.error.LocalizedError
import eu.darken.capod.common.debug.logging.logTag
class GplayServiceUnavailableException(cause: Throwable) :
BillingException("Google Play services are unavailable.", cause), HasLocalizedError {
@@ -48,10 +49,11 @@ class GplayServiceUnavailableException(cause: Throwable) :
)
private fun onLaunchFailed(e: Exception) {
log(ERROR) { "Can't launch settings intent for Google Play: $e" }
log(TAG, ERROR) { "Can't launch settings intent for Google Play: $e" }
}
companion object {
private const val GPLAY_PKG = "com.android.vending"
private val TAG = logTag("Upgrade", "Gplay", "ServiceUnavailable")
}
}
@@ -0,0 +1,11 @@
package eu.darken.capod.common.upgrade.core.billing
/**
* A purchase can't proceed because the account already has a payment Play is still processing.
*
* Typed so the UI can answer with the informational pending dialog instead of the already-owned
* error and its restore tips: restoring cannot help — Play refuses to re-sell a product with a
* pending payment, and the entitlement arrives on its own once the payment clears.
*/
class PendingPurchaseBillingException(cause: Throwable? = null) :
BillingException("A purchase with a pending payment already exists.", cause)
@@ -8,6 +8,21 @@ import com.android.billingclient.api.Purchase
internal val BillingResult.isSuccess: Boolean
get() = responseCode == BillingClient.BillingResponseCode.OK
/**
* Owned right now. The ONLY state that may grant an entitlement, stamp the Pro grace cache or be
* acknowledged — a PENDING purchase is a payment Play is still processing, and acknowledging one is
* rejected permanently.
*/
internal val Purchase.isPurchased: Boolean
get() = purchaseState == Purchase.PurchaseState.PURCHASED
/**
* Worth carrying in our state at all: owned, or a payment in progress the user should see. Anything
* else (UNSPECIFIED_STATE) is dropped at ingestion — it is neither.
*/
internal val Purchase.isRelevant: Boolean
get() = isPurchased || purchaseState == Purchase.PurchaseState.PENDING
/**
* Log-safe rendering of a [Purchase].
*
@@ -7,7 +7,6 @@ import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.Purchase.PurchaseState
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryProductDetailsResult
import com.android.billingclient.api.QueryPurchasesParams
@@ -41,11 +40,11 @@ class BillingConnection(
private val skuTypeOf: (String) -> Sku.Type? = DEFAULT_SKU_TYPE_RESOLVER,
) {
// A purchase proven by an onPurchasesUpdated success event. Additive only: events prove
// ownership, never absence. `gen` orders it against queries (a query that STARTED before this
// event must not clear it); `type` is resolved at ingestion so a later per-type query that
// confirms absence can supersede it (null = product unknown to this app, only a complete
// refresh may clear it).
// A purchase (owned or with a pending payment) proven by an onPurchasesUpdated success event.
// Additive only: events prove existence, never absence. `gen` orders it against queries (a
// query that STARTED before this event must not clear it); `type` is resolved at ingestion so a
// later per-type query that confirms absence can supersede it (null = product unknown to this
// app, only a complete refresh may clear it).
data class OverlayEntry(
val purchase: Purchase,
val gen: Long,
@@ -64,11 +63,11 @@ class BillingConnection(
) {
internal fun withEvent(
purchased: Collection<Purchase>,
relevant: Collection<Purchase>,
typeOf: (String) -> Sku.Type?,
): ReducerState {
val gen = eventGen + 1
val entries = purchased.map { purchase ->
val entries = relevant.map { purchase ->
OverlayEntry(
purchase = purchase,
gen = gen,
@@ -169,10 +168,13 @@ class BillingConnection(
"onPurchasesUpdated(code=${result.responseCode}, message=${result.debugMessage}, " +
"purchases=${purchases?.redacted()})"
}
// PENDING purchases must never surface as owned (or stamp the Pro grace cache).
val purchased = purchases.orEmpty().filter { it.purchaseState == PurchaseState.PURCHASED }
// The reducer carries PENDING purchases too (the UI must be able to show a payment in
// progress), but the fresh stream stays PURCHASED-only: it feeds the entitlement and
// grace bookkeeping, which must never see a payment Play hasn't completed.
val relevant = purchases.orEmpty().filter { it.isRelevant }
val purchased = relevant.filter { it.isPurchased }
synchronized(reducerLock) {
state.value = state.value.withEvent(purchased, skuTypeOf)
state.value = state.value.withEvent(relevant, skuTypeOf)
if (purchased.isNotEmpty()) {
freshUpdatesChannel.trySend(FreshUpdate(purchased, isFullSnapshot = false))
}
@@ -193,12 +195,29 @@ class BillingConnection(
failureChannel.close()
}
// The purchases of a refresh plus whether it covered both product types: a partial result (one
// query failed) is still authoritative for what it FOUND, but must not be treated as proof of
// absence for the type that couldn't be checked.
// The full outcome of a refresh: what it committed, what it actually CONFIRMED, and how far it
// got. A partial result (one query failed) is still authoritative for what it found, but must
// not be treated as proof of absence for the type that couldn't be checked — so callers that
// need to fail closed, or to feed the grace episode clock, get the provenance instead of having
// to infer it from the merged view.
data class PurchaseRefresh(
// The committed reducer state (queries merged with retained snapshots and surviving
// events) — the same view the reactive purchases flow emits.
val purchases: Collection<Purchase>,
// ONLY what these queries returned: never retained state of a failed type, so a consumer
// can tell "Play said so just now" from "we still remember this".
val confirmed: Collection<Purchase> = emptyList(),
// A confirmed PURCHASED purchase of a product this app knows. Fail-safe default: "we
// couldn't confirm Pro" is the direction that keeps the grace bookkeeping honest.
val hasConfirmedProPurchase: Boolean = false,
val isComplete: Boolean,
// Commit time under reducerLock — the same instant stamped on this refresh's FreshUpdate,
// so a confirmation and a later failure signal are ordered by when they HAPPENED.
val occurredAt: Long = System.currentTimeMillis(),
// Why the refresh is incomplete (the failed type's already user-friendly-mapped
// exception), null when complete. Carried rather than thrown: a partial refresh that found
// something is still useful, only the caller can decide whether partial is good enough.
val partialError: Throwable? = null,
)
// Serializes concurrent refreshes (manual, background, auto-restore): an older query that got
@@ -214,8 +233,8 @@ class BillingConnection(
coroutineScope {
log(TAG) { "refreshPurchases()" }
val genAtQueryStart = state.value.eventGen
val iapJob = async { queryPurchasedProducts(BillingClient.ProductType.INAPP) }
val subJob = async { queryPurchasedProducts(BillingClient.ProductType.SUBS) }
val iapJob = async { queryRelevantProducts(BillingClient.ProductType.INAPP) }
val subJob = async { queryRelevantProducts(BillingClient.ProductType.SUBS) }
val iap = iapJob.await()
val sub = subJob.await()
log(TAG) { "Refreshed IAPs=${iap.getOrNull()?.redacted()}, SUBs=${sub.getOrNull()?.redacted()}" }
@@ -224,6 +243,12 @@ class BillingConnection(
// authoritative even when its sibling failed — verified absence (e.g. a refunded IAP)
// must not be discarded just because the SUB query errored.
val isComplete = iap.isSuccess && sub.isSuccess
// Only what the queries CONFIRMED as owned — retained stale data of a failed type, and
// pending payments, stay out (both would keep re-stamping the grace window).
val confirmed = (iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty())
.filter { it.isPurchased }
.sortedByDescending { it.purchaseTime }
var committedAt = 0L
val committed = synchronized(reducerLock) {
val next = state.value.withQueryResults(
iap = iap.getOrNull(),
@@ -231,17 +256,18 @@ class BillingConnection(
genAtQueryStart = genAtQueryStart,
)
state.value = next
committedAt = System.currentTimeMillis()
if (iap.isSuccess || sub.isSuccess) {
// Only what the queries CONFIRMED — retained stale data of a failed type stays
// out of the fresh stream (it would keep re-stamping the grace window).
val confirmed = (iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty())
.sortedByDescending { it.purchaseTime }
// A surviving overlay entry (purchase event newer than the query start, or of
// a failed type) means this result does NOT prove total absence: it must not
// count as a full snapshot, or an empty query racing a fresh purchase event
// would start a false unconfirmed-grace episode.
val provesAbsence = isComplete && next.overlay.isEmpty()
freshUpdatesChannel.trySend(FreshUpdate(confirmed, isFullSnapshot = provesAbsence))
// A surviving OWNED overlay entry (purchase event newer than the query start,
// or of a failed type) means this result does NOT prove total absence: it must
// not count as a full snapshot, or an empty query racing a fresh purchase event
// would start a false unconfirmed-grace episode. A surviving PENDING entry
// proves nothing about ownership, so it must not suppress the bookkeeping
// either — a payment in progress would otherwise freeze the episode clock.
val provesAbsence = isComplete && next.overlay.none { it.purchase.isPurchased }
freshUpdatesChannel.trySend(
FreshUpdate(confirmed, isFullSnapshot = provesAbsence, occurredAt = committedAt)
)
}
next
}
@@ -249,31 +275,42 @@ class BillingConnection(
// Support-log anchor, at INFO because purchase complaints arrive as debug recordings.
// Logs what these queries CONFIRMED, kept distinct from the committed view: merged()
// retains a failed type's previous purchases, so reporting it as "what Play returned"
// would be the same false-certainty trap the copy elsewhere had to fix. Product IDs
// only -- never the Purchase, which carries order and token data.
// would be the same false-certainty trap the copy elsewhere had to fix. Pending ids are
// listed separately — "bought it, still not Pro" reports are exactly this state.
// Product IDs only -- never the Purchase, which carries order and token data.
log(TAG, INFO) {
val confirmedIds = (iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty()).flatMap { it.products }
"refreshPurchases(): confirmed=$confirmedIds, isComplete=$isComplete, " +
val returned = iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty()
val confirmedIds = returned.filter { it.isPurchased }.flatMap { it.products }
val pendingIds = returned.filterNot { it.isPurchased }.flatMap { it.products }
"refreshPurchases(): confirmed=$confirmedIds, pending=$pendingIds, isComplete=$isComplete, " +
"iapOk=${iap.isSuccess}, subOk=${sub.isSuccess}, merged=${committed.merged().size}"
}
// Throws when nothing was found and a query failed, so the caller can tell "not
// owned" apart from "couldn't verify".
combinePurchaseResults(iap, sub)
combinePurchaseResults(iap, sub, skuTypeOf)
PurchaseRefresh(
purchases = committed.merged(),
confirmed = confirmed,
hasConfirmedProPurchase = confirmed.any { purchase ->
purchase.products.any { skuTypeOf(it) != null }
},
isComplete = isComplete,
occurredAt = committedAt,
partialError = iap.exceptionOrNull() ?: sub.exceptionOrNull(),
)
}
}
// Never throws except on cancellation, so a single failing product-type query doesn't cancel
// the sibling query (or the coroutineScope). The exception is already user-friendly-mapped.
private suspend fun queryPurchasedProducts(
// Returns owned AND pending purchases; the split by state happens where it matters (fresh
// stream, entitlement mapping), so a pending payment stays visible instead of vanishing here.
private suspend fun queryRelevantProducts(
@BillingClient.ProductType type: String,
): Result<Collection<Purchase>> = try {
Result.success(queryPurchases(type).filter { it.purchaseState == PurchaseState.PURCHASED })
Result.success(queryPurchases(type).filter { it.isRelevant })
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -307,37 +344,6 @@ class BillingConnection(
return purchaseData
}
// Strict SUBS-only query for the pre-purchase subscription gate: unlike refreshPurchases(),
// a failure propagates (no cross-type tolerance) — callers must be able to fail closed on
// "couldn't verify". Commits through the reducer like any query, so the reactive purchases
// flow picks up the fresh renewal state, and emits a partial fresh update: it proves what the
// SUBS query found, never the absence of anything it didn't cover.
suspend fun querySubscriptions(): Collection<Purchase> = refreshMutex.withLock {
log(TAG) { "querySubscriptions()" }
val genAtQueryStart = state.value.eventGen
val subs = queryPurchases(BillingClient.ProductType.SUBS)
.filter { it.purchaseState == PurchaseState.PURCHASED }
val committed = synchronized(reducerLock) {
val next = state.value.withQueryResults(
iap = null,
sub = subs,
genAtQueryStart = genAtQueryStart,
)
state.value = next
freshUpdatesChannel.trySend(FreshUpdate(subs, isFullSnapshot = false))
next
}
// The COMMITTED view, not the raw response: a purchase event that arrived after the query
// started survives the commit as a newer overlay and must reach the gate too — otherwise a
// just-purchased renewing sub could slip past the fail-closed double-billing check.
// Non-IAP overlays only; untyped (unknown product) entries stay in on the safe side.
val byToken = LinkedHashMap<String, Purchase>()
subs.forEach { byToken[it.purchaseToken] = it }
committed.overlay
.filter { it.type != Sku.Type.IAP }
.forEach { byToken[it.purchase.purchaseToken] = it.purchase }
byToken.values.sortedByDescending { it.purchaseTime }
}
suspend fun acknowledgePurchase(purchase: Purchase): BillingResult {
val ack = AcknowledgePurchaseParams.newBuilder().apply {
setPurchaseToken(purchase.purchaseToken)
@@ -529,16 +535,22 @@ class BillingConnection(
OurSku.PRO_SKUS.singleOrNull { it.id == productId }?.type
}
// Combines the two product-type query results: a purchase found by either type is
// authoritative; an error is only propagated when nothing was found, so callers can tell
// "not owned" apart from "couldn't verify one product type". Treating any found purchase
// as authoritative is safe because every product this app sells is a Pro SKU (see
// OurSku.PRO_SKUS). Pure and unit-tested.
// Combines the two product-type query results: an error is only propagated when the refresh
// learned nothing usable, so callers can tell "not owned" apart from "couldn't verify one
// product type". A PURCHASED result of ANY product suppresses the error — every product
// this app sells is a Pro SKU (see OurSku.PRO_SKUS), so it is by construction relevant. A
// PENDING result only counts when it maps to a KNOWN Pro SKU: it grants nothing, and an
// unknown pending product says nothing about the type whose query failed, so treating it
// as a find would swallow a real "couldn't verify". Pure and unit-tested.
internal fun combinePurchaseResults(
iap: Result<Collection<Purchase>>,
sub: Result<Collection<Purchase>>,
typeOf: (String) -> Sku.Type? = DEFAULT_SKU_TYPE_RESOLVER,
): Collection<Purchase> {
val found = iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty()
val returned = iap.getOrNull().orEmpty() + sub.getOrNull().orEmpty()
val found = returned.filter { purchase ->
purchase.isPurchased || purchase.products.any { typeOf(it) != null }
}
return when {
found.isNotEmpty() -> found.sortedByDescending { it.purchaseTime }
else -> {
@@ -0,0 +1,114 @@
package eu.darken.capod.common.upgrade.core.billing.work
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.await
import androidx.work.workDataOf
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.core.billing.BillingManager
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
/**
* Arms the [PurchaseAckWorker] safety net. Two triggers:
* - a billing flow is about to launch (armed and awaited BEFORE the Play sheet, so the WorkManager
* DB transaction lands even if the process dies around the sheet),
* - an ack pass discovered unacknowledged purchases (called directly, pre-attempt, from
* BillingManager's runAckPass).
*/
@Singleton
class PurchaseAckScheduler @Inject constructor(
// Resolved on the first arm, not at construction: fleet App classes eagerly inject the billing
// stack during Application field injection, and resolving WorkManager there can trigger its
// on-demand initialization before the Application's worker factory field is set.
private val workManager: Provider<WorkManager>,
) {
// A genuinely new flow refreshes the watch window: REPLACE the previous LAUNCH watch. The
// worker sweeps ALL unacknowledged purchases, so replacing an older watch loses nothing — and a
// pending rescue for an already-discovered purchase has its own identity, so starting another
// purchase can never displace it. The long delay keeps the worker out of the window where the
// user may still be in the Play sheet.
suspend fun armForBillingFlowLaunch() = arm(
name = WORK_NAME_LAUNCH,
policy = ExistingWorkPolicy.REPLACE,
expiresAt = System.currentTimeMillis() + BillingManager.ACK_SAFETY_NET_DEADLINE_MS,
initialDelayMs = LAUNCH_DELAY_MS,
)
// Any pending rescue already covers every unacknowledged purchase: KEEP it. Once completed
// work exists, KEEP inserts a fresh request. Short delay — the purchase already EXISTS (unlike
// the launch trigger), possibly for days, so waiting 30min could waste real deadline time.
// Accepted edge of KEEP, within the rescue lane only: a pending request keeps its original
// (possibly earlier) expiry; a newer purchase with a later deadline is only re-covered once a
// later pass re-arms after the old work completed. Bounded residual, only reachable via
// out-of-band purchases.
suspend fun armForUnackedPurchases(expiresAt: Long) = arm(
name = WORK_NAME_RESCUE,
policy = ExistingWorkPolicy.KEEP,
expiresAt = expiresAt,
initialDelayMs = DISCOVERY_DELAY_MS,
)
private suspend fun arm(
name: String,
policy: ExistingWorkPolicy,
expiresAt: Long,
initialDelayMs: Long,
) {
if (expiresAt <= System.currentTimeMillis()) {
// Play has already voided (or is about to void) such a purchase; a sweep can't help.
log(TAG, WARN) { "arm($policy): deadline $expiresAt already passed, not scheduling" }
return
}
val request = OneTimeWorkRequestBuilder<PurchaseAckWorker>().apply {
setConstraints(
Constraints.Builder().apply {
setRequiredNetworkType(NetworkType.CONNECTED)
}.build()
)
// Launch trigger: the worker must not run while the user may still be in the Play
// sheet — an immediate sweep would find nothing unacknowledged, report success, and
// complete the net before the purchase it exists for even happened.
setInitialDelay(initialDelayMs, TimeUnit.MILLISECONDS)
setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_DELAY_MS, TimeUnit.MILLISECONDS)
setInputData(workDataOf(PurchaseAckWorker.KEY_EXPIRES_AT to expiresAt))
}.build()
// Await the enqueue: the caller arms this because the process may die at any moment — a
// fire-and-forget enqueue could be lost with it. Cancellable and BOUNDED: every caller
// needs a durable enqueue without an unbounded stall — a WorkManager that never settles
// must become an exception (handled fail-open by every caller) instead of a hang (which on
// the launch lane would park the purchase and its busy guard forever).
val operation = workManager.get().enqueueUniqueWork(name, policy, request)
withTimeoutOrNull(ENQUEUE_TIMEOUT_MS) { operation.await() }
?: throw IllegalStateException("WorkManager enqueue did not settle within ${ENQUEUE_TIMEOUT_MS}ms")
log(TAG) { "arm($policy): safety net armed, expiresAt=$expiresAt" }
}
companion object {
// WorkManager persists these names AND the worker's class name in its DB across app
// updates: keep all of them stable while old work may exist (hence the version suffix for
// future changes). Separate identities per trigger: the launch watch's REPLACE must not be
// able to displace a pending rescue for a purchase that already exists.
private val WORK_NAME_LAUNCH = "${BuildConfigWrap.APPLICATION_ID}.gplay.purchase-ack.launch.v1"
private val WORK_NAME_RESCUE = "${BuildConfigWrap.APPLICATION_ID}.gplay.purchase-ack.rescue.v1"
private const val LAUNCH_DELAY_MS = 30 * 60 * 1000L
private const val DISCOVERY_DELAY_MS = 60 * 1000L
private const val BACKOFF_DELAY_MS = 30 * 60 * 1000L
private const val ENQUEUE_TIMEOUT_MS = 10 * 1000L
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "AckScheduler")
}
}
@@ -0,0 +1,86 @@
package eu.darken.capod.common.upgrade.core.billing.work
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker.Result
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.upgrade.core.billing.BillingManager
import kotlinx.coroutines.withTimeoutOrNull
/**
* Persistent acknowledgement safety net, armed by [PurchaseAckScheduler].
*
* Play auto-refunds (and revokes) any purchase not acknowledged within 3 days. The in-process ack
* machinery in [BillingManager] handles every case where the process lives long enough — this
* worker covers the case it can't: the process dies around the Play purchase sheet (OEM task
* killers) and the user doesn't reopen the app before the deadline. Play voids such purchases and
* revokes the entitlement, so the user loses what they signed up for.
*
* Self-completing by design: nothing cancels this work from the foreground ack path (an ack pass
* can legitimately see zero unacknowledged purchases while the Play sheet is still open, which
* must not tear down the net). The redundant sweep after a successful foreground ack is one
* purchase query.
*/
@HiltWorker
class PurchaseAckWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted private val params: WorkerParameters,
private val billingManager: BillingManager,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val expiresAt = inputData.getLong(KEY_EXPIRES_AT, 0L)
log(TAG) { "doWork(): attempt=$runAttemptCount, expiresAt=$expiresAt" }
if (!isWorthSweeping(System.currentTimeMillis(), expiresAt)) {
// Past Play's refund deadline (or malformed input): retrying can't achieve anything.
// failure() is deliberate over success() — it is visible in WorkManager diagnostics,
// and a completed state lets a later KEEP enqueue insert fresh work.
log(TAG, WARN) { "doWork(): deadline passed, giving up" }
return Result.failure()
}
// Bounded well below WorkManager's 10-minute execution limit, but generous enough for the
// connection wait plus the per-purchase inline retries. A sweep that ran out of time is a
// transient outcome, not a verdict. External cancellation propagates out of doWork — it
// must never be converted into success.
val sweep = withTimeoutOrNull(SWEEP_TIMEOUT_MS) {
billingManager.ensureAllAcknowledged()
}
log(TAG, INFO) { "doWork(): sweep=$sweep" }
return mapSweep(sweep, System.currentTimeMillis(), expiresAt)
}
companion object {
// Persisted in WorkManager's request data — keep the key stable while old work may exist.
const val KEY_EXPIRES_AT = "purchase.ack.expiresAt"
private const val SWEEP_TIMEOUT_MS = 4 * 60 * 1000L
// Pure so the retry/expiry decision is unit-testable without a WorkManager test harness.
internal fun isWorthSweeping(now: Long, expiresAt: Long): Boolean =
expiresAt > 0L && now < expiresAt
internal fun mapSweep(
sweep: BillingManager.AckSweepResult?,
now: Long,
expiresAt: Long,
): Result = when (sweep) {
BillingManager.AckSweepResult.COMPLETE -> Result.success()
BillingManager.AckSweepResult.PERMANENT_FAILURE -> Result.failure()
// RETRY or timeout (null): keep trying until the deadline. WorkManager's exponential
// backoff caps at 5h, so the 3-day window still yields many attempts.
else -> if (now < expiresAt) Result.retry() else Result.failure()
}
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "AckWorker")
}
}
@@ -13,5 +13,14 @@ sealed class UpgradeEvents {
*/
data object RestoreInconclusive : UpgradeEvents()
data object SubscriptionStillRenewing : UpgradeEvents()
data object SubscriptionCheckFailed : UpgradeEvents()
/**
* Play answered, no completed purchase exists, but a payment is still being processed. Purely
* informational: nothing to fix, nothing to restore — Pro unlocks by itself once Play clears
* the payment, and a new purchase would be rejected (or double-charge) in the meantime.
*/
data object PurchasePending : UpgradeEvents()
/** The pre-purchase check with Play didn't finish, so the purchase was not started. */
data object PurchaseCheckFailed : UpgradeEvents()
}

Some files were not shown because too many files have changed in this diff Show More