diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index afc867346d..392244538e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -249,6 +249,11 @@ class HomeViewModel @Inject constructor( } } else if(subscription.type is ProStatus.Expired + // Only after a SUCCESSFUL get_pro_status request (the round-trip completed and the + // backend answered — even if with "expired"/"not pro"), never off stale data from a + // failed or in-flight fetch: on foreground the cached status can be pre-renewal, and a + // network failure must not surface a false "expired". Consistent with the iOS fix. + && subscription.refreshState is org.thoughtcrime.securesms.util.State.Success && !prefs.hasSeenProExpired()) { val validUntil = subscription.type.expiredAt showExpired = now.isBefore(validUntil.plus(30, ChronoUnit.DAYS)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt index 929ad6f5a3..d73aced4f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt @@ -30,10 +30,15 @@ fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context, refundInProg return when (userStatus) { ProUserStatus.ACTIVE -> { val paymentItem = latestPayment ?: return ProStatus.NeverSubscribed - // Access expiry (incl. grace); "renew due" is expiry minus the grace period. - val expiryMs = (expiry ?: return ProStatus.NeverSubscribed).toEpochMilli() - val renewingAtMs = expiryMs - gracePeriod.toMillis() - val renewingAt = Instant.ofEpochMilli(renewingAtMs) + // `expiry` is the paid-through end. user_status stays `active` through the grace window — + // the backend judges status against coverage_end = expiry + grace_period_duration (both gated + // on auto_renewing) — so being in this ACTIVE branch already means we are still covered. The + // renewal is due AT the paid-through end; being past it while still active IS the grace period. + // Never subtract grace here: that put "renew due" a whole grace period in the past, which + // (in sandbox, where grace ≫ the compressed period) made inGracePeriod perpetually true. + val accountExpiry = expiry ?: return ProStatus.NeverSubscribed + val expiryMs = accountExpiry.toEpochMilli() + val renewingAt = accountExpiry val providerData = providerMetadata(paymentItem.paymentProvider, context) val duration = paymentItem.toProPlanPeriod() @@ -53,11 +58,12 @@ fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context, refundInProg providerData = providerData, quickRefundExpiry = paymentItem.platformRefundExpiry, refundInProgress = refundInProgress, - inGracePeriod = nowMs >= renewingAtMs && nowMs < expiryMs, + // In this ACTIVE branch we're covered; past the paid-through end (renewingAt) = grace. + inGracePeriod = nowMs >= expiryMs, ) } else { ProStatus.Active.Expiring( - renewingAt = renewingAt, // equals expiry when the grace period is zero + renewingAt = renewingAt, // the paid-through end (not auto-renewing → it just expires then) duration = duration, providerData = providerData, quickRefundExpiry = paymentItem.platformRefundExpiry, diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt index ec50c4b760..7083549aba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -27,8 +27,9 @@ import org.thoughtcrime.securesms.api.server.ServerApiExecutor import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.pro.api.GenerateProProofApi +import org.thoughtcrime.securesms.pro.api.ProApiResponse +import org.thoughtcrime.securesms.pro.api.ProErrorCode import org.thoughtcrime.securesms.pro.api.ServerApiRequest -import org.thoughtcrime.securesms.pro.api.successOrThrow import org.thoughtcrime.securesms.util.findCause import java.time.Duration import java.time.Instant @@ -75,7 +76,7 @@ class ProProofGenerationWorker @AssistedInject constructor( val rotatingSeed = ED25519.proRotatingSeed(proMasterKey, snodeClock.currentTime().epochSecond) val rotatingPrivateKey = ED25519.generate(rotatingSeed).secretKey.data - val response = apiExecutor.execute( + val result = apiExecutor.execute( ServerApiRequest( proBackendConfig = proBackendConfig.get(), api = generateProProofApi.create( @@ -83,36 +84,80 @@ class ProProofGenerationWorker @AssistedInject constructor( rotatingPrivateKey = rotatingPrivateKey ), ) - ).successOrThrow() - // §5.2 invariant: an `ok` proof response always carries the proof. - val proof = requireNotNull(response.proof) { "generate-proof returned ok without a proof" } + ) - configFactory.withMutableUserConfigs { configs -> - // Upgrade guard: only replace the proof if it extends coverage (monotonic merge; - // same-period races round to the same expiry -> byte-identical -> no-op). Avoids - // churning a proof another device just landed. - val current = configs.userProfile.getProConfig()?.proProof - if (current == null || proof.expirySeconds > current.expirySeconds) { - configs.userProfile.setProConfig(ProConfig( - proProof = proof, - rotatingPrivateKey = rotatingPrivateKey)) + when (result) { + is ProApiResponse.Success -> { + val response = result.data + // §5.2 invariant: an `ok` proof response always carries the proof. + val proof = requireNotNull(response.proof) { "generate-proof returned ok without a proof" } + + configFactory.withMutableUserConfigs { configs -> + // Upgrade guard: only replace the proof if it extends coverage (monotonic merge; + // same-period races round to the same expiry -> byte-identical -> no-op). Avoids + // churning a proof another device just landed. + val current = configs.userProfile.getProConfig()?.proProof + if (current == null || proof.expirySeconds > current.expirySeconds) { + configs.userProfile.setProConfig(ProConfig( + proProof = proof, + rotatingPrivateKey = rotatingPrivateKey)) + } + // Refresh the cached access-expiry from the advisory account_expiry that rides the + // proof response, so the renewal path keeps E fresh without a separate get_pro_status. + response.accountExpiry?.let { configs.userProfile.setProAccessExpiry(it.epochSecond) } + } + + Log.d(WORK_NAME, "Successfully generated a new pro proof expiring at ${Instant.ofEpochSecond(proof.expirySeconds)}") + // Minting the proof is what makes the backend validate the payment and mark the + // account active, so a get_pro_status fetched before now (e.g. the one behind the + // Pro settings screen right after a purchase) is stale "expired". Refresh the + // display-only status so the UI flips to active on its own, instead of the user + // having to hit "Check Pro Status" manually. + proStatusRepository.requestRefresh(force = true) + Result.success() } - // Refresh the cached access-expiry from the advisory account_expiry that rides the - // proof response, so the renewal path keeps E fresh without a separate get_pro_status. - response.accountExpiry?.let { configs.userProfile.setProAccessExpiry(it.epochSecond) } - } + is ProApiResponse.Failure -> { + val code = result.error.errorCode + val notEntitled = code == ProErrorCode.NOT_SUBSCRIBED || + code == ProErrorCode.REVOKED || + code == ProErrorCode.SUBSCRIPTION_EXPIRED + when { + // A purchase in flight overrides "not entitled": the backend may just not have + // ingested the payment yet, so keep polling (WorkManager backoff; the pro_prepaid + // 1-week gate eventually terminates it). + notEntitled && purchasePending -> Result.retry() - Log.d(WORK_NAME, "Successfully generated a new pro proof expiring at ${Instant.ofEpochSecond(proof.expirySeconds)}") - Result.success() + notEntitled -> { + // Backend authoritatively says we're not (or no longer) entitled. Clear the + // synced access-expiry (E) so the renewal loop terminates: libsession's renewal + // target now fires on "future E but no proof", so a stale future E left here + // would spin. (subscription_expired's past account_expiry is redundant with the + // get_pro_status horizon, so we clear E rather than re-set it.) Also drop a now- + // defunct credential, guarded so a proof another device just landed survives. + configFactory.withMutableUserConfigs { configs -> + configs.userProfile.removeProAccessExpiry() + val nowSeconds = snodeClock.currentTime().epochSecond + val existing = configs.userProfile.getProConfig()?.proProof + if (existing == null || existing.expirySeconds <= nowSeconds) { + configs.userProfile.removeProConfig() + } + } + Log.w(WORK_NAME, "Pro proof denied (code=$code); cleared access-expiry, ending the acquire loop") + Result.failure() + } + + result.error.isRetryable -> Result.retry() + else -> Result.failure() + } + } + } } catch (e: Exception) { if (e is CancellationException) throw e Log.e(WORK_NAME, "Error generating Pro proof", e) - // 403 / NonRetryable normally means "not entitled" -> stop. But while a purchase is in flight - // that same "not Pro yet" response just means the backend hasn't ingested the payment yet, so - // keep polling (WorkManager's exponential backoff is our capped poll; the pro_prepaid 1-week - // gate checked at the top of doWork() eventually terminates it). + // A raw transport-level 403 (not a parsed slug) likewise means "not entitled" -> stop, unless a + // purchase is pending (payment may be un-ingested); everything else is a retryable transport error. val notEntitled = e is NonRetryableException || e.findCause()?.code == 403 if (notEntitled && !purchasePending) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index 4f22d1ec53..2c15f2c880 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -442,9 +443,65 @@ class ProStatusManager @Inject constructor( } Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Purchase in flight; set pro_prepaid, scheduling proof redemption") ProProofGenerationWorker.schedule(application) + pollProStatusAfterPurchase() + } + + /** + * After a purchase, chase the ACCOUNT expiry (get_pro_status) — not the proof. The store took the + * payment but the backend only learns of it out-of-band (an async store notification), so a single + * refresh right after the purchase usually fires before the backend knows and reads a stale + * "expired" — and nothing else re-fetches (the proof is still valid so the renewal loop is dormant, + * and pro_prepaid is suppressed while a proof is held). We deliberately do NOT rotate the proof + * early (that would leak the subscription change via the rotating seed); we only re-fetch the + * display-only status, on a bounded poll, until the account flips to active. + */ + private fun pollProStatusAfterPurchase() { + scope.launch { + val repo = proStatusRepository.get() + // We're done when the ACCOUNT expiry advances past this pre-purchase value — that's the field + // that moves once the backend redeems the new payment. + val baselineExpiry = repo.loadState.value.lastUpdated?.first?.expiry + // Keep firing until it's been ~2 minutes since the FIRST request: an onion-routed fetch can + // take a while (or time out around 30s), so a short window would only manage one attempt. + val stopFiringAfter = snodeClock.currentTime().plusMillis(PURCHASE_POLL_WINDOW_MS) + // Backstop in case a fetch never settles (it should always resolve to Loaded/Error) so we + // never leave this polling indefinitely. + withTimeoutOrNull(PURCHASE_POLL_MAX_MS) { + while (true) { + // Fire immediately (over onion routing the request often reaches the backend after + // the store's async notification already did). + val before = repo.loadState.value.lastUpdated?.second + repo.requestRefresh(force = true) + // Pace off COMPLETION, not request-start: requestRefresh enqueues with REPLACE, so + // re-firing while a slow request is in flight would just cancel and restart it forever. + // Wait for THIS fetch to settle — a newer Loaded, or an Error (failure/timeout). + val settled = repo.loadState.first { st -> + (st is ProStatusRepository.LoadState.Loaded && st.lastUpdated.second != before) || + st is ProStatusRepository.LoadState.Error + } + val newExpiry = (settled as? ProStatusRepository.LoadState.Loaded) + ?.lastUpdated?.first?.expiry + if (newExpiry != null && (baselineExpiry == null || newExpiry.isAfter(baselineExpiry))) { + break + } + // Failure/timeout, or a response whose account expiry hasn't advanced yet: wait 5s and + // retry, as long as we're still within the ~2-minute window. + if (!snodeClock.currentTime().isBefore(stopFiringAfter)) break + delay(PURCHASE_POLL_INTERVAL_MS) + } + } + } } companion object { + // Bounded post-purchase get_pro_status poll (the backend learns of the payment out-of-band via + // an async store notification): after each fetch settles, wait 5s and retry until it's been ~2 + // minutes since the first request, so slow/timing-out onion requests still get a few attempts. + private const val PURCHASE_POLL_INTERVAL_MS = 5_000L + private const val PURCHASE_POLL_WINDOW_MS = 120_000L + // Hard backstop (> the window) so a fetch that never settles can't poll forever. + private const val PURCHASE_POLL_MAX_MS = 150_000L + // Single-sourced from libsession (see SessionProtocol) rather than hard-coded here. val MAX_CHARACTER_PRO = SessionProtocol.PRO_HIGHER_CHARACTER_LIMIT // max message codepoints for pro users private val MAX_CHARACTER_REGULAR = SessionProtocol.STANDARD_CHARACTER_LIMIT // max message codepoints for non-pro users