Upgrade: Offer an optional Pro upgrade for history, widget, and tile - #57
Merged
Conversation
Introduces the flavor-agnostic `UpgradeRepo` (storeSite/upgradeSite/betaSite, `upgradeInfo: Flow<Info>`, `refresh()`) that the gplay Play-Billing and FOSS GitHub-Sponsors implementations will both satisfy. Settledness rides each `Info` emission rather than a parallel flow, so a settle signal can never be observed against stale ownership data. Two gate helpers with deliberately different failure directions: - `isProSettled` is the backend safety net. It spends one budget on the whole reconciliation (refresh round-trip plus wait) and only denies on a settled, error-free, purchase-free state. A billing hiccup must never block a payer. - `isProForUi` is the navigation gate. It resolves immediately for a known Pro user and for a settled non-Pro one, and only waits out the billing handshake race, so a free user's tap routes to the upgrade screen without a stall. Supporting pieces: `WebpageTool` (reports whether a page actually opened, which the FOSS sponsor-return heuristic depends on), `InstantSerializer` for the persisted upgrade timestamps, and the Play Billing dependency wired to the gplay flavor only.
Both flavors get an entirely separate upgrade screen, so the layout primitives they share live in src/main: the scaffold, the width-capped content column, the hero card (which stacks its brand mark above the copy once the text column would be squeezed — the threshold scales with the font scale, not just the screen width), the section/action/inline-state cards, and the bullet-parsing feature list. The brand title is composed through a two-slot template and spliced into the already-formatted translation, so a translator owns the word order and punctuation of "Get Amply Pro" while the tier word keeps its highlight. A template that lost or doubled a slot is discarded whole rather than patched, or we would render a title nobody wrote. Also adds ProBadge, the marker the gated affordances elsewhere in the app will carry. It reads the flavor postfix, so the FOSS build says FOSS and the Play build says Pro.
The FOSS flavor has no store to verify a receipt against, so the unlock is trust-based: opening the sponsor page and staying there for at least five seconds records a local unlock. Three rules make that defensible. The heuristic only arms when the page actually opened — WebpageTool reports that, so a device with no browser can't have an unrelated later background/foreground round-trip mistaken for a sponsor visit. The pending launch is tracked in the SavedStateHandle rather than in the composition, so a process death while the browser is in front doesn't swallow the return. And the record is written create-only-if-absent inside the store transaction, so a repeat visit never overwrites the user-visible "supporter since" date; the ViewModel's isPro fast path is only a UX shortcut, never the integrity guard. A failed entitlement read or write restores the consumed marker so a genuine visit can still be redeemed on the next return, and the entitlement flow keeps the last known state when a late cache read fails — revoking a supporter's unlock over a transient storage error would be worse than showing it with an error attached.
Adds the Google Play entitlement: a yearly subscription (with a trial offer when Play returns one) and a permanent one-time purchase, either of which unlocks the same features. The billing connection keeps ONE atomically-updated ownership state. That is what makes the ordering rules expressible: a purchase event proves presence but never absence, a per-type query is authoritative only for the type it covered, and a partial result stays authoritative for what it found without being read as proof that nothing else exists. Consumers read the committed state, so the reactive flow and a refresh's return value can never disagree. Pending purchases are carried alongside the owned ones instead of being dropped. They grant nothing and are never acknowledged — acknowledging one is a protocol error — but the UI needs them: an offer row for something the user is already paying for must not invite a second payment, and an empty restore reads as a lost purchase rather than one still in flight. Entitlement survives a Play hiccup through a local grace window, seven days for a subscription and thirty for the permanent purchase, stamped only from fresh Play round-trips so replayed data can't keep a refunded purchase alive. The window requires the age of the last confirmation to be non-negative: a bare "younger than the window" test accepts a device clock moved backwards and would hand out an open-ended upgrade to anyone who set their clock back. Billing failures stay typed all the way down and are mapped to copy in one place in the UI layer, so a new failure mode cannot fall through to a raw stack trace. Mockk is added as a test-only dependency: the Play client and its connection are final classes we neither own nor can instantiate on the JVM.
Charge control itself — the temporary full charge, the reconnect gesture, the notification actions — stays free. What the upgrade unlocks is charge history, the Quick Settings tile and the home-screen widget. Only *enabling* history is gated. Turning it off, viewing what was recorded and clearing it never are: an entitlement that lapses must not strand a user's data behind a paywall or keep a service running they want stopped. The check also runs before the notification-permission prompt rather than after it, so nobody is put through a system dialog only to be refused. The tile renders as inactive-with-an-explanation rather than unavailable, because SystemUI draws an unavailable tile as inert and the whole point is that the tap leads somewhere. The widget renders a locked composition instead of blocking placement — a widget the launcher refuses to place is a worse experience than one that says what it needs — and every widget action re-checks the entitlement, since a Glance composition can be minutes stale by the time it is tapped. Because both surfaces render from stale snapshots and nothing else pushes them when an entitlement lands or lapses, a small startup singleton watches for settled transitions and refreshes them. Only settled ones: Play reports non-Pro until billing connects, so reacting sooner would flash the locked rendering at a paying user on every cold start. Robolectric now defaults to a plain Application. It previously stood up the real one per sandbox, which on the Play flavor meant ~120 live billing connections in a single test JVM — enough to exhaust the shared native runtime and fail unrelated Compose tests. No unit test needs the production Application; the one test that needs a Hilt graph already asks for HiltTestApplication explicitly.
The Play data-safety form now answers "yes" to in-app purchases, with the reason the "no data collected" answer is unaffected: purchases are processed by Google Play, Amply never sees payment details and has no server to send them to, and the only thing kept locally is the entitlement bookkeeping that carries the upgrade through a Play outage. The privacy policy states the same in user-facing terms, and says explicitly that the FOSS build contains no billing code at all. The store description and README mark which features the upgrade unlocks so the split is visible before install rather than discovered at a locked button — and both restate that charge control itself stays free.
The connect loop owns every retry decision in the billing stack, so what it does when Play is unreachable decides whether the entitlement gates ever resolve during an outage: it has to settle the failure signal (otherwise "Play is down" is indistinguishable from "still connecting"), report each failure with its own occurrence time (the grace bookkeeping orders failures against confirmations), wait out its backoff rather than spin, and cut that backoff short when someone actively wants billing. On the screen side, the two decisions that cost money if they are wrong: a one-time purchase must be refused while a subscription still renews AND when the subscription check could not be completed at all — "couldn't verify" read as "no subscription" is exactly what lets a user pay twice — and a restore must keep a completed-but-empty check apart from one that never got an answer, since only the first justifies telling an owner that nothing was found. The manager's Play round-trips are `open` so a test can answer them: they all terminate in Play's BillingClient, which cannot be stood up on the JVM.
check_metadata_length.sh used `sed -z` to strip the trailing newline before counting, which only exists in GNU sed — BSD sed aborts with "illegal option -- z", so the check could not be run locally on macOS. Count with `wc -m` over the whole file and subtract one character when the last byte is a newline, detected via `tail -c 1` (command substitution strips a trailing newline, so an empty result means the file ends with one). Both are POSIX and behave identically on macOS and on CI's Linux runners. `wc -m` stays the counting primitive deliberately: it honours LC_ALL and counts characters, whereas awk's length() is byte-based in mawk (the default awk on the CI runners), which would have inflated the counts for the non-ASCII metadata. Limits, checked files and pass/fail semantics are unchanged; the counts for the current en-US metadata are identical (title 5, short description 42, full description 2729, changelog 204), including the single-trailing- newline, CRLF and empty-file edge cases.
The write-level re-check used the navigation gate (isProForUi), which resolves fast but denies as soon as billing reports settled-and-not-pro - including a settled error state. The write is the enforcement point, so it now uses the backend gate (isProSettled): it reconciles a cold-start billing race before denying and fails open on an error, so a Play hiccup can never refuse a paying user. requestEnableCapture keeps the navigation gate. The capture-denial route also stopped reading the origin off the live destination: the collector was keyed on `destination`, so it restarted on every navigation (losing a denial emitted in the gap) and could send the user back to whichever screen they had reached meanwhile. The requesting surface is now recorded when the request is made, and enterUpgrade refuses to record the upgrade screen as its own return target. Fixes review findings F2, F3
Three corrections around purchases Play has not completed yet: A pending purchase proves no ownership, but combinePurchaseResults counted it as "found" and swallowed the sibling product type's query failure - so a pending one-time purchase next to a broken subscription query read as "verified, nothing owned" and a real subscription behind that failure looked lapsed. The failure now propagates unless something actually PURCHASED was found; pending entries are still returned for the UI whenever nothing failed. The two products are alternatives for the same entitlement, so a pending payment for either now disables both buy actions (previously only its own row), and both rows carry the payment-pending note so neither is a silently dead button. The total-failure state no longer also emits an error event: the Unavailable state it returns already renders the failure inline with a Retry, so the snackbar reported the same thing a second time. Fixes review findings F4, F5, F8
The promo card only rendered in the supported-device branch, so a device Amply cannot control - the users most likely to be told about the widget and tile shortcuts - never saw it unless a contribution was also wanted. It now renders in both branches; they are mutually exclusive, so the list key stays unique. Pro badges are also settled-aware now instead of keying on a bare !isPro. The recording opt-in, the quick-access buttons and the settings charging-history row take an explicit flag computed from the same settled-and-not-Pro condition as the promo card, so a paying user no longer gets them flashed at them on cold start while billing connects. The settings tier row's Active/Free subtitle keeps plain isPro - it is a status readout, not an ask. Fixes review findings F6, F7
The error is already smart-cast to non-null by the surrounding check, so the safe call produced a compiler warning without changing behavior.
Inside the '!inGrace ||' branch the compiler already smart-casts loadedState to GplayUpgradeUiState.Loaded, so the '?.' on it was an unnecessary safe call on a non-null receiver.
Inside the `!inGrace || …` branch the smart cast already proves `loadedState.grace` non-null, so the safe call and the `== true` comparison on the plain Boolean only produced a compiler warning.
Brings in the GrapheneOS charge-limit adapter, the plug-latched awaiting-replug pending state (dashboard/tile/widget), the charging speed/wattage presentation, and the battery hub's wattage rows. Git merged every overlapping file cleanly; the only adaptation was to main's new BatteryHubScreenTest, which calls BatteryHubScreen without the showProBadge parameter this branch added. Its cases render with recording already enabled, so the badged opt-in card never appears and showProBadge = false matches the scenario.
The badge is a one-word pill, but its label inherited the default wrapping, so a narrow caller or a large font scale could break it across two lines inside the surface. Pin it to one line and disable soft wrapping.
AmplyCardHeader had exactly one slot for extra content, the floated trailing control, which is the wrong place for a badge that qualifies the title: it lands at the far end of the row, next to (or instead of) the card's actual control. Add a titleAccessory slot that renders inline right after the title text in the leading group. The title becomes weighted with fill = false while an accessory is present, so the unweighted accessory is measured first and keeps its intrinsic width and a long title wraps instead of pushing the marker out of the row.
Both shortcuts need the same upgrade, so a badge inside each button stated it twice — and the badges took the width from labels that already have to wrap inside those weighted buttons. The card carries one badge next to its title instead; the buttons are text-only again. The badge goes in the header's new inline title slot rather than the trailing slot, which holds the dismiss button and reserves only a 96dp minimum title width.
The badge states a condition of the feature, not a part of the action, so it now sits beside the card title and the enable button is text-only again. Inside the button it also stretched the action past its own label.
The row is a destination, not a preference: at the top of the screen it read as the app's first setting and pushed the actual settings down. It now heads the Other category, above Support, where the rest of the non-preference destinations already live.
Both cards carry exactly one action, but that action lived in a small button in the corner, so the rest of the card — the part that explains the action — was inert. Both now route through AmplyClickableCard: the surface performs the action and carries its accessibility label, and the former button is a flat, non-interactive action label. One tap semantic per card, no nested button that could double-fire or leave a dead zone. The Shizuku banner also gains a header with the setup guide's wrench, so the two setup surfaces read as the same kind of card, and its click label follows the action it actually performs (allow vs. open).
The dashboard's title was always the plain app name, so an upgraded user got no acknowledgement anywhere they actually spend time. It now splices the flavor postfix in, coloured, through the same brand-title helpers the upgrade screen and the settings row use, so the word order stays translator-owned. Both flags are gated on a settled, upgraded entitlement: free and not-yet-resolved (the state of every cold start) render the plain name, so the title can never claim an upgrade and take it back a frame later.
The upgrade ViewModel is activity-scoped, so its per-visit binding outlived the screen. Entering the settings status view while the previous visit's pitch was still bound made an upgraded user's screen render the pitch for a frame and then close itself again, because the auto-dismiss only looked at the view and the entitlement. Three fixes, all needed together: - The dismiss condition now also requires the plain entry (not manage), so a status entry can never dismiss itself on a stale pitch. - The visit binding is keyed on the manage flag, not Unit: the composition root can change it in place while the screen stays composed (a widget's open-upgrade intent arriving on the open status view), which a Unit key would never rebind. - Leaving the screen releases the binding via onVisitEnd(), so the next entry decides from its own manage flag. It sets the key to null instead of removing it: SavedStateHandle.remove() detaches the getStateFlow instance the state combine captured, after which later writes would update a fresh flow the combine never sees. The host's view selection and dismiss decision are extracted as pure functions so the stale-state cases can be asserted independent of frame order. Not applied to the gplay flavor: it has no visit-backed view state, and resetting its error-episode booleans mid-flow risks re-emitting the same episode.
The dash was doing punctuation work everywhere in the app's copy, which reads as a tic once it appears in twenty strings, and it is awkward for translators who have to decide what it means in their language. Every user-visible use is now a comma, colon, or sentence break with the meaning unchanged. Covered: the string resources of all three source sets (main, foss, gplay) and the contribution report's prose and generated issue title, which a user reviews and then posts publicly. An rg sweep of app/src leaves matches only in categories that are deliberately untouched: code comments and KDoc (including the XML, AIDL and robolectric.properties comments), log strings, preview-only fixture text, a backtick test name, and the standalone dash used as a missing-value glyph in the stats and diagnostics tables.
The visit-binding test only checked the StateFlow's value between steps, so a release that never emitted would still have passed. Assert on the collected stream instead: the neutral state and the plain entry's pitch both have to be handed to a collector, because that is what a composed screen renders.
The card no longer names individual features. The upgrade screen is the single source of truth for what the upgrade contains, so the dashboard promo cannot drift out of sync with it.
Pre-launch decision: build a user base before asking for the upgrade on the dashboard's main surface. Both render sites of UpgradePromoCard (the unsupported branch and the supported branch, after the quick-access promotion) are removed, so the dashboard no longer carries an upgrade ask of its own. The upgrade stays discoverable through the Pro badges, the locked tile/widget, and the settings row. shouldShowUpgradePromo still gates those badges, and the DashboardScreen onUpgrade parameter plus its MainActivity wiring are kept, so reintroducing the card after launch is a one-line change. The composable, its previews, strings, and component test are untouched.
Brings in HyperOS 3 support, GrapheneOS Shizuku gating, the qualification ledger update, the fixed-limit hardware warning, the tile applying hint, the dashboard capability note, and the 0.3.2-beta0 release bump. Conflicts: - app/src/main/java/eu/darken/amply/main/ui/dashboard/DashboardScreen.kt: import-block collision only; kept both icons (our twotone Build for the upgrade promo, main's filled WarningAmber for the hardware-unconfirmed warning). The capability-note card and the Pro title/banner/badges merged cleanly and are both retained. - app/src/main/res/values/strings.xml: took main's side for the GrapheneOS adapter detail (grapheneos_no_key dropped, grapheneos_ready rewritten for the Shizuku gate), which supersedes our em-dash rewrite of the old text. Applied the same em-dash rewrite to main's new dashboard_hw_unconfirmed. Auto-merged with both intents kept: the tile now shows main's applying hint inside the subtitle detail while the Pro gate still governs tile state, label, and the click target. version.properties and VERSION carry main's bump.
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
Amply now offers an optional Pro upgrade. Charge control stays free: the temporary full charge, the reconnect gesture, the notification actions, and the charge alarm all work without paying. Pro unlocks recording charge history, using the Quick Settings tile, and adding the home-screen widget.
On Google Play the upgrade is a yearly subscription (with a free trial) or an equivalent one-time purchase, handled entirely by Play Billing with purchase restore and a grace period for outages. The FOSS build contains no billing code: sponsoring development on GitHub Sponsors unlocks Pro permanently on that device.
A new upgrade screen carries the pitch, purchase options, ownership status, and restore. Gated features route free users there instead of failing silently: the tile opens it on tap, a free user's widget renders as a locked card that opens it, and enabling history capture asks before any system permission dialog appears. A dashboard card promotes the upgrade while it is not active, and the settings screen gains a status row. The store listing, data-safety declaration, privacy policy, and README now describe the free/Pro split.
Technical Context