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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,6 +43,7 @@ class MessageParser @Inject constructor(
private val storage: StorageProtocol,
private val snodeClock: SnodeClock,
private val proBackendConfig: Provider<ProBackendConfig>,
private val proDatabase: ProDatabase,
) {

// A faster way to check if the user is blocked than to go through RecipientRepository
Expand Down Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
42 changes: 42 additions & 0 deletions app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Loading