Skip to content

Pro redemption reflow (Android): implicit redemption + libsession-owned renewal - #2147

Open
jagerman wants to merge 8 commits into
session-foundation:devfrom
jagerman:pro-redemption-reflow
Open

Pro redemption reflow (Android): implicit redemption + libsession-owned renewal#2147
jagerman wants to merge 8 commits into
session-foundation:devfrom
jagerman:pro-redemption-reflow

Conversation

@jagerman

Copy link
Copy Markdown
Member

See PRs session-foundation/session-pro-backend#4 and session-foundation/libsession-util#104

The Pro subscription client used to decide for itself when to renew a proof, when to
redeem a purchase, and how to schedule that work — logic that had drifted between
platforms and, worse, leaked how many devices were on an account (through per-device
renewal jitter). This PR hands all of those decisions to libsession and reduces the app
to a thin driver: it asks libsession whether and when to renew, and acts on the answer.

Redemption is now implicit. There's no separate "register this payment" step. When a
purchase is pending, the app simply calls generate_pro_proof, which both redeems the
purchase and returns the proof. The old AddProPaymentApi is removed.

Renewal timing and the rotating signing key come from libsession. Every device derives
the same rotating key for the current window and renews on the same deterministic
schedule, so all of an account's devices converge instead of each rolling its own random
key and timing. The previous per-device jitter — which leaked device count — is gone.

Writes to the synced proof are now guarded. Because the proof lives in synced user
config, the app no longer blindly overwrites it: it replaces a proof only with one that
extends coverage, clears on an authoritative expired/never only when it isn't already
holding a valid proof, and treats revocation as terminal. It also now reacts to a purchase
made on a different device (a synced pro_prepaid) by polling to redeem on this device's
behalf — previously nothing triggered that.

Smaller fixes. Message character limits are counted as Unicode codepoints via
libsession rather than a client-side copy of the rules, and a mis-set failure-code
constant was corrected (subscription_expired, not the expired user-status token).

Depends on the libsession-util-android glue re-pinned to libsession 078c611b (companion PR).

Version: bumped to 1.34.0.

Dev convenience: you can now build the glue from source alongside the app in one Gradle
run by pointing local.properties at a libsession-util-android checkout; production/CI,
with no such entry, keep using the published AAR.

jagerman and others added 8 commits July 29, 2026 19:12
Adapts the app to implicit redemption + libsession-owned rotation/renewal
(pairs with the glue on branch pro-redemption-reflow / libsession 58bafc4a):
- Delete add_pro_payment: remove AddProPaymentApi, ProStatusManager.addProPayment,
  the PurchaseEvent.Failed.ServerError retry UX + PaymentServer/NonRetryable exceptions.
  New ProStatusManager.onPurchaseInFlight() = setProPrepaid(now) + schedule proof worker.
- ProProofGenerationWorker: rotating key via ED25519.proRotatingSeed(masterKey, now)
  (deterministic weekly seed) instead of random; runs for ACTIVE *or* pro_prepaid
  (redemption); 'not Pro yet' is retryable while prepaid (WorkManager exp-backoff =
  capped client poll; pro_prepaid 1-week gate terminates).
- FetchProStatusWorker: renewal via UserProfile.getProRenewalTarget(now) (drops the
  autoRenewing/expiry bug); also drives the redemption poll when a (possibly synced)
  pro_prepaid marker is seen while not active.
- PlayStore listener: only mark in-flight on PURCHASED (skip PENDING).
- Refund 'in progress' now read from synced config (getRefundRequested) — the wire
  field is gone; threaded into ProDataMapper.toProStatus.

Verified: glue builds green standalone; app-side inspection-clean (no dangling refs).
Local app compile blocked by the pre-existing local-glue AGP/kotlin.android plugin
mismatch. Remaining: purchase-pending UI (getProPrepaid) display. WIP; not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Count/truncate message codepoints natively (String.codePointCount /
  offsetByCodePoints) instead of the glue's utf16_count round-trip.
- addProFeatures now feeds the native codepoint count to
  SessionProtocol.proFeaturesForMessage, letting libsession own the
  count -> higher-character-limit decision.
- MAX_CHARACTER_PRO / MAX_CHARACTER_REGULAR are single-sourced from
  libsession (SessionProtocol.PRO_HIGHER/STANDARD_CHARACTER_LIMIT).
Clients must not care about or assert libsession's rotating-seed rotation
window (it's libsession-owned and may change). Reword the rotating-key
comments to just 'the seed for now' instead of 'weekly'/'rotation period';
no code logic relied on the window.
…e guards

Align android's worker-based renewal with the reviewed design
(agent-comms/pro-proof-renewal-loop-design.md). Android was already
renewal_target-driven + backoff-bounded (WorkManager) + revocation-clearing;
the remaining gaps:

- Drop the random(10..60min) pre-expiry jitter -> deterministic 60min lead.
  Per-device jitter leaks device count via the landed-renewal order statistic;
  libsession owns timing, config resolution settles concurrent renewals.
- ProProofGenerationWorker: upgrade guard (replace proof iff newer expiry) +
  write E<-account_expiry from the proof response, so the renewal path keeps E
  fresh without a get_pro_status.
- FetchProStatusWorker: downgrade guard on the Never/Expired clear (never wipe a
  still-valid proof a fresh sync just landed).
…d change

Coverage gap found while verifying the reconcile triggers: the status-refresh
scheduler watched proAccessExpiry (E), the proof, status expiry, and startup —
but nothing watched pro_prepaid (I). So a prepaid synced from another device's
purchase never kicked this device's redemption poll, breaking the cross-device
'any device pulls the entitlement through' resilience (design 1.7). Watch (E, I)
so a synced prepaid triggers a status refresh -> scheduleProofGenerationIfNeeded
-> poll.
ProErrorCode is the generate_pro_proof error_code vocabulary; the backend emits
subscription_expired there for a lapsed entitlement (base.ErrorCode), NOT expired.
"expired" is a user_status value (never/active/expired) — a different wire field,
already modelled correctly by ProUserStatus. The two vocabularies are deliberately
disjoint so no token identifies two fields; ProErrorCode.EXPIRED="expired" borrowed
the status token into the error_code enum, so it could never match a real slug and
the actual lapse code was unmodelled.
…rties

The composite-build hook (build the glue from source in one Gradle run with
the app, for live-testing coordinated glue+app changes) previously required
passing -Dsession.libsession_util.project.path on every invocation. Read the
same glue-checkout path from local.properties too (gitignored, set once), so a
plain ./gradlew builds glue-from-source for devs who opt in, while production/CI
— with no such entry — keep using the published AAR. The -D system property
still works and takes precedence for a one-off override.

Requires the glue on AGP 9 (matched toolchain) so both share one Gradle run.
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