Skip to content

Offer past buyers a free unlock, once, and keep a way to reach support - #2

Merged
StaticHumStudio merged 5 commits into
masterfrom
feat/paid-buyer-claim-path
Aug 21, 2026
Merged

Offer past buyers a free unlock, once, and keep a way to reach support#2
StaticHumStudio merged 5 commits into
masterfrom
feat/paid-buyer-claim-path

Conversation

@StaticHumStudio

Copy link
Copy Markdown
Owner

What

Offers a free unlock, once, to anyone who bought Nine Lives back when it was a paid app, and
gives them a way to reach support if they miss the offer.

  • A one-time claim dialog. Shown when the entitlement picture says free but the install
    predates the free flip. Tapping through opens a prefilled support email. Declining retires the
    offer permanently, and the dialog says so.
  • A direct contact row in Settings with an explicit copy button, so the address survives the
    dialog being dismissed.
  • Two settle-wait fixes plus a conditional settle gate in BillingManager, so the claim
    prompt never acts on a provisional reading of a user's purchase state.

Why the gate is conditional

The prompt decides on whatever Play tells us. The distinction that matters is between Play
saying "no" and Play saying nothing:

  • OK settles the gate.
  • A hard no (BILLING_UNAVAILABLE, DEVELOPER_ERROR) settles it. A retry says the same thing.
  • SERVICE_DISCONNECTED, SERVICE_UNAVAILABLE, NETWORK_ERROR do not, because
    auto-reconnection plausibly fixes those inside the consumer's own bound.
  • A 30s timeout settles it. Play had the whole window.

Entitlement application is untouched, so the tested asymmetry in PurchaseEvaluator (only a
successful query may revoke) holds exactly as before. Only the timing of the gate changed.

The asymmetry this accepts, stated rather than buried

Over-showing the dialog costs someone a tap. Under-showing it costs a real buyer an unlock they
never learn was offered, permanently, because the prompt is one-time. The code is deliberately
biased toward showing it. A device that cannot reach Play is a legitimate state, and a past buyer
on one still deserves the offer.

Consequence worth flagging for review: with the standing Settings claim row removed, someone who
declines the dialog has no claim-specific route left. The direct contact row is now load-bearing.
There is a comment where the old row used to be saying that if the contact row ever goes away,
this decision has to be revisited.

Testing

  • 423 unit tests, 0 failures. assembleDebug clean, no unused-symbol warnings.
  • Canary, policy, both directions. Forcing shouldPrompt false turned the two
    buyer-is-offered tests red. Forcing it true turned all four guard tests red.
  • Canary, settle gate, on device. This change makes the gate harder to settle, and the
    failure mode of that is a prompt that silently never arrives. Forcing queryAndApply to never
    answer suppressed the prompt at t+5s, +10s, +15s and +20s, then the ViewModel's 35s backstop
    released it at t+25s. Proves the gate gates, and that the backstop prevents a hang. Reverted,
    zero markers left, prompt back at t+5s.
  • Device pass on SM-S948U: fresh install prompts at about 14s, no re-prompt after cold
    restart, email prefills, clipboard output verified by paste readback rather than by reading the
    code.

Review history

Three independent codex rounds ran against this code: one clean, one P2 fixed in the final
commit, one P2 declined with written reasoning and device evidence. The code in this branch is
byte-identical to the reviewed tree (git diff <reviewed-head> HEAD -- app/ is empty); the
branch was rebased onto the new master, which only removed documentation.

🤖 Generated with Claude Code

StaticHumStudio and others added 5 commits August 20, 2026 14:50
…ttings

On 2026-08-16 a stranger bought the paid app. Jeff's own April purchase is
in the same boat: his production install is Play-signed and never received
the sideloaded build that wrote legacy_paid. Nothing that ever shipped to
Play writes that flag, so both of them land on the free tier when 2.1.0
reaches production. The trigger is the ROLLOUT, not the price flip. The
resolver never consults the store price.

Their Play order identifiers are deliberately not recorded anywhere in this
repo, which is public. An earlier version of this branch carried them in
comments, a test and a commit message. That was a mistake and this history
is the correction.

The plan is a refund plus a free unlock code, not code that guesses who
paid. A date-gated grandfather writer was built earlier today and thrown
away on purpose: it worked, but it was only safe while a human remembered
to flip the price after a compiled-in cutoff, and one forgotten ordering
rule would have marked every free install paid-for-life and quietly ended
the paid tier. Money back beats clever code.

So this ships the channel instead of the mechanism. PaidEraClaimPolicy
decides whether to offer the claim, and a one-time dialog at the top level
carries it, with the Settings row as the permanent path for anyone who
dismisses it and changes their mind.

The same firstInstallTime date logic that was too dangerous in a writer is
fine here, and the asymmetry is the whole argument. Nothing in this grants
entitlement. A wrong date costs somebody a dialog they dismiss once, not
the existence of the paid tier. Which flips the bias: over-showing is
cheap, under-showing costs a real customer an unlock they will never learn
was on offer, so the cutoff is set generously past the expected flip rather
than tightly against it.

Entitlement is OBSERVED rather than read once, because Billing answers
asynchronously during startup. A single read would race queryPurchasesAsync
and could offer a claim to somebody whose purchase had simply not landed
yet. The claim waits for the first purchase query to settle before it will
offer anything on a provisional free reading.

Two bugs in that wait, both found by review rather than by tests:

purchaseQuerySettled was set at the exits inside queryAndApply(), which
withTimeoutOrNull cancels before they run, so the 30s timeout path never
settled and anything waiting on it waited forever. It now lives in
refreshPurchases's finally, the one place cancellation cannot skip.

SETTLE_TIMEOUT_MS was 8s against a 30s billing timeout, so it expired while
Play was still legitimately working and a slow query on an unlocked
reinstall could offer a claim to somebody who already owned the unlock.
Raised to 35s. Undercutting the layer below turns its patience into our
bug.

Both buttons latch the prompt as seen, not just "No thanks". Someone who
tapped through to email has been served, and meeting the same dialog again
next cold start reads as a bug rather than a courtesy.

The email builder is shared between the dialog and the Settings row so the
two cannot drift into asking for different things, and it falls back to a
chooser when nothing handles mailto:. Play does not expose buyer email
addresses for a paid-app order, so this really is the only channel between
a past buyer and us. A dead button here means no channel at all. The claim
flow verifies a claimant by the address they write from, which is also why
publishing a live order id was worth undoing: verification belongs to the
sender address in Console order search, never to an id somebody quotes.

The claim-shown flag gets its own prefs file, deliberately not merged into
EntitlementPrefs (the grandfather flag) or EntitlementCachePrefs (the Play
grant, excluded from backup so a restore cannot become a portable unlock).
No backup-rule file is touched, so Auto Backup picks it up by default and
nobody meets the prompt twice on a new phone.

Settings also gains a plain contact row under the structured report form.
The studio address previously existed only inside Intent extras, so a phone
with no mail client had no way to even learn where to write. It is now
visible selectable text: tap to compose, long-press to copy.

Also corrected the comment in EntitlementPrefs that still claimed the paid
population was one person, and recorded there why the writer was built and
then deleted, so it does not get rebuilt in six months.

423 tests, 0 failures, assembleDebug clean. Canary run in BOTH directions,
because the dangerous failure here is silence and nobody reports a dialog
they never saw: forcing shouldPrompt false turns the two "buyer is offered
the claim" tests red, forcing it true turns the four guard tests red.
Observed failing both ways, then reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The contact row said "long-press to copy" and, on the one spot people
actually press, it did not. The address sits in a SelectionContainer nested
inside the row's combinedClickable. SelectionContainer wins the long-press
and starts text selection instead, and Compose selects on word boundaries,
so the "@" splits the address and Copy hands back "StaticHum.Studio" with
the "Static@" missing. Following the row's own printed instruction produced
a broken address.

Long-pressing anywhere ELSE in the row worked fine, which is exactly why
this survived the first look. The failing path was the obvious one.

So copy is a real button now, sitting to the right of the address. A button
cannot be stolen by a gesture. The row keeps its tap-to-compose, the
SelectionContainer stays for anyone who wants to select by hand, and the
hint drops "long-press to copy" for "or copy the address".

Verified on device rather than reasoned about, because the whole bug was a
gesture nobody could see in the source:
- Tapping the button shows "Copied." and the foreground activity STAYS on
  MainActivity, so the button consumes its own tap instead of also firing
  the row's compose intent.
- Clipboard contents read BACK by pasting into a system text field:
  "Static@StaticHum.Studio", intact. That readback is the check that
  actually closes this, not the toast.
- Row tap still resolves to the mail chooser, so the button did not break
  the compose path.

Method note worth keeping: the Compose selection toolbar does NOT appear in
a uiautomator dump, it renders in a popup window the dump misses. The
programmatic check reported no toolbar while the screenshot plainly showed
Copy and Select all. The bug was only visible in the picture.

Also renames the report type "Upgrade Request" to "Feature Request", which
is what people actually call it. Enum constant renamed to match. The
subject prefix stays "[NineLives Request]" since a feature request is still
a request, and changing it would break any mail filter already keyed to it.

423 tests, 0 failures, assembleDebug clean.

Evidence: /home/static/nine-lives-evidence/2026-08-20-settle-bound-and-contact-row/

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It read as clutter, and it was. A standing question about a price that no
longer exists, shown forever to every free user, to serve a paid population
of two. Jeff called it and he is right: the Unlock section now says one
thing, that you can buy the unlock, plus Restore for people who already did.

The channel survives, which is the only reason this is safe to cut. The
one-time PaidEraClaimDialog still carries the claim, and the direct contact
row further down the same screen is a general way in for anyone who
dismissed the prompt and changed their mind. What is lost is a
claim-SPECIFIC route for someone who tapped "No thanks", which makes the
dialog's "this is the only time we'll ask" literally true rather than nearly
true.

