Skip to content

General: Let subscribers switch to the one-time Pro purchase#2563

Merged
d4rken merged 21 commits into
mainfrom
worktree-pro-ownership-manage
Jul 20, 2026
Merged

General: Let subscribers switch to the one-time Pro purchase#2563
d4rken merged 21 commits into
mainfrom
worktree-pro-ownership-manage

Conversation

@d4rken

@d4rken d4rken commented Jul 14, 2026

Copy link
Copy Markdown
Member

What changed

Subscribers (e.g. trial users) had no way to switch to the one-time Pro purchase: while Pro was active, every path to the purchase screen was hidden — for up to ~3 weeks after cancelling.

  • Settings gains an always-visible "SD Maid SE Pro" entry (FOSS: "SD Maid SE FOSS") that opens an upgrade status screen with a congratulations header and a Google Play management shortcut.
  • The one-time purchase is always visible to subscribers, but locked until the subscription is no longer set to renew; a fresh Google Play check before every purchase blocks accidental double-billing.
  • The purchase options were redesigned into one compact card, and users kept Pro by the internal grace window see an honest status instead of a purchase pitch.
  • Restoring a purchase now gives feedback: a success toast, or a troubleshooting dialog with a contact-support option — and visibly takes a moment, matching the real Play check.
  • FOSS gets its own status view: free users see their status with a path to the supporter page, supporters get a thank-you with a recurring-donation suggestion.

Technical Context

  • Root cause: isPro treats any active purchase (including a cancelled but still-running trial) as Pro, and every upgrade entry point was gated on !isPro; Purchase.isAutoRenewing was never read anywhere.
  • The buy gate does not trust replayed state: onGoIap() re-verifies against a fresh SUBS-only query committed through the billing reducer, failing closed on error/timeout, with the single-flight guard held through the Play sheet launch.
  • Grace visibility is two-staged via a persisted "unconfirmed episode" (BillingCache.proUnconfirmedSince), stamped only from fresh full-snapshot reconciliations and query failures; diagnostics plus restore appear after 24h.
  • Known limitation: a client-only gate cannot see a renewing subscription on a different Google account — the disclosure copy tells users to use the same account.
  • Review focus: UpgradeViewModel.state and onGoIap(), UpgradeRepoGplay.recordProState/recordProUnconfirmed, and BillingConnection.querySubscriptions() — all connection-level behaviors are unit-tested.
  • The locale-file churn only deletes obsolete strings; new strings are English-only pending the next Crowdin cycle.

…one-time purchase

Users who started the trial subscription and cancelled it mid-trial had
no way to buy the one-time upgrade until the subscription (plus the
7-day grace window) lapsed: the upgrade screen auto-closed via navUp()
for any Pro user, and the dashboard upgrade card - the only persistent
entry point - is hidden while Pro.

- UpgradeRoute gains manage=false; manage routes skip the auto-close in
  both flavor ViewModels
- New always-visible settings entry (both flavors), title composed from
  app_name + app_name_upgrade_postfix so branding stays flavor-correct;
  replaces the FOSS-only sponsor row (strings removed from all locales)
- GPLAY upgrade screen gains an ownership presentation: renewing
  subscribers see status + a Play subscription-management deep link
  only (no one-time upsell); once Play reports the sub as non-renewing,
  the one-time purchase appears with a separate-purchase disclosure
- Hard double-billing gate: onGoIap() verifies against a fresh
  SUBS-only query (BillingConnection.querySubscriptions) and fails
  closed on error/timeout or a still-renewing sub; single-flight covers
  the whole tap-to-sheet window via the suspending launchBillingFlowNow
- Ownership renders independently of the product-detail queries: owners
  keep status/manage actions when price queries fail and no longer get
  price-query error dialogs; the settling wait is bounded so a starved
  billing layer degrades to the acquisition screen, not a spinner
