Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
18 changes: 12 additions & 6 deletions app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,44 +76,88 @@ 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(
masterPrivateKey = proMasterKey,
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<UnhandledStatusCodeException>()?.code == 403
if (notEntitled && !purchasePending) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading