Skip to content

Upgrade: Offer an optional Pro upgrade for history, widget, and tile - #57

Merged
d4rken merged 28 commits into
mainfrom
feat/pro-upgrade
Aug 14, 2026
Merged

Upgrade: Offer an optional Pro upgrade for history, widget, and tile#57
d4rken merged 28 commits into
mainfrom
feat/pro-upgrade

Conversation

@d4rken

@d4rken d4rken commented Aug 13, 2026

Copy link
Copy Markdown
Member

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

  • This ports SD Maid SE's upgrade system as the canonical reference: the same repo abstraction, the same subscription/one-time split with 7-day/30-day grace windows, and the same trust-based FOSS unlock (return from the sponsors page after 5 seconds). It introduces the project's first per-flavor source sets; the Play Billing dependency exists only in the gplay flavor.
  • Three deliberate deviations from the canonical implementation: grace ages are clamped to the window (a rolled-back clock can no longer extend grace indefinitely); pending slow payments are represented and disable both offer rows (buying the alternative product while one payment is pending would double-charge); both persisted records decode corrupt values as absent, so a damaged record can always be repaired by re-unlocking or a fresh Play result.
  • The entitlement gates fail open toward paying users: denial requires a settled, error-free "not Pro" answer. An entitlement lapse never stops a running capture, and viewing or clearing already-recorded history is never gated.
  • The tile renders as inactive with an unlock subtitle rather than unavailable, because unavailable tiles receive no taps; widget placement cannot be refused on Android, so gating happens through the locked rendering and the in-app add buttons.
  • Review focus: the Play billing core (the purchase-state reducer in BillingConnection and the connection/acknowledgement lifecycle in BillingManager) is the highest-risk surface. Real purchase flows cannot run until the products exist in Play Console; an internal-track pass with license testers is required before release. Emulator smoke tests covered both flavors' gating and the complete FOSS unlock flow on-device, which CI cannot.

d4rken added 14 commits August 12, 2026 13:41
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.
@github-actions github-actions Bot added FOSS FOSS flavor / F-Droid distribution Google Play Google Play flavor / distribution Build/Deploy Build system / CI / release tooling enhancement New feature or request labels Aug 13, 2026
d4rken added 12 commits August 13, 2026 11:12
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.
d4rken added 2 commits August 14, 2026 10:31
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.
@d4rken
d4rken merged commit e9a2f28 into main Aug 14, 2026
12 checks passed
@d4rken
d4rken deleted the feat/pro-upgrade branch August 14, 2026 09:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Build/Deploy Build system / CI / release tooling enhancement New feature or request FOSS FOSS flavor / F-Droid distribution Google Play Google Play flavor / distribution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant