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 392244538e..ec1594eed7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -52,6 +52,7 @@ import org.thoughtcrime.securesms.onboarding.OnBoardingPreferences.HAS_VIEWED_SE import org.thoughtcrime.securesms.preferences.AppPreferences import org.thoughtcrime.securesms.preferences.PreferenceStorage import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination +import org.thoughtcrime.securesms.pro.ProRefreshWindows import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.repository.ConversationRepository @@ -67,7 +68,6 @@ import org.thoughtcrime.securesms.util.UserProfileUtils import org.thoughtcrime.securesms.webrtc.CallManager import org.thoughtcrime.securesms.webrtc.data.State import java.time.Instant -import java.time.temporal.ChronoUnit import javax.inject.Inject @HiltViewModel @@ -236,7 +236,7 @@ class HomeViewModel @Inject constructor( && !prefs.hasSeenProExpiring() ){ val validUntil = subscription.type.renewingAt - showExpiring = validUntil.isBefore(now.plus(7, ChronoUnit.DAYS)) + showExpiring = validUntil.isBefore(now.plus(ProRefreshWindows.EXPIRING_CTA)) Log.d(DebugLogGroup.PRO_DATA.label, "Home: Pro active but not auto renewing (expiring). Valid until: $validUntil - Should show Expiring CTA? $showExpiring") if (showExpiring) { _dialogsState.update { state -> @@ -255,12 +255,15 @@ class HomeViewModel @Inject constructor( // 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)) + // Anchored at coverage end, not at the payment date: the backend only reports + // EXPIRED once coverage has ended, so measuring from the payment date shortens + // this window by exactly the grace period and empties it entirely when grace is + // 30 days or more. + val coverageEnded = subscription.type.coverageEndedAt + showExpired = now.isBefore(coverageEnded.plus(ProRefreshWindows.EXPIRED_CTA)) - Log.d(DebugLogGroup.PRO_DATA.label, "Home: Pro expired. Expired at: $validUntil - Should show Expired CTA? $showExpired") + Log.d(DebugLogGroup.PRO_DATA.label, "Home: Pro expired. Coverage ended: $coverageEnded - Should show Expired CTA? $showExpired") - // Check if now is within 30 days after expiry if (showExpired) { _dialogsState.update { state -> state.copy(proExpiredCTA = true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 426012bcc0..1b07ffab09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -22,10 +22,13 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R +import network.loki.messenger.libsession_util.pro.GetProStatusResponse import org.session.libsession.database.StorageProtocol import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol @@ -64,6 +67,7 @@ import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State import java.math.BigDecimal import java.time.Duration +import java.time.Instant import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -106,6 +110,18 @@ class ProSettingsViewModel @AssistedInject constructor( private var recovering: Boolean = false init { + // Trigger #3 — refresh on entering Pro settings. Floored: arriving here is not on its own a + // reason to bypass the floor. + // + // Not via refreshProStatus(), which early-returns while `refreshState` is Loading — and a + // process that has not confirmed a fetch reports Loading from launch, so that guard would + // suppress the one trigger able to clear it. The repository single-flights anyway + // (WorkManager REPLACE), so the guard buys nothing here. + proStatusRepository.requestRefresh(immediate = false) + + // Trigger #4 — the bounded grace poll, for as long as this screen is open. + pollProStatusDuringGraceWhileOpen() + // observe subscription status viewModelScope.launch { proStatusManager @@ -196,16 +212,18 @@ class ProSettingsViewModel @AssistedInject constructor( recovering = false } + // `inGracePeriod` is safe to read directly here and at the label below: the "completed fetch at + // or after the crossing" condition is applied in `toProStatus`, where the flag is produced. while (true) { val now = clock.currentTime() _proSettingsUIState.update { it.copy( proDataState = proDataState, - inGracePeriod = (subType as? ProStatus.Active.AutoRenewing)?.inGracePeriod ?: false, + inGracePeriod = (subType as? ProStatus.Active.AutoRenewing)?.inGracePeriod == true, subscriptionExpiryLabel = when(subType){ is ProStatus.Active.AutoRenewing -> { - // in grace period + // in grace period — already debounced at construction (see toProStatus) if(subType.inGracePeriod) { Phrase.from(context, R.string.proRenewalUnsuccessful) .format() @@ -696,12 +714,61 @@ class ProSettingsViewModel @AssistedInject constructor( } } - private fun refreshProStatus(force: Boolean){ + /** + * Trigger #4 — poll `get_pro_status` while the renewal is overdue and this screen is open. + * + * Not a 60s timer on the screen: it sleeps until the renewal falls due, then polls once a minute + * while the renewal still hasn't landed. Three things stop it — the renewal arrives (`expiry` + * advances, restarting this from the new date), the account runs past coverage, or the screen + * closes and cancels `viewModelScope`. + * + * No `auto_renewing` check needed: the wire zeroes `grace_period_duration` when the subscription + * is not auto-renewing, so coverage ends exactly when the renewal falls due and the loop runs no + * iterations at all. + * + * Exempt from the freshness floor — see [GRACE_POLL_INTERVAL_MS] for why. + */ + private fun pollProStatusDuringGraceWhileOpen() { + viewModelScope.launch { + proStatusRepository.loadState + .map { it.lastUpdated?.first } + .distinctUntilChanged { old, new -> old?.expiry == new?.expiry } + .collectLatest { status -> + val renewalDue = status?.renewalDueAt() ?: return@collectLatest + val coverageEnd = status.coverageEndsAt() ?: return@collectLatest + + // Returns immediately when already past it — i.e. the screen was opened + // mid-grace — which is exactly when we want the first poll to be now. + clock.delayUntil(renewalDue) + + while (clock.currentTime().isBefore(coverageEnd)) { + proStatusRepository.requestRefresh(immediate = true) + delay(GRACE_POLL_INTERVAL_MS) + } + } + } + } + + /** + * The instant the renewal falls due. `expiry` IS that date — do not subtract grace, which runs + * forward from it. + */ + private fun GetProStatusResponse.renewalDueAt(): Instant? = expiry + + /** The instant coverage really ends. See [renewalDueAt]. */ + private fun GetProStatusResponse.coverageEndsAt(): Instant? = expiry?.plus(gracePeriod) + + /** + * [immediate] bypasses the repository's freshness floor. Every caller here is a user-initiated + * refresh (a retry button, recover, returning from cancellation) — trigger #5 — so they pass + * true: the user is looking at the screen waiting for the answer. + */ + private fun refreshProStatus(immediate: Boolean){ // stop early if we are already refreshing if(_proSettingsUIState.value.proDataState.refreshState is State.Loading) return // refreshes the pro status data - proStatusRepository.requestRefresh(force = force) + proStatusRepository.requestRefresh(immediate = immediate) } private fun getSelectedPlan(): ProPlan? { @@ -988,4 +1055,16 @@ class ProSettingsViewModel @AssistedInject constructor( val showTCPolicyDialog: Boolean = false, val showSimpleDialog: SimpleDialogData? = null, ) + + companion object { + /** + * Cadence of the #4 grace poll. Shared cross-client contract (spec §9.3) — the same 60s + * Desktop and iOS use for their while-open poll; keep them in step. + * + * This equals `ProStatusRepository.MIN_UPDATE_INTERVAL_SECONDS`, and that equality is why #4 + * bypasses the floor: a poll running exactly at the floor loses every other tick to timing + * jitter. The two constants are not coincidentally equal — change neither without the other. + */ + private const val GRACE_POLL_INTERVAL_MS = 60_000L + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt index ae81b2c296..6d91a51b6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt @@ -27,7 +27,6 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.api.server.ServerApiExecutor import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.auth.LoginStateRepository -import network.loki.messenger.libsession_util.pro.GetProStatusResponse import org.thoughtcrime.securesms.pro.api.GetProStatusApi import org.thoughtcrime.securesms.pro.api.ServerApiRequest import org.thoughtcrime.securesms.pro.api.successOrThrow @@ -38,14 +37,13 @@ import javax.inject.Provider /** * A worker that fetches the user's Pro status from the server and updates the local database. * - * This worker doesn't do any business logic in terms of when to schedule itself, it simply performs - * the fetch and update operation regardlessly. It, however, does schedule the [ProProofGenerationWorker] - * if needed based on the fetched Pro status, this is because the proof generation logic - * is tightly coupled to the fetched Pro status state. + * Performs the fetch and the update, and makes no scheduling decisions — not even its own. Proof + * renewal is scheduled by [ProStatusManager]'s config watcher, deliberately not from here: keying it + * to a status response would make renewal a downstream effect of a display fetch. */ @HiltWorker class FetchProStatusWorker @AssistedInject constructor( - @Assisted private val context: Context, + @Assisted context: Context, @Assisted params: WorkerParameters, private val proBackendConfig: Provider, private val serverApiExecutor: ServerApiExecutor, @@ -61,6 +59,11 @@ class FetchProStatusWorker @AssistedInject constructor( "User must be logged in to fetch pro status" } + // Record the attempt before making it, so one that fails still spaces out the next. The + // success timestamp can't do this job: it is stored as a pair with the response blob, so a + // failed fetch leaves nothing behind and a failing network was never throttled at all. + proDatabase.setProStatusLastAttemptAt(snodeClock.currentTime()) + return try { Log.d(TAG, "Fetching Pro status from server") val details = serverApiExecutor.execute( @@ -86,12 +89,28 @@ class FetchProStatusWorker @AssistedInject constructor( configs.userProfile.removeProAccessExpiry() } + // A and G go into synced config beside E, so a linked device has the account state + // without its own fetch. All three must come from ONE response: coverage is read as + // `E + G` downstream, so an E stored without its G pairs with whatever G was already + // there. + // + // Written unconditionally. `set_nonzero_int` short-circuits a no-change write on a + // clean config, so a client-side "only if changed" guard adds nothing, and a + // presence-based guard would be wrong — the key is erased rather than stored when + // false, so presence flips on every transition. No `t`/`T` bump either: this is + // backend-derived state like E and I, not a user profile edit. + // + // `details.gracePeriod` is the ACCOUNT-level field, not `latestPayment.gracePeriod`, + // which reports one store transaction and is not gated on auto-renewing. + configs.userProfile.setProAutoRenewing(details.autoRenewing) + configs.userProfile.setProGracePeriod(details.gracePeriod) + // Remove the pro config only when the backend authoritatively says we are no longer // pro (expired) or never were (never). An unknown/future status is NOT a basis to // delete it: removeProConfig() writes the SYNCED user profile, so clearing on an // unrecognised status would erase a valid proof across all the user's devices. Leave // it — the proof's own expiry governs, and the backend won't refresh (or will revoke) - // a genuinely-lapsed account. We schedule proof generation below if we are still pro. + // a genuinely-lapsed account. if (details.userStatus == ProUserStatus.EXPIRED || details.userStatus == ProUserStatus.NEVER ) { @@ -107,8 +126,6 @@ class FetchProStatusWorker @AssistedInject constructor( } proDatabase.updateProStatus(proStatus = details, updatedAt = snodeClock.currentTime()) - scheduleProofGenerationIfNeeded(details) - Result.success() } catch (e: CancellationException) { Log.d(TAG, "Work cancelled") @@ -123,43 +140,6 @@ class FetchProStatusWorker @AssistedInject constructor( } - private suspend fun scheduleProofGenerationIfNeeded(details: GetProStatusResponse) { - if (details.userStatus != ProUserStatus.ACTIVE) { - // Not (yet) Pro — but if a purchase is in flight (possibly synced from another device that - // bought and set pro_prepaid), keep driving the redemption poll so any device can pull the - // entitlement through. Otherwise there's nothing to generate. - val purchasePending = configFactory.withUserConfigs { it.userProfile.getProPrepaid() } != null - if (purchasePending) { - Log.d(TAG, "Not active but a purchase is in flight; scheduling proof redemption") - ProProofGenerationWorker.schedule(context) - } else { - Log.d(TAG, "Pro is not active, cancelling any existing proof generation work") - ProProofGenerationWorker.cancel(context) - } - return - } - - // libsession owns the renewal schedule now — no more client-side autoRenewing/expiry logic (which - // was inconsistent and skipped non-auto-renewing but still-valid entitlements). getProRenewalTarget - // returns null (valid proof, no renewal needed), a target <= now (renew now), or a future target - // (~1h before proof expiry, nudged off the rotation-period boundary so all devices converge). - val nowSeconds = snodeClock.currentTime().epochSecond - val target = configFactory.withUserConfigs { it.userProfile.getProRenewalTarget(nowSeconds) } - if (target == null) { - Log.d(TAG, "Pro proof is still valid; no renewal needed") - return - } - - val delay = Duration.ofSeconds((target - nowSeconds).coerceAtLeast(0L)) - if (delay.isZero) { - Log.d(TAG, "Pro proof needs (re)generation now, scheduling immediately") - ProProofGenerationWorker.schedule(context) - } else { - Log.d(TAG, "Pro proof renewal due in $delay, scheduling") - ProProofGenerationWorker.schedule(context, delay) - } - } - companion object { private const val TAG = "FetchProStatusWorker" 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 d73aced4f4..39f458c2e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt @@ -25,20 +25,37 @@ object ProUserStatus { /** * Map a libsession-parsed get-pro-status response to the app's [ProStatus] domain model. Needs a [Context] * to resolve the (client-owned) provider display strings. + * + * @param confirmedAt when the fetch behind this response COMPLETED, or null if nothing has been + * confirmed. Gates [ProStatus.Active.AutoRenewing.inGracePeriod]: a snapshot taken before the renewal + * fell due cannot have observed it failing, so raising the "renewal unsuccessful" warning off one + * turns an ordinary boundary crossing into an alarm the backend never reported. + * + * Applied here, where the flag is produced, rather than at each consumer — so a new reader inherits + * the protection instead of having to know about it. */ -fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context, refundInProgress: Boolean): ProStatus { +fun GetProStatusResponse.toProStatus( + nowMs: Long, + context: Context, + refundInProgress: Boolean, + confirmedAt: Instant?, +): ProStatus { return when (userStatus) { ProUserStatus.ACTIVE -> { val paymentItem = latestPayment ?: return ProStatus.NeverSubscribed - // `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 + // `expiry` is the PAYMENT-DUE date and coverage runs a further `gracePeriod` past it: + // `expiry_ts + grace_period_duration` is when the backend stops serving, derived from the + // same instant it judged this status against. So being in the ACTIVE branch past `expiry` + // IS the grace period. + // + // Two traps: + // * Do not subtract grace to get the renewal date. `expiry` already is that date and + // grace runs forward from it, so subtracting double-counts. + // * `gracePeriod` here is the ACCOUNT-level field — how much longer we serve — and not + // `ProPaymentItem.gracePeriod`, which reports what a store declared about one + // transaction and is not gated on auto-renewing. + val renewingAt = expiry ?: return ProStatus.NeverSubscribed + val renewingAtMs = renewingAt.toEpochMilli() val providerData = providerMetadata(paymentItem.paymentProvider, context) val duration = paymentItem.toProPlanPeriod() @@ -58,12 +75,19 @@ fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context, refundInProg providerData = providerData, quickRefundExpiry = paymentItem.platformRefundExpiry, refundInProgress = refundInProgress, - // In this ACTIVE branch we're covered; past the paid-through end (renewingAt) = grace. - inGracePeriod = nowMs >= expiryMs, + // Covered but past the payment-due date = the renewal is overdue and grace is + // running. Reachable because this ACTIVE branch extends to `expiry + gracePeriod`. + // + // Requires a fetch that COMPLETED at or after the renewal fell due — see + // `confirmedAt`. Never constructed true on an unconfirmed snapshot. + inGracePeriod = nowMs >= renewingAtMs && + confirmedAt != null && !confirmedAt.isBefore(renewingAt), ) } else { ProStatus.Active.Expiring( - renewingAt = renewingAt, // the paid-through end (not auto-renewing → it just expires then) + // Not auto-renewing, so there is nothing to renew and grace is 0: this instant + // is both the payment-due date and the end of coverage. It just expires then. + renewingAt = renewingAt, duration = duration, providerData = providerData, quickRefundExpiry = paymentItem.platformRefundExpiry, @@ -73,7 +97,12 @@ fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context, refundInProg } ProUserStatus.EXPIRED -> ProStatus.Expired( + // Both values come off the response, and neither may be read from config: they are only + // meaningful as a PAIR from one response. Not every status branch writes `G` to config, and + // the branches that clear `E` cascade `G` away with it, so a config read would pair this + // response's expiry with a grace period from a different one. expiredAt = expiry ?: Instant.EPOCH, + gracePeriod = gracePeriod, providerData = providerMetadata( latestPayment?.paymentProvider ?: PAYMENT_PROVIDER_GOOGLE_PLAY, context, @@ -146,5 +175,7 @@ val previewAutoRenewingApple = ProStatus.Active.AutoRenewing( val previewExpiredApple = ProStatus.Expired( expiredAt = Instant.now() - Duration.ofDays(14), + // Zero grace, so expiredAt and coverage end coincide: the fixture means what it reads as. + gracePeriod = Duration.ZERO, providerData = previewAppleMetaData ) 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 17112b2324..86b9bedc68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -39,8 +39,9 @@ import javax.inject.Provider * A worker that generates a new [network.loki.messenger.libsession_util.pro.ProProof] and stores it * locally. * - * Normally you don't need to interact with this worker directly, as it is scheduled - * automatically when needed based on the Pro status state, by the [FetchProStatusWorker]. + * Normally you don't need to interact with this worker directly: + * [ProStatusManager.manageProofRenewalScheduling] schedules it off libsession's + * `pro_renewal_target`, recomputed whenever the config inputs to that target change. */ @HiltWorker class ProProofGenerationWorker @AssistedInject constructor( @@ -49,7 +50,6 @@ class ProProofGenerationWorker @AssistedInject constructor( private val apiExecutor: ServerApiExecutor, private val proBackendConfig: Provider, private val generateProProofApi: GenerateProProofApi.Factory, - private val proStatusRepository: ProStatusRepository, private val loginStateRepository: LoginStateRepository, private val configFactory: ConfigFactoryProtocol, private val snodeClock: SnodeClock, @@ -59,28 +59,38 @@ class ProProofGenerationWorker @AssistedInject constructor( "User must be logged to generate proof" } - // Run when we're already Pro (proof renewal) OR when a purchase is in flight (redemption): in the - // latter case get_pro_status may not be ACTIVE yet, and calling generate_pro_proof is what pulls - // the entitlement through once the backend has ingested the payment. If neither holds, nothing to do. - val isActive = proStatusRepository.loadState.value.lastUpdated?.first?.userStatus == ProUserStatus.ACTIVE - val purchasePending = configFactory.withUserConfigs { it.userProfile.getProPrepaid() } != null - if (!isActive && !purchasePending) { - Log.d(WORK_NAME, "Not Pro and no purchase in flight; nothing to generate") + // Whether a proof is wanted at all — proof renewal, a purchase in flight (redemption, where + // get_pro_status is not ACTIVE yet and minting the proof is what pulls the entitlement + // through), or an entitlement held with no proof to attach. + // + // Ask CONFIG, never `proStatusRepository.loadState`. WorkManager persists this worker's + // schedule across process death and `loadState` does not, restarting at `Init` — so an + // in-memory check reads "not active, no purchase" and returns without renewing, for exactly + // the renewal that came due while the app was dead. + val now = snodeClock.currentTime() + val (renewalTarget, purchasePending, proof) = configFactory.withUserConfigs { configs -> + Triple( + configs.userProfile.getProRenewalTarget(now.epochSecond), + configs.userProfile.getProPrepaid() != null, + configs.userProfile.getProConfig()?.proProof, + ) + } + + if (renewalTarget == null) { + Log.d(WORK_NAME, "No Pro proof renewal is due; nothing to generate") return Result.success() } - // Pace acquisition. Without a floor this path is a closed loop: a successful generate - // force-refreshes get_pro_status (below), the fetch asks libsession for a renewal target, and - // `target = proofExpiry - PRO_RENEWAL_LEAD` is permanently in the past whenever a proof lives - // for less than the 60-minute lead, so it reschedules us immediately, forever. + // Pace acquisition. Without a floor this path is a closed loop: a successful generate writes + // the new proof to config, ProStatusManager.manageProofRenewalScheduling re-reads the renewal + // target off that write, and `target = proofExpiry - PRO_RENEWAL_LEAD` is permanently in the + // past whenever a proof lives for less than the 60-minute lead — so it reschedules us + // immediately, forever. // // Mirrors iOS `SessionProManager.reconcileProofRenewal` and Desktop `ducks/proBackendData.ts`, // constants included. Note it RE-ARMS rather than skipping: `target <= now` is the normal // "renewal due" signal, so dropping the work would break real renewals. - val now = snodeClock.currentTime() - val covered = configFactory.withUserConfigs { configs -> - configs.userProfile.getProConfig()?.proProof - }?.let { it.expirySeconds > now.epochSecond } == true + val covered = proof?.let { it.expirySeconds > now.epochSecond } == true if (covered) darkAttempt = 0 val intervalSeconds = if (covered) { @@ -141,15 +151,37 @@ class ProProofGenerationWorker @AssistedInject constructor( // 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) } + + // The renewing flag and the grace period must travel with the expiry above: + // coverage end is derived as `E + G`, so a fresh E beside a G from an older + // response is wrong by the difference between them. + // + // Do not hoist these two out of the success branch. Their protection is + // PLACEMENT — not the parse, and not the type. + // + // Absent fields on a parsed response mean "not applicable" and arrive as grace + // 0 / renewing false, which are genuine values. On a transport or protocol + // failure the same defaults arrive having been parsed from nothing, and the C + // struct has no presence flag and the Kotlin type is non-nullable, so a read + // outside this branch cannot tell the two apart. + // + // That matters because writing `false` to a presence-only config key ERASES + // it: it would wipe a flag `get_pro_status` had correctly learned, on the + // strength of a response that said nothing about the account. + // + // The entitlement-denied path needs no equivalent write. It clears `E`, and + // libsession erases `G` and `A` with it — a grace that outlived its expiry + // would pair with whatever wrote `E` next. + configs.userProfile.setProAutoRenewing(response.accountAutoRenewing) + configs.userProfile.setProGracePeriod(response.accountGracePeriod) } 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) + // No status refresh requested here, deliberately. Minting the proof does flip the + // account active at the backend, but the `setProAccessExpiry` write above already + // fires the (E, prepaid) config-change trigger, which schedules that fetch. + // Requesting one here as well would make the proof loop a SOURCE of status + // fetches, coupling the two loops. Result.success() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProRefreshWindows.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProRefreshWindows.kt new file mode 100644 index 0000000000..0a290ff69c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProRefreshWindows.kt @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.pro + +import java.time.Duration + +/** + * The time windows the Pro refresh and the home CTAs share. + * + * **Cross-client contract (spec §9.3).** Desktop and iOS use the same values; they move by agreement + * across clients or not at all, and a divergence needs saying why in the commit. + * + * These live here rather than inside the startup gate because the gate is not their only consumer. The + * gate decides whether to fetch on a cold start, and the home CTAs decide whether to show — off the same + * two windows. Tuning one of a duplicated pair leaves the gate and the CTA disagreeing about the same + * instant, which is the one property they exist to share. + */ +object ProRefreshWindows { + /** Minimum spacing between startup-gate fetches, persisted across processes. */ + val STARTUP_MIN_INTERVAL: Duration = Duration.ofHours(24) + + /** How long before coverage lapses the Expiring CTA may fire. Anchored at the payment date. */ + val EXPIRING_CTA: Duration = Duration.ofDays(7) + + /** How long after coverage has ended the Expired CTA may fire. Anchored at coverage end. */ + val EXPIRED_CTA: Duration = Duration.ofDays(30) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt index c10960edf8..ccea0c1ae0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt @@ -4,13 +4,14 @@ import network.loki.messenger.BuildConfig import org.thoughtcrime.securesms.pro.subscription.ProPlanPeriod import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State +import java.time.Duration import java.time.Instant sealed interface ProStatus{ data object NeverSubscribed: ProStatus sealed interface Active: ProStatus{ - val renewingAt: Instant //this takes into account the expiry and the grace period + val renewingAt: Instant // the payment/renewal-due date (E), as the backend sends it val duration: ProPlanPeriod // the backend's raw (count, unit) — rendered generically, never bucketed val providerData: PaymentProviderMetadata val quickRefundExpiry: Instant? @@ -56,9 +57,29 @@ sealed interface ProStatus{ } data class Expired( + /** + * The payment-due date, as the backend sends it. This is the date to display; coverage ran a + * further [gracePeriod] past it. + */ val expiredAt: Instant, + val gracePeriod: Duration, val providerData: PaymentProviderMetadata - ): ProStatus + ): ProStatus { + /** + * When access actually ended, and the anchor for any window measuring how long ago that was. + * + * The backend only reports EXPIRED once coverage has ended, so a window measured from + * [expiredAt] instead is short by exactly [gracePeriod], and empty once grace reaches the + * window length. [gracePeriod] is coverage-past-expiry: the provider's dunning window plus the + * backend's ~1h renewal-latency allowance. It is multi-day once a real dunning window is known + * (Apple states its retry window directly; for Play the backend keeps the reported expiry at + * the paid-through date and carries Play's expiry extension as the grace instead), and ~1h + * before then. + * + * Derived here rather than at the consumer so a second reader cannot pick the other anchor. + */ + val coverageEndedAt: Instant get() = expiredAt.plus(gracePeriod) + } } data class ProDataState( 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 144b573eff..31fa69702e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -19,9 +19,8 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart @@ -127,14 +126,26 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugProPlanStatus.LOADING -> State.Loading DebugMenuViewModel.DebugProPlanStatus.ERROR -> State.Error(Exception()) else -> { - // calculate the real refresh state here + // `Success` means THIS PROCESS has had a fetch confirmed by the backend, nothing + // weaker: consumers gate on it to avoid acting on stale data, `HomeViewModel`'s + // Expired CTA above all. + // + // Exhaustive on purpose — do not add an `else`. It would sweep up two states that + // are not successes: `Init`, where nothing has been asked yet, and a `Loaded` + // restored from WorkManager's PERSISTED work state, i.e. a fetch from an EARLIER + // process. The second is the dangerous one — a renewal landing while the app is + // closed leaves a stale cache reading as confirmed, and the CTA fires off it. when(proStatusState){ is ProStatusRepository.LoadState.Loading -> { if(proStatusState.waitingForNetwork) State.Error(Exception()) else State.Loading } is ProStatusRepository.LoadState.Error -> State.Error(Exception()) - else -> State.Success(Unit) + is ProStatusRepository.LoadState.Loaded -> { + if (proStatusState.confirmedInThisProcess) State.Success(Unit) + else State.Loading + } + ProStatusRepository.LoadState.Init -> State.Loading } } } @@ -148,7 +159,9 @@ class ProStatusManager @Inject constructor( val refundInProgress = configFactory.get() .withUserConfigs { it.userProfile.getRefundRequested() != null } ProDataState( - type = proStatusState.lastUpdated?.first?.toProStatus(nowMs, application, refundInProgress) ?: ProStatus.NeverSubscribed, + type = proStatusState.lastUpdated?.let { (response, confirmedAt) -> + response.toProStatus(nowMs, application, refundInProgress, confirmedAt) + } ?: ProStatus.NeverSubscribed, showProBadge = showProBadgePreference, refreshState = proDataRefreshState ) @@ -227,14 +240,20 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED -> ProStatus.Expired( expiredAt = now - Duration.ofDays(14), + // Zero grace: these fixtures mean "coverage ended N days ago". + gracePeriod = Duration.ZERO, providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application) ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_EARLIER -> ProStatus.Expired( expiredAt = now - Duration.ofDays(60), + // Zero grace: these fixtures mean "coverage ended N days ago". + gracePeriod = Duration.ZERO, providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application) ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_APPLE -> ProStatus.Expired( expiredAt = now - Duration.ofDays(14), + // Zero grace: these fixtures mean "coverage ended N days ago". + gracePeriod = Duration.ZERO, providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application) ) }.withMockedExpiry(debugAccessExpiry), @@ -274,6 +293,7 @@ class ProStatusManager @Inject constructor( launch { manageOtherPeoplePro() } launch { manageProStatusRefreshScheduling() } + launch { manageProofRenewalScheduling() } launch { manageCurrentProProofRevocation() } } @@ -337,32 +357,44 @@ class ProStatusManager @Inject constructor( .map { "ProAccessExpiry/prepaid in config changes" }, proStatusRepository.get().loadState - .mapNotNull { it.lastUpdated?.first?.expiry } + .mapNotNull { state -> + state.lastUpdated?.first?.let { status -> + status.expiry?.let { renewalDue -> + // Renewal falls due at `expiry`, grace ends at `expiry + gracePeriod`. + renewalDue to renewalDue.plus(status.gracePeriod) + } + } + } .distinctUntilChanged() - .transformLatest { expiry -> - // Schedule a refresh for 30 seconds after access expiry - if (snodeClock.delayUntil(expiry.plusSeconds(30))) { - emit("30 seconds after Access expiry reached") + .transformLatest { (renewalDue, coverageEnd) -> + // Two wakes: the renewal date (did the charge land?) and coverage end (did grace + // run out?). The second is not redundant, because the proof loop only covers it + // when `E` MOVES — a successful renewal advances `E` and fires the config-change + // trigger, a failed one leaves `E` alone and fires nothing. The failed branch is + // the one the grace warning exists for. + // + // `transformLatest` cancels this body when `E` moves, so a wake armed against a + // superseded expiry cannot outlive it and there are no handles to track. + if (snodeClock.delayUntil(renewalDue.plus(WAKE_SLACK))) { + emit("$WAKE_SLACK after the renewal fell due") } - }, - configFactory.get() - .watchUserProConfig() - .filterNotNull() - .distinctUntilChanged() - .mapLatest { proConfig -> - val expiry = Instant.ofEpochSecond(proConfig.proProof.expirySeconds) - // Wake ~1h before proof expiry so the renewal path runs. Deterministic - // (no client-side jitter): per-device random offsets leak device count - // via the landed-renewal order statistic; libsession owns the timing - // (renewal_target), and config resolution settles concurrent renewals. - val refreshTime = expiry.minus(Duration.ofMinutes(60)) - - snodeClock.delayUntil(refreshTime) - "Pro proof expiry reached" + // Guarded on the two instants coinciding rather than on grace being zero: the same + // condition today, but it survives a change to how the instants are derived. + // + // If this wake looks like it never fired on a QA backend, it fired and the FETCH + // was dropped — see [ProStatusRepository.MIN_UPDATE_INTERVAL_SECONDS], which owns + // that interaction. Do not make this wake `immediate` to work around it. + if (coverageEnd != renewalDue && snodeClock.delayUntil(coverageEnd.plus(WAKE_SLACK))) { + emit("$WAKE_SLACK after coverage ended") + } }, - flowOf("App starting up") + // No trigger keyed to PROOF expiry, deliberately: proof timing drives proof renewal + // (see manageProofRenewalScheduling) and nothing else. A status fetch on the proof's clock + // couples the two loops, so a proof renewing early or late drags the status fetch with it. + + startupGate() ).debounce(500.milliseconds) .collect { refreshReason -> Log.d( @@ -370,7 +402,97 @@ class ProStatusManager @Inject constructor( "Scheduling ProStatus fetch due to: $refreshReason" ) - proStatusRepository.get().requestRefresh(force = true) + // Background triggers respect the freshness floor. `immediate` is for the two + // paths where the user is waiting: the post-purchase poll and manual/recover. + proStatusRepository.get().requestRefresh() + } + } + + /** + * Trigger #1 — the startup fetch, gated. + * + * An ungated cold-start fetch has no consumer for most users: entitlement runs off the proof, the + * settings screen refreshes when opened, and account-expiry awareness is the wake. The only real + * consumer is the home CTAs, so the gate asks whether one could plausibly fire, from synced config + * alone. Never-subscribed and comfortably-paid-up accounts make no request at all. + * + * Two independent brakes: the CTA-worthiness test, and a persisted 24h minimum. The interval needs + * its own key — a routine refresh must not consume the gate's budget, and a startup fetch from + * twenty hours ago must not satisfy the 60s floor. + */ + private fun startupGate(): Flow = flow { + val now = snodeClock.currentTime() + + val lastStartupFetch = proDatabase.getProStatusLastStartupFetchAttemptAt() + if (lastStartupFetch != null && lastStartupFetch.plus(ProRefreshWindows.STARTUP_MIN_INTERVAL).isAfter(now)) { + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Startup gate: fetched within the last ${ProRefreshWindows.STARTUP_MIN_INTERVAL}, skipping") + return@flow + } + + val (renewalDue, autoRenewing, grace) = configFactory.get().withUserConfigs { configs -> + Triple( + configs.userProfile.getProAccessExpiry()?.let(Instant::ofEpochSecond), + configs.userProfile.getProAutoRenewing(), + configs.userProfile.getProGracePeriod(), + ) + } + + val reason = startupFetchReason(renewalDue, autoRenewing, grace, now) + if (reason == null) { + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Startup gate: no CTA could fire, skipping the startup fetch") + return@flow + } + + // Stamped on ATTEMPT, matching the floor's key: a fetch that fails still costs the backend, + // and stamping on success would retry hardest exactly when the server is least able to take it. + proDatabase.setProStatusLastStartupFetchAttemptAt(now) + emit("App starting up — $reason") + } + + /** + * Drives proof acquisition/renewal purely from config, off libsession's `pro_renewal_target`. + * + * The inputs to `pro_renewal_target` — the stored proof, the access expiry (E) and the prepaid + * marker (I) — all live in the user profile, so watching them directly is sufficient. Do not drive + * this off a `get_pro_status` response: that makes the proof loop a downstream effect of a display + * fetch, so no fetch means no renewal. + * + * The loop closes with no status fetch in it. The proof worker's own config writes re-enter here + * and schedule the next attempt; a `null` target cancels the work outright. + */ + @OptIn(FlowPreview::class) + private suspend fun manageProofRenewalScheduling() { + configFactory.get() + .userConfigsChanged(EnumSet.of(UserConfigType.USER_PROFILE)) + .castAwayType() + .onStart { emit(Unit) } + .map { + configFactory.get().withUserConfigs { configs -> + // Only the three inputs to pro_renewal_target, so an unrelated profile edit + // (name, avatar) doesn't take the config lock again to recompute the same answer. + Triple( + configs.userProfile.getProConfig()?.proProof?.expirySeconds, + configs.userProfile.getProAccessExpiry(), + configs.userProfile.getProPrepaid(), + ) + } + } + .distinctUntilChanged() + .debounce(500.milliseconds) + .collectLatest { + val nowSeconds = snodeClock.currentTime().epochSecond + val target = configFactory.get() + .withUserConfigs { it.userProfile.getProRenewalTarget(nowSeconds) } + + if (target == null) { + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "No Pro proof renewal needed; cancelling any scheduled work") + ProProofGenerationWorker.cancel(application) + return@collectLatest + } + + val delay = Duration.ofSeconds((target - nowSeconds).coerceAtLeast(0L)) + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Pro proof renewal due in $delay; scheduling") + ProProofGenerationWorker.schedule(application, delay.takeIf { !it.isZero }) } } @@ -389,15 +511,36 @@ class ProStatusManager @Inject constructor( ) .filterNotNull() .collectLatest { revokedHash -> - configFactory.get().withMutableUserConfigs { configs -> + val cleared = configFactory.get().withMutableUserConfigs { configs -> if (configs.userProfile.getProConfig()?.proProof?.revocationTagHex == revokedHash) { Log.w( DebugLogGroup.PRO_SUBSCRIPTION.label, "Current Pro proof has been revoked, clearing Pro config" ) configs.userProfile.removeProConfig() + true + } else { + false } } + + // Ask the server what the state is now. Clearing the proof leaves the account + // asserting a future access expiry with nothing behind it, and nothing else corrects + // that: the config-change trigger watches `E` and the prepaid marker, and this path + // touches neither, so the stale expiry stands until some unrelated trigger fires. + // + // `E` is not cleared locally. A revocation says this proof is void, not what the + // subscription is now — the account may be fine and re-provable, or genuinely gone, + // and only the server knows which. + // + // Floored, not `immediate`: nobody is waiting on a screen. + // + // Requested outside the mutation block, and gated on the clear having happened: this + // collector can fire for a hash that is no longer the stored proof, and a refresh + // triggered by someone else's revocation has no reason behind it. + if (cleared) { + proStatusRepository.get().requestRefresh() + } } } @@ -511,7 +654,7 @@ class ProStatusManager @Inject constructor( // 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) + repo.requestRefresh(immediate = 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). @@ -534,6 +677,78 @@ class ProStatusManager @Inject constructor( } companion object { + /** + * How long after a wake instant to fetch. The backend judges against its own clock, so a wake + * landing exactly on the boundary can read the pre-crossing state. + */ + private val WAKE_SLACK: Duration = Duration.ofSeconds(30) + + /** + * Whether a cold start should fetch `get_pro_status`, and why — or null to stay off the + * network. Pure over plain values so it can be tested without a clock, a database or config. + * + * [renewalDue] is the access expiry as sent — the payment-due date, with coverage running a + * further [grace] past it (see `toProStatus`). + * + * | config state | action | + * |----------------------------------------------------|-----------------------------------| + * | `auto_renewing && now < renewalDue` | no fetch — comfortably active | + * | `auto_renewing && now >= renewalDue` | fetch — the renewal is overdue | + * | `!auto_renewing && renewalDue` in the CTA window | fetch — the Expiring CTA may fire | + * | `!auto_renewing && now >= renewalDue` | confirm before the Expired CTA | + * + * No row tests `renewalDue + grace <= now`: a cold start does not need to know when coverage + * ends, and testing it would double-count grace against the payment date the rows turn on. + */ + internal fun startupFetchReason( + renewalDue: Instant?, + autoRenewing: Boolean, + grace: Duration, + now: Instant, + ): String? { + // No access expiry at all: never subscribed, so no CTA can fire and nothing to confirm. + if (renewalDue == null) return null + + val overdue = !now.isBefore(renewalDue) + + return when { + autoRenewing && !overdue -> null + + // Past the payment date while auto-renewing: the charge is either still retrying or + // it failed and coverage has since ended. Both want a fetch — to raise the grace + // warning, or to confirm before the Expired CTA. + // + // The bound is measured from COVERAGE end, which is the only place [grace] does any + // work here. Anchored at the payment date instead, an account still inside a multi-day + // grace reads as long-dead; unbounded, an account whose renewing flag was never + // cleared fetches on every cold start forever. + autoRenewing -> + if (renewalDue.plus(grace).plus(ProRefreshWindows.EXPIRED_CTA).isAfter(now)) { + "auto-renewing and past the payment date; grace or a failed renewal" + } else { + null + } + + !overdue -> + if (renewalDue.isBefore(now.plus(ProRefreshWindows.EXPIRING_CTA))) { + "not auto-renewing and expiring within ${ProRefreshWindows.EXPIRING_CTA}" + } else { + // Comfortably active and not renewing — a prepaid or long non-renewing + // subscription. No CTA can fire, so nothing to fetch for. + null + } + + // Past the renewal date and not auto-renewing. Confirm with the backend before + // showing the Expired CTA: config can read expired while a renewal landed on another + // device and hasn't synced. Bounded by the CTA's own window — once the CTA can no + // longer fire there is nothing for the fetch to serve. + renewalDue.plus(ProRefreshWindows.EXPIRED_CTA).isAfter(now) -> + "not auto-renewing and expired within ${ProRefreshWindows.EXPIRED_CTA}; confirming before the Expired CTA" + + else -> null + } + } + // 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. diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusRepository.kt index f7d06dae71..e8d6c35237 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusRepository.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import org.session.libsession.network.SnodeClock import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository @@ -28,7 +29,7 @@ class ProStatusRepository @Inject constructor( private val application: Application, private val db: ProDatabase, private val snodeClock: SnodeClock, - @ManagerScope scope: CoroutineScope, + @param:ManagerScope private val scope: CoroutineScope, loginStateRepository: LoginStateRepository, private val networkConnectivity: NetworkConnectivity, ) { @@ -45,7 +46,18 @@ class ProStatusRepository @Inject constructor( val waitingForNetwork: Boolean ) : LoadState - data class Loaded(override val lastUpdated: Pair) : LoadState + /** + * A status fetch has succeeded — but [confirmedInThisProcess] says whether it was OURS. + * + * WorkManager persists a unique work's terminal state, so at process start `watch()` replays + * the PREVIOUS run's `SUCCEEDED` and this state is reached before we have asked anyone + * anything. Callers that mean "the backend has confirmed this for us" must check the flag; + * `Loaded` alone does not mean that and never did. + */ + data class Loaded( + override val lastUpdated: Pair, + val confirmedInThisProcess: Boolean, + ) : LoadState data class Error(override val lastUpdated: Pair?) : LoadState } @@ -68,7 +80,12 @@ class ProStatusRepository @Inject constructor( WorkInfo.State.SUCCEEDED -> { if (last != null) { Log.d(DebugLogGroup.PRO_DATA.label, "Successfully fetched Pro status from backend") - LoadState.Loaded(last) + LoadState.Loaded( + lastUpdated = last, + // The success timestamp is stamped on completion, so "at or after this + // process started observing" is exactly "this process confirmed it". + confirmedInThisProcess = !last.second.isBefore(processStartedAt), + ) } else { // This should never happen, but just in case... LoadState.Error(null) @@ -82,25 +99,124 @@ class ProStatusRepository @Inject constructor( /** - * Requests a fresh of current user's pro status. By default, if last update is recent enough, - * no network request will be made. If [force] is true, a network request will be - * made regardless of the freshness of the last update. + * When this process started observing status. + * + * Used to tell a fetch WE completed from one restored out of WorkManager's persisted state. Both + * failure directions are safe: a clock that runs backwards, or a singleton constructed late, make + * a genuine confirmation read as unconfirmed, which suppresses rather than asserts. + */ + private val processStartedAt: Instant = snodeClock.currentTime() + + /** + * Requests a refresh of the current user's Pro status. By default the request is dropped when + * the last successful fetch is recent enough. + * + * [immediate] bypasses that floor, and its caller list is closed — adding a fourth is a + * cross-client decision: + * + * - #5 manual refresh / recover (`ProSettingsViewModel`) — the user is watching. + * - #7 post-purchase poll (`ProStatusManager`) — bounded, the user is waiting on the entitlement. + * - #4 while-open grace poll (`ProSettingsViewModel`) — bounded, and mechanical rather than + * urgent: its cadence is exactly [MIN_UPDATE_INTERVAL_SECONDS], so leaving it floored would + * drop every other tick to timing jitter. + * + * Everything else goes through the floor, which is what stops coinciding triggers each costing a + * fetch. + */ + /** + * Whether THIS process has asked for a status fetch yet — in memory, and not the question the + * persisted timestamp answers. That one is "was the last fetch recent?"; this one is "has this + * process asked at all?". + * + * Do not delete as redundant with the timestamp. The floor is the only refusal that survives a + * restart, because it reads a value that outlived the process that wrote it, so this is what + * guarantees a process's first request reaches the network. Without it the Pro settings screen's + * on-enter refresh is refused on a relaunch inside the interval, leaving the screen with no + * confirmed status to render and nothing left that would ask: it spins until the interval expires. + * + * Cold-start load stays bounded by the 24h startup gate. */ - fun requestRefresh(force: Boolean = false) { - val currentState = loadState.value - if (!force && (currentState is LoadState.Loading || currentState is LoadState.Loaded) && - currentState.lastUpdated?.second?.plusSeconds(MIN_UPDATE_INTERVAL_SECONDS) - ?.isAfter(snodeClock.currentTime()) == true) { - Log.d(DebugLogGroup.PRO_DATA.label, "Pro status are fresh enough, skipping refresh") + @Volatile + private var fetchedInThisProcess = false + + fun requestRefresh(immediate: Boolean = false) { + if (immediate) { + Log.d(DebugLogGroup.PRO_DATA.label, "Scheduling immediate fetch of Pro status from server") + fetchedInThisProcess = true + FetchProStatusWorker.schedule(application, ExistingWorkPolicy.REPLACE) return } - Log.d(DebugLogGroup.PRO_DATA.label, "Scheduling fetch of Pro status from server") - FetchProStatusWorker.schedule(application, ExistingWorkPolicy.REPLACE) + // The floor reads a persisted timestamp rather than `loadState`. `loadState` is a + // StateFlow starting at LoadState.Init, and Init is neither Loading nor Loaded, so the + // old check was skipped outright until the database combine had emitted — which the + // startup trigger beats. The result was that the floor never applied to the one fetch it + // most needed to cover. So it instead reads `pro_status_last_attempt_at`, stamped on every + // fetch attempt (see below), which survives process death. + // + // The scope is GlobalScope (Dispatchers.Default), so the read is off the main thread; the + // callers are all fire-and-forget. + scope.launch { + // The ATTEMPT timestamp, not the success one: a failed request that reached the server + // costs it the same as a successful one, and gating on success made a failing network + // re-attempt on every trigger and every cold launch — hardest exactly when the server is + // least able to take it. + val lastFetchedAt = db.getProStatusLastAttemptAt() + if (!shouldFetch( + immediate = false, + fetchedInThisProcess = fetchedInThisProcess, + lastFetchedAt = lastFetchedAt, + now = snodeClock.currentTime(), + ) + ) { + Log.d(DebugLogGroup.PRO_DATA.label, "Pro status are fresh enough, skipping refresh") + return@launch + } + + Log.d(DebugLogGroup.PRO_DATA.label, "Scheduling fetch of Pro status from server") + fetchedInThisProcess = true + FetchProStatusWorker.schedule(application, ExistingWorkPolicy.REPLACE) + } } companion object { - private const val MIN_UPDATE_INTERVAL_SECONDS = 60L + /** + * The status freshness floor. Shared cross-client contract: Desktop and iOS use the same 60s, + * and it moves by agreement across clients or not at all. + * + * A scheduled wake depends on this not being crossed. `ProStatusManager`'s `user_expiry` + * trigger arms two wakes, at the renewal date and at coverage end, and both reach the network + * through the floored path. A grace period shorter than this floor puts both inside it and the + * second one's fetch is dropped — reachable only on compressed QA backends, since production + * grace always includes a ~1h renewal-latency allowance. To exercise the coverage-end wake, + * override this constant; do not make a scheduled trigger `immediate`. + */ + const val MIN_UPDATE_INTERVAL_SECONDS = 60L + + /** + * The whole of the floor decision, over plain values so it can be tested without a + * database, a clock or WorkManager. + * + * Keyed off the timestamp, never off a load-state enum: an absent timestamp means "no + * successful fetch on record", which is a reason to fetch, whereas a cold start's initial enum + * value is neither `Loading` nor `Loaded` and so falls outside any test against those two. + * + * Drop-on-fresh, not re-arm (spec §4): a caller whose request is dropped here does not get a + * later one scheduled on its behalf. The proof loop is deliberately the opposite. + * + * [fetchedInThisProcess] is the second exemption — see its own doc for why the two are not + * redundant. + */ + fun shouldFetch( + immediate: Boolean, + fetchedInThisProcess: Boolean, + lastFetchedAt: Instant?, + now: Instant, + ): Boolean = + immediate || + !fetchedInThisProcess || + lastFetchedAt == null || + !lastFetchedAt.plusSeconds(MIN_UPDATE_INTERVAL_SECONDS).isAfter(now) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt index f2e98336d6..7ca3c4a4da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt @@ -185,6 +185,62 @@ class ProDatabase @Inject constructor( } } + /** + * When a `get_pro_status` fetch was last **attempted**, successful or not. + * + * Separate from [getProStatusAndLastUpdated]'s timestamp, which cannot serve this purpose: that + * value is written as a pair with the response blob and is unreadable without it, so a failed fetch + * records nothing and a failing network goes unthrottled entirely. + */ + fun getProStatusLastAttemptAt(): Instant? { + return readableDatabase.query( + "SELECT value FROM pro_state WHERE name = ?", + arrayOf(STATE_PRO_STATUS_LAST_ATTEMPT_AT) + ).use { cursor -> + if (cursor.moveToFirst()) Instant.ofEpochMilli(cursor.getString(0).toLong()) else null + } + } + + /** + * When the STARTUP GATE last attempted a fetch. Separate from [getProStatusLastAttemptAt]: the + * gate's 24h interval must not be consumed by a routine refresh, and the 60s floor must not be + * satisfied by a startup fetch from twenty hours ago. + */ + fun getProStatusLastStartupFetchAttemptAt(): Instant? { + return readableDatabase.query( + "SELECT value FROM pro_state WHERE name = ?", + arrayOf(STATE_PRO_STATUS_LAST_STARTUP_FETCH_ATTEMPT_AT) + ).use { cursor -> + if (cursor.moveToFirst()) Instant.ofEpochMilli(cursor.getString(0).toLong()) else null + } + } + + /** See [getProStatusLastStartupFetchAttemptAt]. Attempt-stamped, like the floor's key. */ + fun setProStatusLastStartupFetchAttemptAt(attemptedAt: Instant) { + writableDatabase.compileStatement(""" + INSERT OR REPLACE INTO pro_state (name, value) + VALUES (?, ?) + """).use { stmt -> + stmt.bindString(1, STATE_PRO_STATUS_LAST_STARTUP_FETCH_ATTEMPT_AT) + stmt.bindString(2, attemptedAt.toEpochMilli().toString()) + // Must be executed — binding alone writes nothing, and the failure is silent. + stmt.executeInsert() + } + } + + /** Records a fetch attempt. Deliberately no change notification — this is not display state. */ + fun setProStatusLastAttemptAt(attemptedAt: Instant) { + writableDatabase.compileStatement(""" + INSERT OR REPLACE INTO pro_state (name, value) + VALUES (?, ?) + """).use { stmt -> + stmt.bindString(1, STATE_PRO_STATUS_LAST_ATTEMPT_AT) + stmt.bindString(2, attemptedAt.toEpochMilli().toString()) + // Must be executed — binding alone writes nothing, and the failure is silent. + stmt.executeInsert() + } + } + fun updateProStatus(proStatus: GetProStatusResponse, updatedAt: Instant) { val changes = writableDatabase.compileStatement(""" INSERT INTO pro_state (name, value) @@ -214,6 +270,14 @@ class ProDatabase @Inject constructor( private const val STATE_PRO_STATUS = "pro_status" private const val STATE_PRO_STATUS_UPDATED_AT = "pro_status_updated_at" + // Written on every ATTEMPT, unlike STATE_PRO_STATUS_UPDATED_AT which is written only alongside + // a successful response. No migration needed: pro_state is a name/value table. + private const val STATE_PRO_STATUS_LAST_ATTEMPT_AT = "pro_status_last_attempt_at" + + // The startup gate's 24h interval — a separate key, see getProStatusLastStartupFetchAttemptAt. + private const val STATE_PRO_STATUS_LAST_STARTUP_FETCH_ATTEMPT_AT = + "pro_status_last_startup_fetch_attempt_at" + private const val ROTATING_KEY_VALIDITY_DAYS = 15 fun createTable(db: SupportSQLiteDatabase) { diff --git a/app/src/test/java/org/thoughtcrime/securesms/pro/ProExpiredCoverageEndTest.kt b/app/src/test/java/org/thoughtcrime/securesms/pro/ProExpiredCoverageEndTest.kt new file mode 100644 index 0000000000..74c05002d3 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/pro/ProExpiredCoverageEndTest.kt @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.pro + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Duration +import java.time.Instant + +/** + * [ProStatus.Expired.coverageEndedAt] and the 30-day Expired-CTA window measured from it. + * + * The backend only reports EXPIRED once coverage has ended, so a window measured from the payment date + * is short by exactly the grace period and empty once grace reaches the window length. + * + * A multi-day grace means a real dunning window is known, which happens on both providers. The cases + * sweep grace as a parameter rather than asserting any store's number. + * + * The CTA condition itself lives in `HomeViewModel`, which needs Android. What is testable here is the + * instant it keys off. + */ +class ProExpiredCoverageEndTest { + + private val ctaWindow: Duration = Duration.ofDays(30) + private val paymentDue: Instant = Instant.parse("2026-08-01T00:00:00Z") + + private fun expired(grace: Duration) = ProStatus.Expired( + expiredAt = paymentDue, + gracePeriod = grace, + providerData = previewAppleMetaData, + ) + + /** The CTA condition from `HomeViewModel`, over the anchor this type exposes. */ + private fun ctaShows(grace: Duration, now: Instant): Boolean = + now.isBefore(expired(grace).coverageEndedAt.plus(ctaWindow)) + + @Test + fun `coverage ends a grace period after the payment date`() { + assertEquals( + paymentDue.plus(Duration.ofDays(16)), + expired(Duration.ofDays(16)).coverageEndedAt, + ) + } + + @Test + fun `zero grace leaves the payment date as coverage end`() { + // Grace is 0 when not renewing, so the anchors coincide and this is a no-op for those accounts. + assertEquals(paymentDue, expired(Duration.ZERO).coverageEndedAt) + } + + @Test + fun `the window is a full 30 days of coverage having ended, whatever grace was`() { + // The property: window length is independent of grace. + for (graceDays in listOf(0L, 1L, 16L, 29L, 45L)) { + val grace = Duration.ofDays(graceDays) + val coverageEnd = paymentDue.plus(grace) + assertTrue( + "grace=$graceDays: should show one day after coverage ended", + ctaShows(grace, coverageEnd.plus(Duration.ofDays(1))), + ) + assertTrue( + "grace=$graceDays: should show at 29 days after coverage ended", + ctaShows(grace, coverageEnd.plus(Duration.ofDays(29))), + ) + assertFalse( + "grace=$graceDays: should not show at 30 days after coverage ended", + ctaShows(grace, coverageEnd.plus(ctaWindow)), + ) + } + } + + @Test + fun `a grace period as long as the window does not empty it`() { + // With 30 days of grace, `now` is already past `paymentDue + 30d` the moment EXPIRED can first + // be reported, so the payment-date anchor yields no window at all. + val grace = ctaWindow + val firstMomentExpiredIsReportable = paymentDue.plus(grace).plusSeconds(1) + + assertTrue(ctaShows(grace, firstMomentExpiredIsReportable)) + assertFalse( + "the payment-date anchor is what this test exists to rule out", + firstMomentExpiredIsReportable.isBefore(paymentDue.plus(ctaWindow)), + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/pro/ProStartupGateTest.kt b/app/src/test/java/org/thoughtcrime/securesms/pro/ProStartupGateTest.kt new file mode 100644 index 0000000000..9c73e0a89a --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/pro/ProStartupGateTest.kt @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.pro + +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.thoughtcrime.securesms.pro.ProStatusManager.Companion.startupFetchReason +import java.time.Duration +import java.time.Instant + +/** + * The startup gate's decision, as a pure function of (renewal due, auto-renewing, grace, now). + * + * Scope: the decision only. These do NOT cover the persisted 24h interval or the config read — both + * need a database, and the interval is checked before this function is reached. + * + * Every case turns on the wire model stated in `toProStatus`: the expiry IS the payment-due date and + * grace runs forward from it, so there is no subtraction anywhere and adding one double-counts. `the + * auto-renewing bound is measured from coverage end, not the payment date` pins the direction. + */ +class ProStartupGateTest { + + private val now: Instant = Instant.parse("2026-08-07T00:00:00Z") + + /** Multi-day grace means a real dunning window is known — on either provider. See `ProStatus`. */ + private val grace: Duration = Duration.ofDays(14) + private val noGrace: Duration = Duration.ZERO + + private fun inDays(days: Long): Instant = now.plus(Duration.ofDays(days)) + private fun daysAgo(days: Long): Instant = now.minus(Duration.ofDays(days)) + + // --- never subscribed ----------------------------------------------------------------------- + + @Test + fun `no access expiry means no fetch`() { + // The population the gate exists for: no CTA can fire, so no fetch has a consumer. + assertNull(startupFetchReason(null, autoRenewing = false, grace = noGrace, now = now)) + assertNull(startupFetchReason(null, autoRenewing = true, grace = grace, now = now)) + } + + // --- auto-renewing -------------------------------------------------------------------------- + + @Test + fun `auto-renewing and comfortably active does not fetch`() { + assertNull(startupFetchReason(inDays(20), autoRenewing = true, grace = grace, now = now)) + } + + @Test + fun `auto-renewing and inside the grace window DOES fetch`() { + // Renewal due 7 days ago, grace 14: still covered, charge not landed. The state this exists for. + assertNotNull(startupFetchReason(daysAgo(7), autoRenewing = true, grace = grace, now = now)) + } + + @Test + fun `auto-renewing boundary - exactly at the renewal date fetches`() { + assertNotNull(startupFetchReason(now, autoRenewing = true, grace = grace, now = now)) + } + + @Test + fun `auto-renewing boundary - one second before the renewal date does not fetch`() { + // Negative control: without it, "inside grace fetches" also passes against a gate that always + // fetches when auto-renewing. + assertNull(startupFetchReason(now.plusSeconds(1), autoRenewing = true, grace = grace, now = now)) + } + + @Test + fun `auto-renewing past coverage end still fetches`() { + // Coverage ended 6 days ago: the renewal failed. Still fetches — this account is about to be + // shown the Expired CTA, and config alone must never be the basis for that. + assertNotNull(startupFetchReason(daysAgo(20), autoRenewing = true, grace = grace, now = now)) + } + + @Test + fun `auto-renewing and long dead does not fetch`() { + // Coverage ended 46 days ago, past the CTA window. Unbounded, an account whose renewing flag + // was never cleared fetches on every cold start forever. + assertNull(startupFetchReason(daysAgo(60), autoRenewing = true, grace = grace, now = now)) + } + + @Test + fun `the auto-renewing bound is measured from coverage end, not the payment date`() { + // Coverage ended 26 days ago, so the CTA can still fire. Discriminates the anchor: from the + // payment date this reads as 40 days gone, past the window, and returns null. Only a grace + // longer than the gap between the anchors tells them apart, hence the multi-day value. + assertNotNull(startupFetchReason(daysAgo(40), autoRenewing = true, grace = grace, now = now)) + } + + // --- not auto-renewing ---------------------------------------------------------------------- + + @Test + fun `not auto-renewing and expiring inside the CTA window fetches`() { + assertNotNull(startupFetchReason(inDays(3), autoRenewing = false, grace = noGrace, now = now)) + } + + @Test + fun `not auto-renewing boundary - just outside the 7 day window does not fetch`() { + assertNull(startupFetchReason(inDays(8), autoRenewing = false, grace = noGrace, now = now)) + } + + @Test + fun `not auto-renewing and recently expired fetches to confirm before the Expired CTA`() { + // Config can read expired while a renewal landed on another device and hasn't synced, so the + // Expired CTA must never fire off config alone. + assertNotNull(startupFetchReason(daysAgo(5), autoRenewing = false, grace = noGrace, now = now)) + } + + @Test + fun `not auto-renewing boundary - expired longer ago than the CTA window does not fetch`() { + // Past 30 days the Expired CTA can no longer fire, so a confirming fetch has no consumer. + assertNull(startupFetchReason(daysAgo(31), autoRenewing = false, grace = noGrace, now = now)) + } + + @Test + fun `not auto-renewing, active, outside the CTA window does not fetch`() { + // A prepaid or long non-renewing subscription: no CTA can fire. Unambiguous because the + // proof-success path writes the renewing flag beside the expiry, so absent means not-renewing + // rather than never-recorded. + assertNull(startupFetchReason(inDays(60), autoRenewing = false, grace = noGrace, now = now)) + } + + // --- grace's blast radius ------------------------------------------------------------------- + + @Test + fun `grace does not widen the non-renewing rows`() { + // Grace belongs to one row, the auto-renewing one. The guard against it being reintroduced + // into the others: the wire sends grace = 0 when not auto-renewing, so anything keyed to a + // non-zero grace on this path only ever fires on a fixture. + assertNull(startupFetchReason(daysAgo(31), autoRenewing = false, grace = grace, now = now)) + assertNotNull(startupFetchReason(inDays(3), autoRenewing = false, grace = grace, now = now)) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/pro/ProStatusFreshnessFloorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/pro/ProStatusFreshnessFloorTest.kt new file mode 100644 index 0000000000..517ec38102 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/pro/ProStatusFreshnessFloorTest.kt @@ -0,0 +1,95 @@ +package org.thoughtcrime.securesms.pro + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.thoughtcrime.securesms.pro.ProStatusRepository.Companion.MIN_UPDATE_INTERVAL_SECONDS +import org.thoughtcrime.securesms.pro.ProStatusRepository.Companion.shouldFetch +import java.time.Instant + +/** + * The `get_pro_status` freshness floor. + * + * Scope: the floor's decision, a pure function of (immediate, last-fetch timestamp, now). NOT the + * wiring — that `requestRefresh` reads the timestamp from `pro_state` rather than `loadState`, and that + * a dropped request really skips `FetchProStatusWorker`. Both need WorkManager and a database. + * + * What these pin is the shape: the decision is keyed off the timestamp, and "no timestamp" is a + * deliberate answer rather than a value that falls between the cases. + */ +class ProStatusFreshnessFloorTest { + + private val now: Instant = Instant.parse("2026-08-07T00:00:00Z") + + @Test + fun `no recorded fetch means fetch`() { + // A cold start with an empty pro_state: no evidence of a recent fetch, which is a reason to go. + assertTrue(shouldFetch(immediate = false, fetchedInThisProcess = true, lastFetchedAt = null, now = now)) + } + + @Test + fun `a fetch inside the interval is dropped`() { + val recent = now.minusSeconds(MIN_UPDATE_INTERVAL_SECONDS / 2) + assertFalse(shouldFetch(immediate = false, fetchedInThisProcess = true, lastFetchedAt = recent, now = now)) + } + + @Test + fun `a fetch older than the interval is allowed`() { + val old = now.minusSeconds(MIN_UPDATE_INTERVAL_SECONDS + 1) + assertTrue(shouldFetch(immediate = false, fetchedInThisProcess = true, lastFetchedAt = old, now = now)) + } + + @Test + fun `the interval boundary is inclusive - exactly the interval ago is allowed`() { + // Inclusive on purpose: the #4 grace poll runs at exactly this cadence, so an exclusive + // boundary would drop alternate ticks to timing jitter. + val exactly = now.minusSeconds(MIN_UPDATE_INTERVAL_SECONDS) + assertTrue(shouldFetch(immediate = false, fetchedInThisProcess = true, lastFetchedAt = exactly, now = now)) + } + + @Test + fun `immediate bypasses the floor even on a fetch that just happened`() { + assertTrue(shouldFetch(immediate = true, fetchedInThisProcess = true, lastFetchedAt = now, now = now)) + } + + @Test + fun `immediate is the only bypass - a just-completed fetch is otherwise dropped`() { + // Negative control: without it the immediate test passes against a function returning true. + assertFalse(shouldFetch(immediate = false, fetchedInThisProcess = true, lastFetchedAt = now, now = now)) + } + + @Test + fun `the first request of a process is never floored`() { + // Second exemption: downstream consumers key off THIS process having confirmed the status, not + // off the stored value being recent. See `ProStatusRepository.fetchedInThisProcess`. + assertTrue( + shouldFetch( + immediate = false, + fetchedInThisProcess = false, + lastFetchedAt = now.minusSeconds(1), + now = now, + ) + ) + } + + @Test + fun `once this process has fetched the floor applies again`() { + // Negative control: without it the test above passes against an exemption that never turns off. + assertFalse( + shouldFetch( + immediate = false, + fetchedInThisProcess = true, + lastFetchedAt = now.minusSeconds(1), + now = now, + ) + ) + } + + @Test + fun `a future timestamp still floors`() { + // Clock skew: a fetch recorded against network time compared with a slightly behind reading. + // Treat as fresh — the alternative makes a skewed clock a licence to bypass the floor. + val future = now.plusSeconds(MIN_UPDATE_INTERVAL_SECONDS) + assertFalse(shouldFetch(immediate = false, fetchedInThisProcess = true, lastFetchedAt = future, now = now)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03855d9d83..aec0e97d5c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ kotlinxImmutableVersion = "0.5.1" kryoVersion = "5.6.2" kspVersion = "2.3.6" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.1.0-37-g5f85e5d" +libsessionUtilAndroidVersion = "1.1.0-49-g5dbfffc" media3ExoplayerVersion = "1.10.0" mockitoCoreVersion = "5.23.0" navVersion = "2.9.8"