Compare commits

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

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

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

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

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

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

What remains does not depend on the misreport:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The predicate is applied >= prior rather than == prior. A route that
quantizes the target back up to the starting index attenuated nothing
either, and a volume that came back higher (user raised it between the two
reads) must not produce a duck whose restore would undo their change. The
WARN says "volume did not decrease" rather than blaming the ROM, since the
read-back alone cannot distinguish those three causes.
2026-08-17 13:00:02 +02:00
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
d4rken-org-releaser[bot] 007282d3f5 Release: 5.2.2-rc1 2026-08-07 18:28:44 +00:00
darken 5ffdf66b9d Fix release builds failing on a missing Play Core annotation
R8 reported com.google.android.gms.common.annotation.NoNullnessRewrite as missing; it is a compile-time-only annotation referenced by review-ktx and absent from the runtime classpath. Adds the same targeted -dontwarn SD Maid SE already uses.
2026-08-07 19:30:15 +02:00
230 changed files with 4152 additions and 821 deletions
+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 -1
View File
@@ -1 +1 @@
5.2.2-rc0 50202000
5.2.3-rc0 50203000
+1
View File
@@ -189,6 +189,7 @@ dependencies {
addCompose()
addGlance()
addWorkerManager()
addDataStore()
addNavigation3()
addSerialization()
+3 -1
View File
@@ -5,4 +5,6 @@
# R8 full mode strips no-arg constructors not reachable by static analysis.
# Keep all constructors in the work package. Remove when Glance upgrades to work-runtime 2.10+.
# See: https://issuetracker.google.com/issues/243257364
-keep class androidx.work.** { <init>(...); }
-keep class androidx.work.** { <init>(...); }
# Play Core KTX references this compile-time-only GMS annotation not on the runtime classpath
-dontwarn com.google.android.gms.common.annotation.NoNullnessRewrite
@@ -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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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">دعم كابود</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>
+1 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -10,7 +10,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 -1
View File
@@ -11,7 +11,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
@@ -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()
}
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Autorenew
import androidx.compose.material.icons.twotone.HourglassTop
import androidx.compose.material.icons.twotone.Verified
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
@@ -101,8 +102,10 @@ internal fun UpgradeOwnershipContent(
Button(
onClick = onIap,
// Not gated on iapEnabled: prices may have failed to load while the purchase
// itself would work (the billing flow re-queries details on launch).
enabled = switchUnlocked && uiState.busy == null,
// itself would work (the billing flow re-queries details on launch). A pending
// payment does lock it — Play would reject the purchase, and the pending card
// above carries the explanation.
enabled = switchUnlocked && uiState.busy == null && !uiState.hasPendingPurchase,
modifier = Modifier
.fillMaxWidth()
.testTag(UpgradeScreenTags.GPLAY_IAP),
@@ -239,6 +242,38 @@ internal fun UpgradeGraceCard(
}
}
// Shown to EVERY audience while Google Play is still processing a payment: an acquisition user who
// just bought, an owner buying the other product, and a grace user whose Pro is running out — all
// three have purchase actions locked and need the same explanation. Calm reassurance styling like
// the grace card: nothing is wrong, the payment just isn't done.
@Composable
internal fun PendingPurchaseCard(
modifier: Modifier = Modifier,
) {
UpgradeSectionCard(
title = stringResource(R.string.upgrade_screen_pending_card_title),
icon = Icons.TwoTone.HourglassTop,
modifier = modifier.testTag(UpgradeScreenTags.GPLAY_PENDING),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(
text = stringResource(R.string.upgrade_screen_pending_card_body),
style = MaterialTheme.typography.bodyMedium,
)
}
}
@Preview2
@Composable
private fun PendingPurchaseCardPreview() {
PreviewWrapper {
PendingPurchaseCard()
}
}
private fun previewLoadedState(ownership: Ownership) = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.UNAVAILABLE,
subscriptionEnabled = false,
@@ -42,6 +42,12 @@ import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.navigation.Nav
// The Settings row reads this so it can't name the tier differently than this screen's own
// composed title does. FOSS's counterpart in its own UpgradeScreen.kt stays a support ask instead
// of the composed brand title; each flavor implementation lives only in its own source set.
@Composable
internal fun settingsUpgradeStatusTitle(): String = brandTitleText(includeQualifier = true)
@Composable
fun UpgradeScreenHost(
route: Nav.Main.Upgrade = Nav.Main.Upgrade(),
@@ -64,6 +70,7 @@ fun UpgradeScreenHost(
var showRestoreInconclusive by rememberSaveable { mutableStateOf(false) }
var showStillRenewing by rememberSaveable { mutableStateOf(false) }
var showCheckFailed by rememberSaveable { mutableStateOf(false) }
var showPurchasePending by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(vm) {
vm.events.collect { event ->
@@ -77,7 +84,8 @@ fun UpgradeScreenHost(
UpgradeEvents.RestoreFailed -> showRestoreFailed = true
UpgradeEvents.RestoreInconclusive -> showRestoreInconclusive = true
UpgradeEvents.SubscriptionStillRenewing -> showStillRenewing = true
UpgradeEvents.SubscriptionCheckFailed -> showCheckFailed = true
UpgradeEvents.PurchaseCheckFailed -> showCheckFailed = true
UpgradeEvents.PurchasePending -> showPurchasePending = true
}
}
}
@@ -128,7 +136,7 @@ fun UpgradeScreenHost(
if (showCheckFailed) {
AlertDialog(
onDismissRequest = { showCheckFailed = false },
text = { Text(text = stringResource(R.string.upgrade_screen_sub_check_failed_message)) },
text = { Text(text = stringResource(R.string.upgrade_screen_purchase_check_failed_message)) },
confirmButton = {
TextButton(onClick = { showCheckFailed = false }) {
Text(text = stringResource(R.string.general_close_action))
@@ -137,6 +145,10 @@ fun UpgradeScreenHost(
)
}
if (showPurchasePending) {
PurchasePendingDialog(onDismiss = { showPurchasePending = false })
}
val uiState by vm.state.collectAsStateWithLifecycle()
UpgradeScreen(
@@ -181,6 +193,34 @@ internal fun RestoreFailedDialog(
)
}
/**
* Shown when Play is still processing a payment. Purely informational: there is nothing to fix, no
* purchase to restore and no support case — the entitlement arrives on its own once the payment
* clears, so the dialog offers only a dismiss.
*/
@Composable
internal fun PurchasePendingDialog(
onDismiss: () -> Unit = {},
) {
AlertDialog(
onDismissRequest = onDismiss,
text = { Text(text = stringResource(R.string.upgrade_screen_pending_dialog_message)) },
confirmButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.general_close_action))
}
},
)
}
@Preview2
@Composable
private fun PurchasePendingDialogPreview() {
PreviewWrapper {
PurchasePendingDialog()
}
}
/**
* Shown when the restore never got an answer (timeout, or a Play error absorbed by grace). Carries
* no multi-account hint and no contact-support action: nothing was established, so both would be
@@ -275,6 +315,12 @@ internal fun UpgradeScreen(
}
}
// Above the ownership/acquisition split, because a pending payment cuts across it: the
// buyer waiting for their first Pro purchase, the owner switching products and the
// grace user (whose offers box is hidden entirely) all need it, and it is the reason
// their purchase buttons are locked.
if (loaded?.hasPendingPurchase == true) PendingPurchaseCard()
if (ownedState != null) {
UpgradeOwnershipContent(
uiState = ownedState,
@@ -480,6 +526,24 @@ private fun UpgradeScreenReturningBuyerPreview() {
}
}
// The acquisition variant of the pending state: card above the offers, both buy buttons locked.
@Preview2
@Composable
private fun UpgradeScreenPendingPreview() {
PreviewWrapper {
UpgradeScreen(
uiState = GplayUpgradeUiState.Loaded(
subscriptionAction = SubscriptionAction.STANDARD,
subscriptionEnabled = false,
subscriptionPrice = "$12.99",
iapEnabled = false,
iapPrice = "$24.99",
hasPendingPurchase = true,
),
)
}
}
@Preview2
@Composable
private fun UpgradeScreenUnavailablePreview() {
@@ -23,6 +23,9 @@ internal sealed interface GplayUpgradeUiState {
val ownership: Ownership = Ownership(),
val grace: GraceHint? = null,
val wasPreviouslyPro: Boolean = false,
// A payment Google Play is still processing. SKU-agnostic on purpose: the card explains the
// wait and both purchase actions lock, regardless of which product is pending.
val hasPendingPurchase: Boolean = false,
val busy: BusyOp? = null,
) : GplayUpgradeUiState
}
@@ -73,12 +76,18 @@ internal fun UpgradeRepoGplay.Info.toOwnership() = Ownership(
?.let { subs -> SubscriptionOwnership(isAutoRenewing = subs.any { it.purchase.isAutoRenewing }) },
)
// Any Pro payment Play is still processing. No per-product flag: the one-time purchase and the
// subscription are alternatives, so a pending payment for either one must lock both — completing
// both would charge the user twice for the same thing.
internal fun UpgradeRepoGplay.Info.toPendingFlag(): Boolean = pendingSkus.isNotEmpty()
internal fun toLoadedState(
iap: SkuDetails?,
sub: SkuDetails?,
ownership: Ownership,
grace: GraceHint? = null,
wasPreviouslyPro: Boolean = false,
hasPendingPurchase: Boolean = false,
busy: BusyOp? = null,
): GplayUpgradeUiState.Loaded {
val iapOffer = iap?.details?.oneTimePurchaseOfferDetails
@@ -97,15 +106,18 @@ internal fun toLoadedState(
},
// Any running entitlement operation (restore, manual or the invisible already-owned
// recovery, and purchases) pauses the buy actions too — starting a purchase while an
// entitlement is being reconciled just races Play into ITEM_ALREADY_OWNED.
// entitlement is being reconciled just races Play into ITEM_ALREADY_OWNED. A pending
// payment locks BOTH offers for the same reason the card explains: Play refuses the
// re-purchase, and the alternative product would double-charge for the same features.
subscriptionEnabled = (subOffer != null || subOfferTrial != null) &&
ownership.subscription == null && busy == null,
ownership.subscription == null && busy == null && !hasPendingPurchase,
subscriptionPrice = subOffer?.pricingPhases?.pricingPhaseList?.lastOrNull()?.formattedPrice,
iapEnabled = iapOffer != null && !ownership.hasIap && busy == null,
iapEnabled = iapOffer != null && !ownership.hasIap && busy == null && !hasPendingPurchase,
iapPrice = iapOffer?.formattedPrice,
ownership = ownership,
grace = grace,
wasPreviouslyPro = wasPreviouslyPro,
hasPendingPurchase = hasPendingPurchase,
busy = busy,
)
}
@@ -19,6 +19,7 @@ import eu.darken.capod.common.upgrade.core.OurSku
import eu.darken.capod.common.upgrade.core.UpgradeRepoGplay
import eu.darken.capod.common.upgrade.core.billing.GplayServiceUnavailableException
import eu.darken.capod.common.upgrade.core.billing.OfferUnavailableBillingException
import eu.darken.capod.common.upgrade.core.billing.PendingPurchaseBillingException
import eu.darken.capod.common.upgrade.core.billing.Sku
import eu.darken.capod.common.upgrade.core.billing.SkuDetails
import kotlinx.coroutines.CancellationException
@@ -161,8 +162,10 @@ class UpgradeViewModel @Inject constructor(
null
}
// Owners and grace users don't depend on offer prices: their status and management
// actions render immediately and price problems are not their problem.
val priceIndependent = ownership.ownsAnything || grace != null
// actions render immediately and price problems are not their problem. A user waiting on a
// pending payment is in the same position — the card is their answer, and both offers are
// locked anyway, so a price failure must not replace it with an error screen.
val priceIndependent = ownership.ownsAnything || grace != null || current.pendingSkus.isNotEmpty()
val done = queries as? SkuQueries.Done
if (done == null) {
@@ -258,6 +261,7 @@ class UpgradeViewModel @Inject constructor(
ownership = ownership,
grace = grace,
wasPreviouslyPro = wasEverPro && !current.isPro,
hasPendingPurchase = current.toPendingFlag(),
// This ViewModel's own action wins; otherwise a launch started elsewhere (previous VM
// instance, e.g. across a rotation — the launch lives on AppScope) or the repo's
// invisible already-owned auto-restore still pauses the entitlement actions here.
@@ -303,44 +307,78 @@ class UpgradeViewModel @Inject constructor(
return true
}
// Outcome of the pre-purchase check with Play. Both purchase paths share it: they buy
// alternatives of the same entitlement from the same account, so a check only one of them runs
// is exactly how a double charge slips through. [Blocked] means the user was already told why.
private sealed interface PurchaseGate {
data class Clear(val info: UpgradeRepoGplay.Info) : PurchaseGate
data object Blocked : PurchaseGate
}
// Fails closed: no fresh, complete answer from Play (error, timeout) means no purchase. Bounded,
// because the repo waits for a healthy connection indefinitely and a tap must not park the
// single-flight guard through an outage.
private suspend fun runPurchaseGate(): PurchaseGate {
val info = try {
withTimeoutOrNull(VERIFY_TIMEOUT_MS) { upgradeRepo.verifyPurchaseStateNow() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Purchase verification errored: ${e.asLog()}" }
errorEvents.tryEmit(e)
return PurchaseGate.Blocked
}
if (info == null) {
log(TAG, WARN) { "Purchase verification timed out" }
events.tryEmit(UpgradeEvents.PurchaseCheckFailed)
return PurchaseGate.Blocked
}
if (info.pendingSkus.isNotEmpty()) {
// Play rejects a purchase while it is still processing a payment for this account, and
// the alternative product would charge twice for the same features.
log(TAG, INFO) { "Purchase blocked: a payment is still pending" }
events.tryEmit(UpgradeEvents.PurchasePending)
return PurchaseGate.Blocked
}
return PurchaseGate.Clear(info)
}
// A launch that failed on a pending payment is not an error the user can act on: it gets the
// informational dialog instead of the already-owned copy and its restore tips.
private fun onLaunchError(error: Throwable) {
if (error is PendingPurchaseBillingException) {
events.tryEmit(UpgradeEvents.PurchasePending)
} else {
errorEvents.tryEmit(error)
}
}
fun onGoIap(activity: Activity) {
log(TAG) { "onGoIap($activity)" }
launch {
// Single-flight: repeated taps must not stack verifications or billing launches.
if (!acquireOp(BusyOp.IAP)) return@launch
try {
// Hard gate against double-billing: verify against a FRESH SUBS-only query — the
// replayed upgradeInfo can be stale or built from partial results. Fails closed:
// no verified "not set to renew" (or no sub at all), no one-time purchase.
val subscriptions = try {
withTimeoutOrNull(VERIFY_TIMEOUT_MS) { upgradeRepo.queryCurrentSubscriptions() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Subscription verification errored: ${e.asLog()}" }
errorEvents.tryEmit(e)
val gate = runPurchaseGate()
if (gate !is PurchaseGate.Clear) return@launch
// Hard gate against double-billing, against the FRESH result — the replayed
// upgradeInfo can be stale or built from partial results. Asked of the RAW
// purchases (see Info.hasAutoRenewingSubscription), never of the mapped upgrades:
// a renewing subscription with an unknown or legacy product ID must block the
// one-time purchase too, or the user pays for Pro twice.
if (gate.info.hasAutoRenewingSubscription) {
log(TAG, INFO) { "IAP purchase blocked: subscription is still set to renew" }
events.tryEmit(UpgradeEvents.SubscriptionStillRenewing)
return@launch
}
when {
subscriptions == null -> {
log(TAG, WARN) { "Subscription verification timed out" }
events.tryEmit(UpgradeEvents.SubscriptionCheckFailed)
}
subscriptions.any { it.isAutoRenewing } -> {
log(TAG, INFO) { "IAP purchase blocked: subscription is still set to renew" }
events.tryEmit(UpgradeEvents.SubscriptionStillRenewing)
}
// Suspends until the Play sheet launch resolved, so the single-flight guard
// covers the whole tap-to-sheet window, not just the verification.
else -> upgradeRepo.launchBillingFlowNow(
activity,
OurSku.Iap.PRO_UPGRADE,
null,
onError = errorEvents::tryEmit,
)
}
// Suspends until the Play sheet launch resolved, so the single-flight guard covers
// the whole tap-to-sheet window, not just the verification.
upgradeRepo.launchBillingFlowNow(
activity,
OurSku.Iap.PRO_UPGRADE,
null,
onError = ::onLaunchError,
)
} finally {
activeOp.value = null
}
@@ -361,6 +399,32 @@ class UpgradeViewModel @Inject constructor(
launch {
if (!acquireOp(BusyOp.SUBSCRIPTION)) return@launch
try {
// Same fresh check as the one-time path: a pending payment (for either product)
// must block this launch too — Play would reject it, or bill it on top.
val gate = runPurchaseGate()
if (gate !is PurchaseGate.Clear) return@launch
// The reactive UI can be stale (the purchase was made on another device), and Play
// happily sells the subscription alongside an owned one-time purchase — the user
// would pay for Pro twice. The strict refresh already committed into the shared
// billing data, so the screen re-renders to the ownership state, and the
// restore-success toast explains why nothing launched. Deliberately the MAPPED
// upgrades, not isPro: grace users (mapped upgrades empty) may legitimately
// re-purchase.
if (gate.info.upgrades.isNotEmpty()) {
log(TAG, INFO) { "Subscription purchase blocked: fresh check found an owned upgrade" }
events.tryEmit(UpgradeEvents.RestoreSucceeded)
return@launch
}
// Same breadth as the one-time path's renewal guard: an auto-renewing subscription
// with an unknown or legacy product ID (which maps to zero upgrades, so the block
// above passed) must still block a new subscription — two renewing subscriptions
// for the same features is the same double-billing. Reuses the existing
// manage-subscription dialog.
if (gate.info.hasAutoRenewingSubscription) {
log(TAG, INFO) { "Subscription purchase blocked: another subscription is still set to renew" }
events.tryEmit(UpgradeEvents.SubscriptionStillRenewing)
return@launch
}
// launchBillingFlowNow suspends until the launch resolved, so the guard covers the
// whole tap-to-sheet window. The flow itself still runs on AppScope, so closing the
// screen mid-launch doesn't abort the purchase.
@@ -368,7 +432,7 @@ class UpgradeViewModel @Inject constructor(
activity,
OurSku.Sub.PRO_UPGRADE,
offer,
onError = errorEvents::tryEmit,
onError = ::onLaunchError,
)
} finally {
activeOp.value = null
@@ -432,6 +496,14 @@ class UpgradeViewModel @Inject constructor(
events.tryEmit(UpgradeEvents.RestoreSucceeded)
}
restored.info.pendingSkus.isNotEmpty() -> {
// Play answered and found the purchase — it just hasn't been paid for yet.
// RestoreFailed would send this user chasing account and support advice for
// something that resolves itself.
log(TAG, INFO) { "Restore found a purchase with a pending payment" }
events.tryEmit(UpgradeEvents.PurchasePending)
}
else -> {
// Play answered and had nothing. Includes a grace-only result from a successful
// EMPTY query: Pro may still be active, but the check really did complete, so
@@ -46,8 +46,6 @@
<string name="upgrade_screen_owned_iap_purchase_note">Dit is \'n aparte aankoop — dit kanselleer of vergoed nie jou intekening nie. Gebruik dieselfde Google-rekening.</string>
<string name="upgrade_screen_sub_still_renewing_title">Intekening steeds aktief</string>
<string name="upgrade_screen_sub_still_renewing_message">Jou intekening is steeds gestel om te hernu. Kanselleer dit eers in Google Play, en wissel dan na die eenmalige aankoop. Pas gekanselleer? Gee Google Play \'n oomblik en probeer weer.</string>
<string name="upgrade_screen_sub_check_failed_title">Intekeningkontrole het misluk</string>
<string name="upgrade_screen_sub_check_failed_message">Kon nie jou intekeningstatus by Google Play nagaan nie. Probeer binnekort weer om \'n dubbele aankoop te vermy.</string>
<string name="upgrade_screen_grace_title">Jou aankoop word bevestig</string>
<string name="upgrade_screen_grace_body_short">Google Play het jou aankoop nog nie bevestig nie. Pro is steeds aktief — geen aksie nodig nie.</string>
<string name="upgrade_screen_grace_body">Google Play het jou aankoop al \'n rukkie nie bevestig nie. Pro is steeds aktief. Maak seker jy is aanlyn en aangemeld met die Google-rekening wat vir die aankoop gebruik is, en probeer dan herstel.</string>
@@ -66,7 +64,6 @@
<string name="upgrade_screen_restore_checked_message">CAPod het Google Play nou-nou gekontroleer, maar geen Pro-aankoop kon bevestig word vir die rekening wat Google Play vir hierdie app gebruik nie.</string>
<string name="upgrade_screen_restore_contact_hint">Nog steeds vas? Neem kontak op met ondersteuning van die e-posadres wat die aankoop gemaak het en voeg jou Google Play-ordernommer by.</string>
<string name="upgrade_screen_contact_support_action">Neem kontak op met ondersteuning</string>
<string name="settings_upgrade_status_label">CAPod Pro</string>
<string name="settings_upgrade_status_description">Jou upgrade- en aankoopstatus.</string>
<string name="upgrades_gplay_unavailable_error_description">CAPod kan nie aan Google Play koppel nie. Is Google Play geïnstalleer en opgedateer? Is jou Google-rekening aangemeld? Probeer om die kas van die Google Play-toepassing skoon te maak en jou toestel herlaai.</string>
<!-- Toast shown when the "Google Play" button of an error dialog can't open Google Play's app info, e.g. Google Play is missing or blocked on this device. "Google Play" is a brand name, keep it untranslated. -->
-3
View File
@@ -46,8 +46,6 @@
<string name="upgrade_screen_owned_iap_purchase_note">ይህ የተለየ ግዢ ነው — ምዝገባውን አይሰርዝም ወይም አይመልስም። ተመሳሳይ የGoogle መለያ ይጠቀሙ።</string>
<string name="upgrade_screen_sub_still_renewing_title">ምዝገባ አሁነም ንቁ ነው</string>
<string name="upgrade_screen_sub_still_renewing_message">ምዝገባዎ አሁንም ለማደስ ተዘጋጅቷል። መጀመሪያ በGoogle Play ውስጥ ይሰርዙት፣ ከዚያ ወደ አንድ ጊዜ ግዢ ይቀይሩ። አሁን ገና ሰርዘውታል? ለGoogle Play ትንሽ ጊዜ ይስጡ እና እንደገና ይሞክሩ።</string>
<string name="upgrade_screen_sub_check_failed_title">የምዝገባ ማረጋገጫ አልተሳካም</string>
<string name="upgrade_screen_sub_check_failed_message">የምዝገባ ሁኔታዎን ከGoogle Play ጋር ማረጋገጥ አልተቻለም። ድርብ ግዢን ለማስቀረት፣ ትንሽ ቆይተው እንደገና ይሞክሩ።</string>
<string name="upgrade_screen_grace_title">ግዢዎን በማረጋገጥ ላይ</string>
<string name="upgrade_screen_grace_body_short">Google Play ግዢዎን እስካሁን አላረጋገጠም። Pro አሁንም ንቁ ነው — ምንም እርምጃ አያስፈልግም።</string>
<string name="upgrade_screen_grace_body">Google Play ለተወሰነ ጊዜ ግዢዎን አላረጋገጠም። Pro አሁንም ንቁ ነው። መስመር ላይ መሆንዎን እና ለግዢው ጥቅም ላይ በዋለው የGoogle መለያ መግባትዎን ያረጋግጡ፣ ከዚያ ወደነበረበት ለመመለስ ይሞክሩ።</string>
@@ -66,7 +64,6 @@
<string name="upgrade_screen_restore_checked_message">CAPod አሁን Google Play አረጋገጠ፣ ነገር ግን Google Play ለዚህ መተግበሪያ ጥቅም ላይ ስለሚውለው መለያ Pro ግዢ ማረጋገጥ አልተቻለም።</string>
<string name="upgrade_screen_restore_contact_hint">አሁንም ችግር ካጋጠመዎት? ግዢውን ያደረገው ኢሜይል በመጠቀም ድጋፍ ያግኙ እና Google Play ትዕዛዝ ቁጥርዎን ያካትቱ።</string>
<string name="upgrade_screen_contact_support_action">ድጋፍ ለማነጋገር</string>
<string name="settings_upgrade_status_label">CAPod Pro</string>
<string name="settings_upgrade_status_description">የእርስዎ ማሻሻያ እና ግዢ ሁኔታ።</string>
<string name="upgrades_gplay_unavailable_error_description">CAPod ከGoogle Play ጋር መገናኘት አይችልም። Google Play ተጭኖ እና ወቅታዊ ነው? የGoogle መለያዎ ገብቷል? የGoogle Play መተግበሪያ መሸጎጫ ማጥራት እና መሳሪያዎን እንደገና ማስጀመር ይሞክሩ።</string>
<!-- Toast shown when the "Google Play" button of an error dialog can't open Google Play's app info, e.g. Google Play is missing or blocked on this device. "Google Play" is a brand name, keep it untranslated. -->
-3
View File
@@ -46,8 +46,6 @@
<string name="upgrade_screen_owned_iap_purchase_note">هذا شراء منفصل — لا يؤدي إلى إلغاء اشتراكك أو استرداد قيمته. استخدم نفس حساب Google.</string>
<string name="upgrade_screen_sub_still_renewing_title">الاشتراك لا يزال نشطًا</string>
<string name="upgrade_screen_sub_still_renewing_message">اشتراكك لا يزال مضبوطًا على التجديد. ألغه في Google Play أولًا، ثم بدّل إلى الشراء لمرة واحدة. هل ألغيته للتو؟ امنح Google Play بعض الوقت وحاول مرة أخرى.</string>
<string name="upgrade_screen_sub_check_failed_title">فشل التحقق من الاشتراك</string>
<string name="upgrade_screen_sub_check_failed_message">تعذّر التحقق من حالة اشتراكك لدى Google Play. لتجنب الشراء المزدوج، حاول مرة أخرى بعد قليل.</string>
<string name="upgrade_screen_grace_title">جارٍ تأكيد عملية الشراء</string>
<string name="upgrade_screen_grace_body_short">لم يؤكّد Google Play عملية الشراء الخاصة بك بعد. لا يزال Pro نشطًا — لا حاجة لاتخاذ أي إجراء.</string>
<string name="upgrade_screen_grace_body">لم يؤكّد Google Play عملية الشراء الخاصة بك منذ فترة. لا يزال Pro نشطًا. تأكد من اتصالك بالإنترنت وتسجيل دخولك بحساب Google المستخدم في عملية الشراء، ثم حاول استعادة الشراء.</string>
@@ -66,7 +64,6 @@
<string name="upgrade_screen_restore_checked_message">تحقَّق كابود من جوجل بلاي، ولكن لم يتمّ تأكيد أيّ عملية اشتراء للنسخة الاحترافية للحساب الذي يستخدمه جوجل بلاي لهذا التطبيق.</string>
<string name="upgrade_screen_restore_contact_hint">هل ما زلت تواجه مشكلة؟ تواصل مع الدعم من خلال البريد الإلكتروني الذي استخدمته لإتمام عملية الشراء، وضمّن رقم طلبك من سوق جوجل بلاي.</string>
<string name="upgrade_screen_contact_support_action">التواصُل مع الدعم</string>
<string name="settings_upgrade_status_label">النسخة الاحترافية من كابود</string>
<string name="settings_upgrade_status_description">حالة الترقية والاشتراء الخاصّة بك.</string>
<string name="upgrades_gplay_unavailable_error_description">لا يمكن لكابود الاتّصال بسوق جوجل بلاي. هل سوق جوجل بلاي مُثبَّتة ومُحدَّثة؟ هل حساب جوجل الخاصّ بك مُسجَّل الدخول؟ حاول مسح ذاكرة التخزين المؤقّت لتطبيق جوجل بلاي وإعادة تشغيل جهازك.</string>
<!-- Toast shown when the "Google Play" button of an error dialog can't open Google Play's app info, e.g. Google Play is missing or blocked on this device. "Google Play" is a brand name, keep it untranslated. -->
-3
View File
@@ -46,8 +46,6 @@
<string name="upgrade_screen_owned_iap_purchase_note">Bu ayrı bir satınalmadır — abunəliyinizi ləğv etmir və ya geri ödəmir. Eyni Google hesabından istifadə edin.</string>
<string name="upgrade_screen_sub_still_renewing_title">Abunəlik hələ də aktivdir</string>
<string name="upgrade_screen_sub_still_renewing_message">Abunəliyiniz hələ də yenilənməyə təyin edilib. Əvvəlcə onu Google Play-də ləğv edin, sonra birdəfəlik satınalmaya keçin. Bu yaxınlarda ləğv etmisiniz? Google Play-ə bir az vaxt verin və yenidən cəhd edin.</string>
<string name="upgrade_screen_sub_check_failed_title">Abunəlik yoxlaması uğursuz oldu</string>
<string name="upgrade_screen_sub_check_failed_message">Abunəlik statusunuz Google Play ilə yoxlanıla bilmədi. İkiqat satınalmanın qarşısını almaq üçün bir az sonra yenidən cəhd edin.</string>
<string name="upgrade_screen_grace_title">Satınalmanız təsdiqlənir</string>
<string name="upgrade_screen_grace_body_short">Google Play hələ satınalmanızı təsdiqləməyib. Pro hələ də aktivdir — heç bir əməliyyat lazım deyil.</string>
<string name="upgrade_screen_grace_body">Google Play bir müddətdir satınalmanızı təsdiqləməyib. Pro hələ də aktivdir. Onlayn olduğunuzdan və satınalma üçün istifadə edilən Google hesabı ilə daxil olduğunuzdan əmin olun, sonra bərpa etməyi sınayın.</string>
@@ -66,7 +64,6 @@
<string name="upgrade_screen_restore_checked_message">CAPod yeni Google Play-i yoxladı, lakin bu tətbiq üçün Google Play istifadə etdiyi hesab üçün heç bir Pro alışı təsdiq edilə bilmədi.</string>
<string name="upgrade_screen_restore_contact_hint">Hələ də sıkışmısınız? Alışı edən e-poçtdan dəstək xidmətinə müraciət edin və Google Play sifariş nömrənizi daxil edin.</string>
<string name="upgrade_screen_contact_support_action">Dəstək xidmətinə müraciət edin</string>
<string name="settings_upgrade_status_label">CAPod Pro</string>
<string name="settings_upgrade_status_description">Sizin yüksəltmə və alış statusu.</string>
<string name="upgrades_gplay_unavailable_error_description">CAPod Google Play-ə qoşula bilmir. Google Play quraşdırılıb və yenilənib? Google hesabınız daxil olub? Google Play tətbiqinin keşini təmizləməyi və cihazınızı yenidən başlatmağı sınayın.</string>
<!-- Toast shown when the "Google Play" button of an error dialog can't open Google Play's app info, e.g. Google Play is missing or blocked on this device. "Google Play" is a brand name, keep it untranslated. -->
-3
View File
@@ -46,8 +46,6 @@
<string name="upgrade_screen_owned_iap_purchase_note">Гэта асобная пакупка — яна не скасоўвае падпіску і не вяртае за яе грошы. Выкарыстоўвайце той жа акаунт Google.</string>
<string name="upgrade_screen_sub_still_renewing_title">Падпіска ўсё яшчэ актыўная</string>
<string name="upgrade_screen_sub_still_renewing_message">Ваша падпіска ўсё яшчэ будзе аўтаматычна прадоўжана. Спачатку скасуйце яе ў Google Play, а потым пераключацеся на аднаразовую пакупку. Толькі што скасавалі? Дайце Google Play крыху часу і паспрабуйце зноў.</string>
<string name="upgrade_screen_sub_check_failed_title">Не ўдалося праверыць падпіску</string>
<string name="upgrade_screen_sub_check_failed_message">Не ўдалося праверыць статус вашай падпіскі ў Google Play. Каб пазбегнуць падвойнай пакупкі, паспрабуйце зноў праз хвіліну.</string>
<string name="upgrade_screen_grace_title">Пацвярджэнне вашай пакупкі</string>
<string name="upgrade_screen_grace_body_short">Google Play яшчэ не пацвердзіў вашу пакупку. Pro па-ранейшаму актыўны — дзеянні не патрабуюцца.</string>
<string name="upgrade_screen_grace_body">Google Play ужо некаторы час не пацвярджае вашу пакупку. Pro па-ранейшаму актыўны. Пераканайцеся, што вы падключаны да інтэрнэту і ўвайшлі ў акаунт Google, які выкарыстоўваўся для пакупкі, а потым паспрабуйце аднавіць пакупку.</string>
@@ -66,7 +64,6 @@
<string name="upgrade_screen_restore_checked_message">CAPod толькі што праверыў Google Play, але Pro-пакупка не была пацверджана для акаўнта, які Google Play выкарыстоўвае для гэтай праграмы.</string>
<string name="upgrade_screen_restore_contact_hint">Усё яшчэ ёсць праблемы? Звяжыцеся з падтрымкай з адрасу электроннай пошты, з якой была зроблена пакупка, і ўкажыце нумар заказу Google Play.</string>
<string name="upgrade_screen_contact_support_action">Звязацца з падтрымкай</string>
<string name="settings_upgrade_status_label">CAPod Pro</string>
<string name="settings_upgrade_status_description">Ваш статус абнаўлення і пакупкі.</string>
<string name="upgrades_gplay_unavailable_error_description">CAPod не можа падключыцца да Google Play. Ці ўсталяваны і абноўлены Google Play? Вы ўвайшлі ў свой уліковы запіс Google? Паспрабуйце ачысціць кэш праграмы Google Play і перазагрузіць прыладу.</string>
<!-- Toast shown when the "Google Play" button of an error dialog can't open Google Play's app info, e.g. Google Play is missing or blocked on this device. "Google Play" is a brand name, keep it untranslated. -->

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