Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
47c5e0a
Pro: flag the false grace premise in the display mapper
mpretty-cyro Aug 7, 2026
9132447
Pro: stop the proof worker depending on a status fetch
mpretty-cyro Aug 7, 2026
e4db9f9
Pro: add attempt-stamped pro_state keys for the status floor and the …
mpretty-cyro Aug 7, 2026
3a096cb
Pro: gate the startup fetch, make the floor real, and stop reporting …
mpretty-cyro Aug 7, 2026
24c1667
Pro: subtract grace to get the paid-through date, and gate on when th…
mpretty-cyro Aug 10, 2026
fa76367
Pro: record the blocked proof-path writes for auto-renewing and grace
mpretty-cyro Aug 10, 2026
911a030
Pro: keep the renewing flag and grace coherent when a proof refreshes…
mpretty-cyro Aug 10, 2026
b0e6067
Pro: wake at the renewal date, not at the end of coverage
mpretty-cyro Aug 10, 2026
8a39312
Pro: add a second wake at coverage end, and correct the grace severit…
mpretty-cyro Aug 10, 2026
7e7d1bf
Pro: document why the coverage-end wake doesn't fetch on a compressed…
mpretty-cyro Aug 10, 2026
4929f1f
Pro: write the renewal flag and grace unconditionally on a proof success
mpretty-cyro Aug 10, 2026
a76aa9b
Pro: name placement as what makes the proof-path config write safe
mpretty-cyro Aug 10, 2026
4347916
Pro: read the access expiry as the payment date, not as coverage end
mpretty-cyro Aug 11, 2026
3af519d
Pro: write the grace period on the status path, beside the expiry
mpretty-cyro Aug 11, 2026
c1dba22
Pro: rewrite the comments that explained the withdrawn grace model
mpretty-cyro Aug 11, 2026
3499745
Pro: state the durable contract instead of another repo's current text
mpretty-cyro Aug 11, 2026
855d6f7
Pro: refresh the status after clearing a revoked own-proof
mpretty-cyro Aug 11, 2026
0112690
Pro: state what the code guarantees, not what it used to do
mpretty-cyro Aug 11, 2026
c930ec1
Pro: say which test the startup gate omits, not which one it replaced
mpretty-cyro Aug 11, 2026
97b1a4e
Pro: anchor the Expired CTA at coverage end
mpretty-cyro Aug 11, 2026
1ab0547
Pro: say why the expiry and grace both come off the response
mpretty-cyro Aug 11, 2026
0e8133b
Pro: attribute the multi-day grace to Apple, which is where it comes …
mpretty-cyro Aug 11, 2026
78f5337
Pro: share the CTA/gate windows, and state invariants once
mpretty-cyro Aug 11, 2026
e6f3b22
Pro: keep the wire model at the mapper and point to it elsewhere
mpretty-cyro Aug 11, 2026
fa70642
Pro: fix stale comments left by the E±G polarity correction
jagerman Aug 11, 2026
8a7acb5
Pro: grace is multi-day on both providers, not just Apple
mpretty-cyro Aug 11, 2026
847bcd5
Pro: re-derive why the proof-path writes stay inside the success branch
mpretty-cyro Aug 12, 2026
1945149
Bump libsession-util-android to 1.1.0-49-g5dbfffc
mpretty-cyro Aug 12, 2026
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 @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 ->
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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? {
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ProBackendConfig>,
private val serverApiExecutor: ServerApiExecutor,
Expand All @@ -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(
Expand All @@ -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
) {
Expand All @@ -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")
Expand All @@ -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"

Expand Down
Loading
Loading