General: Let subscribers switch to the one-time Pro purchase#2563
Merged
Conversation
…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
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
force-pushed
the
worktree-pro-ownership-manage
branch
from
July 19, 2026 17:24
23dd0d8 to
38e38ef
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Technical Context
isProtreats any active purchase (including a cancelled but still-running trial) as Pro, and every upgrade entry point was gated on!isPro;Purchase.isAutoRenewingwas never read anywhere.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.BillingCache.proUnconfirmedSince), stamped only from fresh full-snapshot reconciliations and query failures; diagnostics plus restore appear after 24h.UpgradeViewModel.stateandonGoIap(),UpgradeRepoGplay.recordProState/recordProUnconfirmed, andBillingConnection.querySubscriptions()— all connection-level behaviors are unit-tested.