@d4rken d4rken added enhancement New feature, request, improvement or optimization General UI/UX User Interface/Experience Google Play FOSS labels Jul 14, 2026
d4rken added 11 commits July 14, 2026 18:23
On the ownership screen a successful restore changed nothing visible (the user already is Pro), so tapping 'Restore purchase' read as broken. Emit RestoreSucceeded and show a confirmation toast (same pattern as the FOSS flavor's toast events), and give both restore text buttons the same in-progress spinner the restore banner button already had.
…semantics

Pro-via-grace users (Play temporarily not reporting their purchase, or a
genuinely lapsed sub/refunded IAP) previously saw the plain purchase
screen on the 'upgrade status' entry - contradicting the rest of the
app, which still behaves upgraded.

Two-stage grace presentation: immediately on grace the screen keeps the
neutral status title and shows a calm 'Pro is still active' card; the
diagnostic explanation plus inline restore action only appear once the
episode has lasted 24h (BillingCache.proUnconfirmedSince, stamped
exclusively from fresh reconciliations incl. query failures and refresh
timeouts, Mutex-serialized against the confirmation path, set-if-unset
with stale/future-stamp replacement). A boundary tick re-evaluates the
threshold since all other state flows are distinct-until-changed. Grace
users keep the Loaded presentation when offer-detail queries fail
instead of dropping to Unavailable.

Restore semantics: success (toast) now requires actual returned
purchases - a grace-only result shows the troubleshooting dialog. The
already-owned auto-restore likewise only counts as reconciled when the
restored purchases contain the SKU Play claimed was owned.
…p-manage

# Conflicts:
#	app/src/gplay/java/eu/darken/sdmse/common/upgrade/core/BillingCache.kt
#	app/src/gplay/java/eu/darken/sdmse/common/upgrade/core/UpgradeRepoGplay.kt
#	app/src/gplay/java/eu/darken/sdmse/common/upgrade/core/billing/client/BillingConnection.kt
#	app/src/gplay/java/eu/darken/sdmse/common/upgrade/ui/UpgradeScreen.kt
#	app/src/gplay/java/eu/darken/sdmse/common/upgrade/ui/UpgradeViewModel.kt
#	app/src/testGplay/java/eu/darken/sdmse/common/upgrade/core/UpgradeRepoGplayTest.kt
#	app/src/testGplay/java/eu/darken/sdmse/common/upgrade/ui/GplayUpgradeViewModelTest.kt
…urchase-event races

Post-merge review fixes: the pre-purchase SUBS verification now returns
the reducer-committed view (a purchase event arriving after the query
started survives as a newer overlay and must reach the gate); an empty
complete refresh only counts as proof of total absence when no overlay
survived the commit (an empty query racing a fresh purchase event must
not start a false unconfirmed-grace episode); and the ownership
screen's buy button pauses during restores like the acquisition
buttons do.
…p-manage

# Conflicts:
#	app/src/gplay/java/eu/darken/sdmse/common/upgrade/core/UpgradeRepoGplay.kt
Add BillingConnectionTest coverage for querySubscriptions() -- the
pre-purchase double-billing gate -- and the refreshPurchases()
proves-absence rule:

- A fresh SUBS result commits without disturbing the IAP snapshot and
  emits a partial FreshUpdate; the queried product type is asserted via
  the params' zza() accessor since clientReturning ignores params.
- A failed gate query propagates BillingClientException, commits
  nothing, and emits no fresh update.
- A purchase event racing the gate query wins over the query's stale
  renewal state for the same token.
- IAP-typed overlay entries are excluded from the gate view, untyped
  ones stay in; an older sub overlay is superseded by a verified-empty
  gate query; PENDING results are filtered.
- An empty complete refresh racing a purchase event is not a full
  snapshot (it would start a false unconfirmed-grace episode).
The settings "upgrade status" entry (UpgradeRoute(manage = true)) now
shows a status view on FOSS instead of dropping straight into the
donate pitch:

- Free users see their version status with a button that opens the
  familiar pitch; the choice is SavedStateHandle-backed so process
  recreation keeps them on the pitch they asked for.
- Supporters see a thanks card plus a recurring-donation hint with a
  GitHub Sponsors shortcut, reusing the existing sponsor-return
  machinery.
- The dashboard upsell route renders the pitch unchanged, including
  its pre-route-bind frame.

View selection lives in the FOSS UpgradeViewModel as a
StateFlow<FossUpgradeView?> derived from route, upgrade info, and the
show-options flag; upgrading always wins over the pitch choice so a
completed sponsor flow lands on the upgraded status.
…r dedup

The hasShownPartialQueryError flag is deliberately left untouched when a
priceIndependent user (owner/grace) has a failed detail query: resetting
it there would re-emit an already-surfaced failure episode if ownership
lapses again within the same episode. Comment-only change so the
asymmetric else-if doesn't get "fixed" into duplicate error dialogs.
…scape hatch

Restore purchase is account reconciliation, not an offer -- it no
longer dangles as a bare TextButton under the purchase options:

- Plain acquisition users get a described "Already bought Pro?" section
  below the offers explaining that restoring re-checks purchases with
  Google Play and does not start a new purchase.
- The returning-buyer banner became the emphasized variant of the same
  section and is the only restore affordance for that audience.
- The ownership view frames restore as "Status looks wrong?" instead of
  a bare utility button.
- Every variant carries a "Contact support" action opening the guided
  support form; the restore-failed dialog now leads with the fact that
  a live Play check just happened (restore always re-queries before the
  dialog can appear) and offers contact support as its primary action.
- Grace users keep the grace card's two-stage restore disclosure and
  get no extra restore CTA.
The restore-section cards no longer carry their own "Contact support"
action -- escalation lives solely in the failed-restore dialog, which
only appears after a live Play re-query found nothing. Self-service
gets its chance before the support inbox, and the section cards go
back to a single clear action.

The dialog itself shrinks from six paragraphs to four: the "valid
across devices" line read contradictory right after the "could not be
confirmed" lead, and the troubleshooting header was filler. The
multi-account tip moves first (most common real cause). Both dropped
strings are removed from the base and all locale files.
The subscription and one-time offers now render as flat rows inside a
single "Upgrade options" card: title and price share one line, terms
follow as body text above each button, an "or" divider separates the
offers, and "Same features, just different pricing models." closes the
box as a footnote. The old design's nested cards, headline prices,
price-restating hint sentences, and the unexplained "Recommended"
badge are gone; the ownership view's buy-once offer uses the same row
design. Copy stops over-promising ("One payment, no renewals" instead
of "yours forever") and drops the em dashes.

The gplay screen file is also split up: UpgradeOffers.kt holds the
offers box with previews for isolated iteration, UpgradeUiState.kt
holds the render-state model and mappers, UpgradeScreen.kt keeps host,
screen, and acquisition content. Legacy components (UpgradeOfferCard,
UpgradeBadge, the choice-divider wrapper) are deleted and stale or
verbose comments trimmed.

Also includes small copy tweaks to the restore dialog hints.
@d4rken
d4rken force-pushed the worktree-pro-ownership-manage branch from 23dd0d8 to 38e38ef Compare July 19, 2026 17:24
d4rken added 9 commits July 19, 2026 19:45
A fresh Play query can finish in well under a second, which reads as
"nothing was checked" and undermines the restore result -- the dialog
even claims a check just happened. Manual restores now run a 1.5s pad
CONCURRENTLY with the real query: fast checks are stretched to a
believable length, slow ones gain nothing, and the timeout path is
unaffected. The repo's invisible auto-restore recovery stays fast.
… test

- The status title highlights the flavor postfix in the upgraded color,
  exactly like the dashboard title card (composed from app name and
  postfix; the per-flavor manage-title strings are gone). FOSS colors
  it only on the upgraded status view.
- Renewing subscribers now learn HOW to switch: a hint under the
  subscription status explains that the one-time purchase appears here
  once the subscription is cancelled in Google Play. Previously the
  double-billing gate hid the offer without explaining the path.
- Owners get a congrats hero card at the top of the status screen --
  mascot inside the card, "Congratulations, you have SD Maid SE Pro!"
  and the unlock variant (subscription or one-time purchase, with the
  permanent purchase winning when both are owned). The standalone
  mascot header is skipped for owners.
The hero card puts the mascot on the left with title and description
stacked beside it ("Congratulations!" plus the unlock variant naming
the app), instead of the taller centered column.

The switch path is now a visible artifact instead of prose: any
subscriber without the one-time purchase sees the offer card, with the
buy button disabled and an unlock note while the subscription is still
set to renew. Cancelling in Google Play enables it in place. The
ViewModel's fresh-SUBS verification gate keeps failing closed behind
the button regardless.
The quiet grace stage explains itself as a routine process ("Waiting
for Google Play to re-confirm your purchase") instead of a
context-free reassurance whose "still active" implied trouble it
refused to name. The 24h diagnostics body is tightened accordingly.

Grace users also stop seeing the sales pitch: they are Pro, and the
preamble and benefits cards next to a "still active" card read as a
contradiction. The offers stay -- a user whose subscription expired
must be able to switch to the one-time purchase immediately, not
after the grace window lapses.
The client cannot tell a Play blip from a lapsed purchase, so episode
age is the arbiter: a young unconfirmed episode (likely self-healing)
shows only the calm grace card, an aged one adds restore AND brings
the offers back -- an expired subscriber can still switch without
waiting out the full grace window, at a bounded 24h worst case.

The grace card is retitled "Confirming your Pro purchase" with "Pro is
still active" leading both stage bodies -- the old title implied
trouble it refused to name.

The Play-unavailable error no longer nests a card in a card: each
offers-box state brings its own container, so the error card stands
alone.
Give the quiet grace stage a small indeterminate progress indicator in
the card header, replacing the static Verified icon, so the 'Confirming
your purchase' title is backed by visible motion. The aged diagnostics
stage keeps the static icon: its copy asks the user to restore, and a
spinner would contradict that call to action.

UpgradeSectionHeader/UpgradeSectionCard gain an optional leading slot
that overrides the icon when set. Also shortens the grace title to
'Confirming your purchase'.
The 'Back already?' snackbar belongs to the unlock heuristic for free
users. An already upgraded user tapping the recurring donation button
and returning early now gets no feedback instead of a nudge that reads
as a guilt-trip. The late-return path is unchanged: repeat donations
still refresh the upgrade and show the thanks toast.
The English hint was trimmed to only recommend installing through the
Google Play website, but all 79 locale translations still carried the
old advice to sign out of other accounts, which is painful and rarely
needed. Removing them falls back to the current English until Crowdin
catches up.
Once a grace episode reaches the diagnostics stage the upgrade screen
header swaps sdm_happy for sdm_not_happy, the same 'needs your
attention' face the setup dashboard card uses. The quiet stage keeps
the happy mascot: its message is that nothing is wrong. UpgradeMascot
and UpgradeHeader gain a happy flag defaulting to true, so FOSS and
the owned hero are unaffected.
@d4rken
d4rken merged commit 22ad2c2 into main Jul 20, 2026
13 checks passed
@d4rken
d4rken deleted the worktree-pro-ownership-manage branch July 20, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature, request, improvement or optimization FOSS General UI/UX User Interface/Experience Google Play

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant