Offer past buyers a free unlock, once, and keep a way to reach support - #2
Merged
Merged
Conversation
…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>
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
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.
predates the free flip. Tapping through opens a prefilled support email. Declining retires the
offer permanently, and the dialog says so.
dialog being dismissed.
BillingManager, so the claimprompt 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:
OKsettles the gate.BILLING_UNAVAILABLE,DEVELOPER_ERROR) settles it. A retry says the same thing.SERVICE_DISCONNECTED,SERVICE_UNAVAILABLE,NETWORK_ERRORdo not, becauseauto-reconnection plausibly fixes those inside the consumer's own bound.
Entitlement application is untouched, so the tested asymmetry in
PurchaseEvaluator(only asuccessful 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
assembleDebugclean, no unused-symbol warnings.shouldPromptfalse turned the twobuyer-is-offered tests red. Forcing it true turned all four guard tests red.
failure mode of that is a prompt that silently never arrives. Forcing
queryAndApplyto neveranswer 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.
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); thebranch was rebased onto the new master, which only removed documentation.
🤖 Generated with Claude Code