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()) 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/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 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.