From f5fcebd294526e58f5acddc76b59c255b8174596 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 19 Aug 2026 16:53:06 +1000 Subject: [PATCH 1/3] QA: allow a launch extra to force the next Pro revocation poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend serves a 24h retry_in, so a client's second poll is a day after its first and no revocation behaviour is reachable within a test. sessionForceProRevocationRefresh discards the scheduled poll instead of shortening the interval or adding a fetch path, so the ordinary scheduler still decides when to poll and a test exercises that path rather than a parallel one. Cancelling is the local equivalent of moving a stored next-poll instant into the past: the pending poll here is a WorkManager job carrying a delay, and schedule() enqueues with none, so its KEEP policy would otherwise preserve a job up to 48h from running. The cancel sits immediately before the scheduling it affects rather than where the flag is parsed. The two orderings are not equivalent — reversed, it would cancel the poll it was meant to force. true or 1 enables and any other value disables, rather than presence enabling, since a key present with a disabling value has to disable. An absent extra leaves the stored value alone, as the others do: apply runs on each HomeActivity creation rather than once per test, and a fresh install creates it again after onboarding with no extras attached. Reachable only where ALLOW_QA_LAUNCH_CONFIG is set, checked both where the flag is written and where it is read. --- .../utilities/TextSecurePreferences.kt | 16 +++++++ .../securesms/pro/ProStatusManager.kt | 19 +++++++++ .../securesms/qa/QaLaunchConfig.kt | 42 +++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index c6c6f88111..0ef73aabe9 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -146,6 +146,13 @@ interface TextSecurePreferences { fun hasSeenLinkPreviewSuggestionDialog(): Boolean fun setHasSeenLinkPreviewSuggestionDialog() fun forceCurrentUserAsPro(): Boolean + + /** + * QA only: force the next Pro revocation poll to happen now rather than at its scheduled time. + * Set from a launch extra, which is itself unreachable outside QA builds. + */ + fun forceProRevocationRefresh(): Boolean + fun setForceProRevocationRefresh(force: Boolean) fun setForceCurrentUserAsPro(isPro: Boolean) fun forceOtherUsersAsPro(): Boolean fun setForceOtherUsersAsPro(isPro: Boolean) @@ -374,6 +381,7 @@ interface TextSecurePreferences { const val PROFILE_PIC_EXPIRY = "profile_pic_expiry" const val LAST_OPEN_DATE = "pref_last_open_date" const val SET_FORCE_CURRENT_USER_PRO = "pref_force_current_user_pro" + const val SET_FORCE_PRO_REVOCATION_REFRESH = "pref_force_pro_revocation_refresh" const val SET_FORCE_OTHER_USERS_PRO = "pref_force_other_users_pro" const val SET_FORCE_INCOMING_MESSAGE_PRO = "pref_force_incoming_message_pro" const val HAS_SEEN_PRO_EXPIRING = "has_seen_pro_expiring" @@ -1078,6 +1086,14 @@ class AppTextSecurePreferences @Inject constructor( return getBooleanPreference(SET_FORCE_CURRENT_USER_PRO, false) } + override fun forceProRevocationRefresh(): Boolean { + return getBooleanPreference(TextSecurePreferences.SET_FORCE_PRO_REVOCATION_REFRESH, false) + } + + override fun setForceProRevocationRefresh(force: Boolean) { + setBooleanPreference(TextSecurePreferences.SET_FORCE_PRO_REVOCATION_REFRESH, force) + } + override fun setForceCurrentUserAsPro(isPro: Boolean) { setBooleanPreference(SET_FORCE_CURRENT_USER_PRO, isPro) _events.tryEmit(SET_FORCE_CURRENT_USER_PRO) 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 75eb5934fe..c4266888db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withTimeoutOrNull +import network.loki.messenger.BuildConfig import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.pro.BackendRequests import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_APP_STORE @@ -442,6 +443,24 @@ class ProStatusManager @Inject constructor( override suspend fun doWhileLoggedIn(loggedInState: LoggedInState): Unit = supervisorScope { launch { + // QA only. The pending poll is a WorkManager job carrying a delay rather than a stored + // instant, so there is nothing to backdate; cancelling it is the equivalent, because + // `schedule` enqueues with no delay and its KEEP policy would otherwise preserve a job + // that is up to 48h from running. + // + // Cancelled here rather than where the flag is set, immediately before the scheduling it + // affects: the two orderings are not equivalent, and doing it the other way round would + // cancel the poll it was meant to force. + // + // The scheduler below is untouched and still decides when to poll. + if (BuildConfig.ALLOW_QA_LAUNCH_CONFIG && prefs.forceProRevocationRefresh()) { + Log.w( + DebugLogGroup.PRO_SUBSCRIPTION.label, + "Forcing a Pro revocation poll: discarding any scheduled one" + ) + RevocationListPollingWorker.cancel(application) + } + RevocationListPollingWorker.schedule(application) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt index 7a0f2729c3..cbe8102e0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt @@ -116,6 +116,20 @@ object QaLaunchConfig { */ private const val EXTRA_PRO_PROOF = "sessionProProof" + /** + * Forces the next Pro revocation poll to happen at launch instead of at its scheduled time. + * + * `true` or `1` enables; absent, empty or any other value leaves the stored setting alone. Not a + * presence check — a key present with a disabling value must disable. + * + * The QA backend serves a 24h `retry_in`, so a client's second poll is a day after its first and no + * revocation behaviour is observable within a test. This does not shorten that interval or add a + * fetch path: it clears the pending scheduled poll so the ordinary scheduler, unchanged, enqueues + * one immediately. See [applyForceProRevocationRefresh] for why cancelling is the equivalent of + * backdating a stored timestamp here. + */ + private const val EXTRA_FORCE_PRO_REVOCATION_REFRESH = "sessionForceProRevocationRefresh" + /** * When the mocked Pro access expires, overriding the fixed offset the fixture selected by * [EXTRA_PRO_BACKEND_STATUS] carries. iOS's `mockCurrentUserAccessExpiryTimestamp`, which is an @@ -246,6 +260,7 @@ object QaLaunchConfig { applyProBackendStatus(intent, prefs) // After the status extra: it overrides the access half that one sets. applyProProof(intent, prefs) + applyForceProRevocationRefresh(intent, prefs) applyProAccessExpiry(intent, prefs) applyProLoadingState(intent, prefs) applyProRefundingStatus(intent, prefs) @@ -277,6 +292,7 @@ object QaLaunchConfig { EXTRA_PRO_BACKEND_PUBKEY, EXTRA_PRO_BACKEND_STATUS, EXTRA_PRO_PROOF, + EXTRA_FORCE_PRO_REVOCATION_REFRESH, EXTRA_PRO_ACCESS_EXPIRY, EXTRA_PRO_LOADING_STATE, EXTRA_PRO_REFUNDING_STATUS, @@ -525,6 +541,32 @@ object QaLaunchConfig { * here produces a PASSING test of the default state, which is worse than a failure — the same * reasoning as [warnOnUnrecognisedExtras]. */ + /** + * Records whether the next revocation poll should be forced. See [EXTRA_FORCE_PRO_REVOCATION_REFRESH]. + * + * Only the flag is stored here; it is acted on where the poll is normally scheduled, so the forcing + * and the scheduling cannot end up in the wrong order. + * + * Absent leaves the stored value untouched, like every other extra here: [apply] runs on each + * HomeActivity creation rather than once per test, and a fresh install creates it a second time + * after onboarding with no QA extras attached. + */ + private fun applyForceProRevocationRefresh(intent: Intent, prefs: TextSecurePreferences): Boolean { + if (!intent.hasExtra(EXTRA_FORCE_PRO_REVOCATION_REFRESH)) { + return false + } + + val raw = intent.getStringExtra(EXTRA_FORCE_PRO_REVOCATION_REFRESH).orEmpty().trim() + val force = when (raw.lowercase()) { + "true", "1" -> true + else -> false + } + + prefs.setForceProRevocationRefresh(force) + Log.i(TAG, "Set forced Pro revocation refresh to $force (from '$raw')") + return true + } + private fun applyProProof(intent: Intent, prefs: TextSecurePreferences): Boolean { if (!intent.hasExtra(EXTRA_PRO_PROOF)) { // Absent leaves the stored override alone, like every other Pro extra here. From 015593d15a3aea36cf45f5a92a058adada8857f0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 20 Aug 2026 09:10:46 +1000 Subject: [PATCH 2/3] Pro: withhold message features from a revoked sender's proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The revocation list overrides the validity of proofs already in circulation, but libsession's decode cannot see it — the list is cached locally rather than travelling with the message — so a cryptographically valid proof carried its features regardless of whether the sender's generation had been revoked. A revoked sender kept the higher character limit. Cleared where the bitset is populated rather than at each consumer, so the bitset is truthful and everything reading it conforms without knowing about revocation: the incoming character limit, the features listed in message info, and what is persisted with the message. Honours the entry's effective timestamp, as every other read of the list does, so a revocation dated in the future does not withdraw features early. The badge and avatar are unaffected. They resolve from the contact's stored proof via a separate path that already filters revocations. --- .../sending_receiving/MessageParser.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt index f20f47912a..fbf3a7aa71 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt @@ -30,6 +30,7 @@ import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.protos.SessionProtos import org.thoughtcrime.securesms.pro.ProBackendConfig +import org.thoughtcrime.securesms.pro.db.ProDatabase import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Provider @@ -42,6 +43,7 @@ class MessageParser @Inject constructor( private val storage: StorageProtocol, private val snodeClock: SnodeClock, private val proBackendConfig: Provider, + private val proDatabase: ProDatabase, ) { // A faster way to check if the user is blocked than to go through RecipientRepository @@ -140,7 +142,20 @@ class MessageParser @Inject constructor( message.receivedTimestamp = snodeClock.currentTimeMillis() message.isSenderSelf = isSenderSelf - if (pro?.status == ProProof.STATUS_VALID) { + // A cryptographically valid proof is not enough to carry features: the revocation list overrides + // the validity of proofs already in circulation, and libsession's decode cannot see it because + // the list is cached locally rather than travelling with the message. + // + // Cleared here rather than at each consumer so the bitset itself is truthful. Everything + // downstream reads it without knowing about revocation — the character limit, the features shown + // in message info, and what is persisted alongside the message. + // + // Honours the entry's effective timestamp, like every other read of the list, so a revocation + // dated in the future does not withdraw features early. + val proofRevoked = pro?.proof?.revocationTagHex + ?.let { proDatabase.isRevoked(it, snodeClock.currentTime()) } == true + + if (pro?.status == ProProof.STATUS_VALID && !proofRevoked) { (message as? VisibleMessage)?.proFeatures = buildSet { addAll(pro.proMessageFeatures.asSequence()) addAll(pro.proProfileFeatures.asSequence()) From be772dd06e07d32836a004fce53c6b52e4ccf1d9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 21 Aug 2026 07:03:16 +1000 Subject: [PATCH 3/3] Docs: the expiry CTAs arm once per Pro cycle, not once per install The comment said "only once per install", which predates clearProExpiryView() resetting the latch when the account reads Active again. The real model is once per Pro cycle, which is the intended behaviour: Pro can only expire once per cycle, so one warning per cycle is the point. Records why, and that firing per-launch or per-status-change was considered and rejected -- the stale comment invited exactly that "fix", and the other clients are being aligned to this model rather than away from it. Also notes the latch is written on dismissal rather than on display, so a CTA shown to a process that dies before the user dismisses it is shown again. Comment only, no behaviour change. --- .../org/thoughtcrime/securesms/home/HomeViewModel.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 4d02601358..eedfd7ecaa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -222,9 +222,19 @@ class HomeViewModel @Inject constructor( // observe subscription status viewModelScope.launch { proStatusManager.proDataState.collect { subscription -> - // show a CTA (only once per install) when + // show a CTA when // - subscription is expiring in less than 7 days // - subscription expired less than 30 days ago + // + // Armed ONCE PER PRO CYCLE, not once per install and not once per launch: Pro can only + // expire once per cycle, so one warning per cycle is the whole intent. The latch is + // persisted (`has_seen_pro_expir{ing,ed}`), written on DISMISSAL rather than on display + // -- so a CTA shown to a process that dies before the user dismisses it is shown again -- + // and cleared below whenever the account reads Active again. + // + // Do not "correct" this to fire on every launch or every status change. Both were + // considered and rejected: the arm-once-per-cycle model is the ruled behaviour, and it is + // the one the other clients are being aligned TO. // Network time: both comparisons below are against instants the backend supplied, // so device-clock skew moves the boundary rather than the subscription. The two CTAs // also skew in opposite directions — a fast clock warns of expiry early and stops