That trade is fine here and would not be in general. It holds because the
affected population is two people, one of whom is getting refunded anyway,
and because a working contact row now exists to catch the third person who
does not exist. If the contact row ever goes away, this decision has to be
revisited, which is why the comment left in its place says so.

Removing the row orphaned its wiring, so that went too rather than sitting
there looking load-bearing: UnlockSettingsGroup's appVersion parameter, the
argument at its call site, the LocalContext.current local, and the
sendPaidEraClaimEmail import. The helper itself stays, still used by the
dialog.

Verified on device: Unlock section renders with only "Unlock Nine Lives" and
the Restore line, no claim row in the tree.

423 tests, 0 failures, assembleDebug clean, no unused-symbol warnings.

Evidence: /home/static/nine-lives-evidence/2026-08-20-settle-bound-and-contact-row/

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the claim row left five comments describing it as a live safety
net. Two of them were not merely stale, they were load-bearing and wrong:

PaidEraClaimPolicy's firstInstallTime param said reinstall "is what the
Settings row covers", and its error-path note said "the Settings row still
covers anyone that misses". Both promised a net that was deleted hours
earlier. A reader deciding whether the silent error path is acceptable would
have weighed it against a fallback that is not there.

They now say what is actually true: a reinstall or a failed lookup means the
buyer has to write in through the direct-contact row, and the trade still
holds because the alternative is prompting EVERY install on the error path.
The reasoning survives, the false comfort does not.

The other three were history rather than danger and are reworded to read as
history: the "why a prompt and not just the Settings row" section, the
shared-email-builder note, and the NEW_TASK explanation, which is clearer
now that it says there were once two callers and only one of them crashed.

Left alone deliberately: MainActivity, InAppReviewManager and
ReviewEligibility all mention "a Settings row" too, but they mean the
Rate Nine Lives row, which still exists.

Codex reviewed the branch against master before this and returned no
findings, so this one is on the surface pass, not the review.

Canary run this pass, both directions, because a gate never seen to fail is
unproven: forcing shouldPrompt false turned the two buyer-is-offered tests
red, forcing it true turned all four guard tests red. Reverted and the file
confirmed restored.

423 tests, 0 failures, assembleDebug clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex caught this on a second review pass and it is real, though its stated
mechanism is not. It assumed a foreground refresh can run before Billing
setup completes. That cannot happen here: a failed setup returns before
refreshPurchases is ever called, which is also why the gate already stays
false forever on a device with no Play Store.

The path that does exist is narrower. Setup succeeds, then
queryPurchasesAsync fails transiently, and the old `finally` settled the
gate anyway. The claim prompt then treats a provisional free reading as
established, and an unlock owner mid-reinstall can be offered a free code
for the thing they already bought. They can tap through to the email before
a later refresh corrects it.

So the gate now settles on an ANSWER rather than on any exit. The
distinction that matters is between Play saying "no" and Play saying
nothing:

- OK settles it.
- A hard no (BILLING_UNAVAILABLE, DEVELOPER_ERROR) settles it, because a
  retry says the same thing and waiting only delays a correct decision.
- SERVICE_DISCONNECTED, SERVICE_UNAVAILABLE and NETWORK_ERROR do NOT,
  because auto-reconnection can plausibly fix those within seconds and the
  consumer's own bound is longer than that.
- A 30s timeout still settles it. Play had the whole window.

This does not make anything correct that was incorrect. It buys a retry
window inside a bound that already existed, which is the entire value: hold
it slightly longer and you get the real answer instead of a guess.

Entitlement application is untouched, so the tested asymmetry in
PurchaseEvaluator (only a SUCCESSFUL query may revoke) still holds exactly
as before. The only thing that changed is WHEN the gate flips.

CANARY on device, both directions, because this makes the gate harder to
settle and the failure mode of that is a prompt that silently never comes:
forcing queryAndApply to never answer suppressed the prompt at t+5s, +10s,
+15s and +20s, then the ViewModel's 35s backstop released it at t+25s.
Proves the gate gates AND that the backstop still prevents a hang. Reverted,
zero markers left, prompt back at t+5s.

Also corrects an overstatement in the previous commit's comment. It said the
unsettled timeout path left waiters "waiting forever". The only consumer has
always carried its own bound, so it was seconds, not forever. The fix was
still right, because purchaseQuerySettled is public and a future consumer
without a bound WOULD hang, and the doc now says that as a requirement on
consumers rather than a promise from this flow.

423 tests, 0 failures, assembleDebug clean.

Evidence: /home/static/nine-lives-evidence/2026-08-20-settle-bound-and-contact-row/

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@StaticHumStudio
StaticHumStudio merged commit 91688de into master Aug 21, 2026
1 check passed
@StaticHumStudio
StaticHumStudio deleted the feat/paid-buyer-claim-path branch August 21, 2026 21:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant