Commit Graph
110 Commits
Author SHA1 Message Date
darken 76e1128e93 General: Update app translations from Crowdin 2026-08-21 19:14:22 +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
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 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
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
darken 6b358cb101 refactor(upgrade): Move the title template out of the flavour source sets
Arrangement is a property of the language, not the flavour, so the key
now lives once in main alongside app_name. The qualifier stays
flavour-specific: only the word order and punctuation move.

FOSS consequently inherits each language's arrangement instead of being
frozen at the default order.
2026-08-07 18:27:33 +02:00
darken f1e4ffb099 General: Update app translations from Crowdin 2026-08-07 16:51:44 +02:00
darken d012799d49 fix(upgrade): Compose the Pro title from a translatable template
The composed app_name_pro was split on spaces and only styled when it
produced exactly two tokens. Arabic has four (kabud en-dash two-word
qualifier) and lost its branding entirely; Estonian puts the qualifier
first and so passed the guard while highlighting the brand instead.

Titles are now built from a per-flavour app_name_upgraded_template with
the app name and the tier qualifier as placeholders, so translators own
word order and punctuation.
2026-08-07 16:51:44 +02:00
darken 06fd256889 fix(upgrade): Show a failed error-dialog fix action inline instead of a toast
The Google Play launch-failure message was shown via Toast, which Android caps
at 2 lines: English lost the trailing "device.", French was cut mid-word and
lost an entire condition. The strings are fine, the container was wrong.

The fix action now rethrows after logging, the failure reaches the dialog, and
the dialog renders the message inline while staying open. The dismiss button
stays available, so the dialog is never latched.

The message is passed per dispatch rather than read from the LocalizedError, so
no future action button can surface the fix action's failure copy. The inline
state is keyed on the throwable, not the LocalizedError, which is rebuilt with
fresh action lambdas on every recomposition.
2026-08-07 09:49:57 +02:00
darken 2f715ccac9 fix(upgrade): Report empty Play product results as a merchandising error
When both the IAP and SUB queries came back with nothing, the screen always
reported a connectivity failure, telling users to clear Play's cache and reboot.
Play can answer OK and simply have no sellable offer (region, account
eligibility, pulled product), where that advice is futile.

Both causes are now inspected: only when BOTH are OfferUnavailableBillingException
does the merchandising copy surface. A single non-merchandising failure can't
rule out a real Play problem, so the conservative copy stays.
2026-08-07 09:49:57 +02:00
darken 4dba593c13 General: Update app translations from Crowdin 2026-08-06 13:07:28 +02:00
darken ed5ec8152b General: Update app translations from Crowdin 2026-08-06 13:07:28 +02:00
darken 39b8fcb8f8 fix(review): Harden the Play review probe and tap path
Timeouts on all three Play calls, a dismiss generation backstop for the
tap race, and a 3-state probe verdict that caches Play's definitive
answers for the process, retries transient failures on a bounded budget,
and re-evaluates eligibility at the snooze and pro-grace boundaries.
2026-08-06 09:55:09 +02:00
darken 5ef3965a0d General: Update app translations from Crowdin 2026-08-05 16:42:36 +02:00
darken 1e1f0b13c6 fix(overview): Keep the review prompt from crashing or stacking
Corrupt review settings terminated the shared state flow on AppScope,
so the exception crashed the process instead of reaching the ViewModel's
catch. Absorb it upstream of both replayingShare calls.

The review card also no longer stacks on top of the enable-Bluetooth
prompt.

Fixes review findings F1, F2
2026-08-05 16:27:13 +02:00
darken 26707dc0e5 feat(overview): Ask happy users for a Play review
Adds a review prompt card to the overview. On Google Play it uses the
in-app review flow, gated on the user having been Pro for a while, not
having dismissed it recently and not having reviewed yet. FOSS gets a
no-op implementation.

The card is the lowest priority item on the overview and stays hidden
while a permission, troubleshooter, background-monitoring-off or
no-profiles card is on screen.
2026-08-05 16:27:13 +02:00
darken 442f6bcc88 fix: Correct the Google Play launch-failure toast
The toast claimed Google Play was not installed for every failed launch,
but the SecurityException path means Play is installed and merely disabled
or profile-restricted - telling those users to install an app they already
have. Neutral wording covers both cases; the resource key is unchanged (no
translations exist yet).
2026-08-05 11:26:02 +02:00
darken e7cb53f0b8 fix: Harden the error dialog's Google Play fix action
The dialog's fix dispatch ran unguarded: a throwing action crashed the UI
thread from inside a click handler and skipped onDismiss(), leaving the
dialog latched on the current error. The dispatch is now wrapped in
try/catch with onDismiss() in a finally block.

Google Play fix action:
- Drop FLAG_ACTIVITY_NEW_TASK. The action runs on an activity context, so
  the flag only detached Play's app info from the caller's task and back
  stack.
- Catch SecurityException next to ActivityNotFoundException: Play can be
  installed but blocked (disabled app, restricted profile, guarding ROM),
  which denies the launch instead of failing to resolve it.
- The fallback toast is now a translatable string resource instead of a
  hardcoded literal.

New coverage: ComposeErrorDialogGuardTest pins that a throwing fix action
still dismisses the dialog (shared source set, so both flavors run it),
GplayFixActionTest pins the denied and unresolvable launches showing a
toast instead of crashing, and ComposeErrorDialogTest now asserts the
launch intent carries no NEW_TASK flag.
2026-08-04 22:01:26 +02:00
darken 6fcc2c4348 fix(upgrade): Theme the upgrade retry button for its error card
The retry sits inside the errorContainer card but drew itself with the
default primary-on-surface outlined colors, which clashes with the card and
loses contrast once the tap latch disables it. Content and border now follow
onErrorContainer, with a dimmed disabled pair, and both states get a
preview.
2026-08-03 20:28:01 +02:00
darken 375af943ae fix(error): Let the error dialog dismiss instead of only acknowledging
LocalizedError can now carry a fix action, and the shared error dialog shows
it next to a Dismiss button when one is present; errors without a fix keep
today's OK-only shape. The Google Play billing-unavailable error uses it to
open Play's app info as a generic troubleshooting affordance.
2026-08-03 20:28:01 +02:00
darken 65f7cbe8b7 fix(upgrade): Color the brand inside the gplay upgrade pitch title
The acquisition top bar reused the widgets' flat upgrade label, so the Pro
postfix stayed uncolored while the owned/grace title highlighted it. The
title is now a gplay-only template that takes the composed brand as a
placeholder, spliced back in as the same styled AnnotatedString the status
title uses — word order stays the translation's business. The widget label
keeps its own key and its own wording.
2026-08-03 20:28:01 +02:00
darken 2833639892 feat(upgrade): Merge upgrade screen mascot and preamble into one hero card
Adds UpgradeHeroCard, which pairs the mascot with the preamble copy inside a
single ElevatedCard and stacks them once the copy runs out of room. Used by
the FOSS pitch view and the GPLAY acquisition view; grace episodes and the
FOSS status views keep their standalone header, which has no preamble to
pair with. Screen tests pin the hero's presence and absence per state.
2026-08-03 20:28:01 +02:00
darken 6b2536c74f fix(debug): Keep debug recording available when diagnostics hang
The debug log header read the flavor's upgrade diagnostics unbounded. A
wedged source (a stuck DataStore file lock, a billing store that never
answers) left the recorder started but never committed, so the user asking
for a log got nothing at exactly the moment the app was misbehaving.

The read now runs under a deadline: a source that hangs or fails degrades
to "unavailable" and the recording starts. Completion is tracked
separately from the value, so a flavor that legitimately has nothing to
report (FOSS) still logs no line at all instead of claiming a failure.
Cancellation is unchanged: an outer scope death still rolls the
uncommitted recorder back.

The GPlay diagnostics' pro-history read gets the same bound its billing
cache read already had.
2026-08-02 12:40:32 +02:00
darken 72fb1d3b17 fix(upgrade): Never let a failed cache stamp abort entitlement bookkeeping
stampLastProState() was bounded against a wedged file lock, but a write
that failed outright (corrupt preferences file, no disk space) still threw
straight through into the entitlement path it only decorates.

Non-cancellation exceptions from the edit now log a warning and skip the
stamp, same as the timeout does. Cancellation keeps propagating - caught
first on purpose, swallowing it would break the caller's structured
concurrency. Reads stay loud: a snapshot that couldn't be read must not
be mistaken for a never-bought install.
2026-08-02 12:40:32 +02:00
darken 10317b373f fix(upgrade): Polish the GPlay offers-unavailable card
The card reports that PRICES could not be loaded, so it now says so instead
of borrowing the generic "Google Play services are unavailable" title, which
contradicted its own body.

The retry latches after the first tap: the guard sits inside onClick because
`enabled` only takes effect after recomposition, so two taps in the same frame
would both fire. It resets naturally when the card leaves composition.

Returning to the screen re-runs the SKU query when it is in the unavailable
state. MainActivity's per-resume refresh only covers the entitlement, so a
transient Play outage left the retry card up until it was tapped by hand.
2026-07-30 12:55:31 +02:00
darken 7fb1f7aabd fix(debug): Bound the billing cache and fold pro history into diagnostics
BillingCache reads and writes are now bounded by a timeout seam: a wedged
DataStore file lock made the debug-log header hang, and a silent fallback to
the default snapshot would have reported "never bought" for an install whose
evidence merely could not be read. Reads now fail loudly, writes fail soft.

UpgradeDiagnosticsGplay absorbs the pro-state history that the recorder header
used to read directly, with a separate failure boundary per source so one
broken DataStore cannot suppress the other's evidence.

RecorderModule's start-failure guard now covers ordinary exceptions, not just
cancellation, stops the uncommitted recorder under NonCancellable and appears
once instead of per resume branch.
2026-07-30 12:55:31 +02:00
darken e364a5b02c fix(upgrade): Cover widget entry refresh and recorder edge cases
- WidgetConfigurationActivity refreshes the entitlement on resume: it is a
  second launcher entry point and can't rely on MainActivity reconciling.
- The upgrade-return callback re-asks decideConfirm() instead of trusting the
  upgrade activity's result code, so RESULT_OK stays entitlement-gated.
- RecorderModule stops the freshly started recorder when the header's
  diagnostics reads are cancelled, instead of leaking an untracked recording.
- FOSS beta channel points at the GitHub releases page; the Play testing URL
  is signature-incompatible for FOSS builds.
- Billing bug reports carry the contextual wrapper again, so the report is
  grouped by call site instead of the raw billing exception.

Fixes review findings F1, F2, F3, F4, F5.
2026-07-29 14:05:26 +02:00
darken 3651bb3d55 refactor(upgrade): Converge GPlay billing on the canonical stack
Replaces capod's older billing core, upgrade UI and their tests with the
canonical sdmaid-se stack at the pinned revision.

Core (gplay): BillingManager/BillingConnection/BillingConnectionProvider on
billing 8.3 with the centralized connect loop, merging purchases-listener
overlay and the canonical ack pipeline; the dying ack collector, the
ackedTokens gate and the in-billing foreground loop are gone. Full canonical
exception set (internal/network/offer-unavailable added), OurSku with capod's
product ids, BillingCache with snapshot()/episode-guarded stampLastProState.

FOSS: UpgradeControlFoss becomes UpgradeRepoFoss and exposes the canonical API
surface over capod's RETAINED FossUpgrade/FossCache schema — existing supporter
records must keep decoding.

Diagnostics: UpgradeDiagnostics + gplay/foss implementations, read by
RecorderModule next to CurriculumVitae's Pro history as two independent,
isolated header reads.

UI: canonical upgrade screens for both flavors under common/upgrade/ui with
capod chrome (M3 AlertDialog keeping rotation-safety, capod Scaffold, capod
previews). Nav.Main.Upgrade gains `forced`. Entitlement refresh moves to a
per-resume, unthrottled MainActivity call.

Strings reuse capod's existing translated ids wherever equivalent; only
referenced-but-missing ones are authored.

mockk 1.12.4 -> 1.14.9: 1.12.4 cannot synthesize a sealed-class return value
while recording, which the ported restore tests need.
2026-07-29 14:05:26 +02:00
darken 8c1b57a47c refactor(upgrade): Adopt canonical entitlement interface and gates
UpgradeRepo gains the canonical shape: settledness rides each Info
emission, plus storeSite/upgradeSite/betaSite and a suspend refresh().
getSponsorUrl() is replaced by upgradeSite (FOSS only, GPlay keeps the
heart icon hidden). UpgradeRepoExtensions is the canonical file with
isPro/isProSettled/isProForUi.

UpgradeRepoGplay folds its parallel isSettled flow into Info.isSettled
(behaviour preserving) and implements refresh() as a bounded, unthrottled
call to the existing billing refresh. UpgradeControlFoss is settled from
its first emission and no-ops refresh().

Interactive gates move to isProForUi so a paying user isn't bounced to
the upgrade screen during the GPlay cold-start race: the device-settings
and press-controls pro gates, the theme setters, and the widget confirm
action, which now goes through a sealed ConfirmOutcome so the activity
can only return RESULT_OK for an entitled, valid configuration.
Presentation paths that can't reach a suspending gate (general settings
theme items, overview device limit) render the upgrade branch only when
the entitlement is hard-locked: settled, error-free and not pro.
2026-07-29 14:05:26 +02:00
darken 80ea6fbd68 General: Update app translations from Crowdin 2026-07-28 15:53:54 +02:00
darken c08c6f1129 refactor(strings): Move flavor-specific upgrade-status strings into flavor source sets 2026-07-24 17:53:19 +02:00
darken 73dc10aee9 fix(ui): Draw all screens edge-to-edge under system bars
Scrolling content now slides under the transparent status and
navigation bars instead of clipping at the inset boundary. Adds
PaddingValues.plus and systemBarsAndCutoutInsets helpers, moves inset
consumption from scroll viewports into content padding on every screen,
fixes reorder auto-scroll thresholds for content padding, adds IME
handling to form screens, and removes the unused EdgeToEdgeHelper.
2026-07-24 17:53:13 +02:00
darken c822569a63 fix(upgrade): Avoid red unavailable flash during billing warm-up
On entry upgradeInfo looks like a non-owner until Play reconciles, so if the SKU query resolves first the offers box briefly rendered the red "unavailable" card before the owner/grace status or prices appeared. Gate that card behind settled and !skuQueryInProgress and show a neutral spinner during warm-up.

Also label the restore-failed dialog's dismiss button "Close" instead of "Cancel" — it reports a result, it doesn't ask to abort an action.
2026-07-24 14:42:58 +02:00
darken c072b90876 feat(upgrade): Adopt SD Maid offercard layout and restore UX
Reshape the Google Play upgrade screen into SD Maid SE's offercard layout:
purchase options as titled offer rows (name · price, terms, action) with an
"or" divider inside one action card, extracted into gplay-local UpgradeContent
/ UpgradeOffers / UpgradeOwnership / UpgradeRestore primitives. Keeps capod's
icon benefits card, splash graphic, and floating back arrow.

Restore now mirrors SD Maid: a reusable restore section (emphasized for
returning buyers), verification-gated across all surfaces, and a restore-failed
dialog that leads with the live Play check and offers Contact support.

Billing logic is unchanged apart from onContactSupport() navigating to the
contact form. Offer rows render conditionally on offer availability; the offers
box AnimatedContent keys on an availability phase so same-state updates
recompose in place.
2026-07-24 14:15:12 +02:00
darken 36c54d5d19 fix(upgrade): Harden billing storage, restore races, and offer retry 2026-07-23 13:01:14 +02:00
darken 809305c4bd General: Update app translations from Crowdin 2026-07-22 21:14:42 +02:00
darken 061aa21c85 refactor(l10n): Move flavor-specific upgrade strings to source sets 2026-07-22 17:09:46 +02:00
Matthias Urhahn 825892df74 General: See your Pro status and switch from subscription to one-time purchase (#638)
* feat(upgrade): Add Pro status view, grace UI and sub-to-IAP switch

* fix(upgrade): Pad restore purchase to a minimum visible duration

* ui(upgrade): Mention Play-website install fix in restore troubleshooting

* fix(upgrade): Stop re-acknowledging already-acked purchases

* ui(settings): Move upgrade status row into the Other category
2026-07-22 15:40:57 +02:00
Matthias Urhahn f4facd3e86 fix(upgrade): Reconnect billing instantly on user actions 2026-07-14 16:43:58 +02:00
Matthias Urhahn c8179c5c19 fix(upgrade): Cut report noise, pace retries and fix trial wording 2026-07-11 21:32:06 +02:00
Matthias Urhahn bd36c5e5d6 fix(upgrade): Recover billing from stale purchase data and mid-flow errors 2026-07-11 12:15:17 +02:00
Matthias Urhahn 778d45170f feat(upgrade): Re-check purchases when the app comes to the foreground 2026-07-11 12:10:08 +02:00
Matthias Urhahn b52c2cbe6f feat(upgrade): Show restore banner and progress for returning Pro buyers 2026-07-11 12:05:06 +02:00
Matthias Urhahn 69febaac28 feat(upgrade): Keep one-time Pro buyers Pro through longer Play outages 2026-07-11 12:00:00 +02:00
Matthias Urhahn 2f1cbe34e5 fix(upgrade): Surface Google Play errors when a purchase can't start 2026-07-11 11:51:54 +02:00
Matthias Urhahn 8dcc4f8489 fix(upgrade): Harden purchase restore and billing error handling 2026-07-10 22:28:30 +02:00
darken e3bef4e74b General: Update app translations from Crowdin 2026-06-22 20:42:35 +02:00
Matthias Urhahn 948205667b General: Update translations for 75 languages (#600)
* General: Update app translations from Crowdin

* General: Include latest Polish community translation updates
2026-05-26 12:20:17 +02:00
darken 79886e0bfb General: Update app translations from Crowdin 2026-04-17 21:19:24 +02:00