From 2c9ffafa50583949e4d4fae5b671e8df3e553284 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 5 Aug 2026 14:37:27 +1000 Subject: [PATCH 1/6] Allow the Session Pro backend to be overridden via QaLaunchConfig Lets a QA Pro backend be targeted without rebuilding, matching the iOS customProBackendUrl/customProBackendPubkey launch variables. Both values are required together: a QA URL paired with the production signing key reads every QA-signed proof as invalid and silently strips Pro content. --- .../utilities/TextSecurePreferences.kt | 27 ++++++++ .../thoughtcrime/securesms/pro/ProModule.kt | 50 +++++++++++++- .../securesms/qa/QaLaunchConfig.kt | 69 +++++++++++++++++++ 3 files changed, 144 insertions(+), 2 deletions(-) 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 d5251e5260..7176e41c95 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -35,6 +35,8 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_HA import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_SEEN_DONATION_CTA_AMOUNT import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW import org.session.libsession.utilities.TextSecurePreferences.Companion.DEVNET_SEED_URL +import org.session.libsession.utilities.TextSecurePreferences.Companion.PRO_BACKEND_PUBKEY +import org.session.libsession.utilities.TextSecurePreferences.Companion.PRO_BACKEND_URL import org.session.libsession.utilities.TextSecurePreferences.Companion.SNODE_POOL_SEED_MARKER import org.session.libsession.utilities.TextSecurePreferences.Companion.ENVIRONMENT import org.session.libsession.utilities.TextSecurePreferences.Companion.FOLLOW_SYSTEM_SETTINGS @@ -190,6 +192,17 @@ interface TextSecurePreferences { fun getDevnetSeedUrl(): String? fun setDevnetSeedUrl(value: String?) + /** + * Overrides the Session Pro backend, so a QA backend can be targeted without rebuilding. Both + * must be set together: the pubkey is what proofs are verified against, so a QA-signed proof read + * with the production key is simply invalid. `null` (the default) means use the compiled-in + * backend from libsession. + */ + fun getProBackendUrl(): String? + fun setProBackendUrl(value: String?) + fun getProBackendPubkey(): String? + fun setProBackendPubkey(value: String?) + /** * Identifies the seed configuration the cached snode pool was fetched from, so a pool belonging * to a previous network can be discarded (see SnodeDirectory). Opaque; do not parse. @@ -314,6 +327,8 @@ interface TextSecurePreferences { const val LAST_VERSION_CHECK = "pref_last_version_check" const val ENVIRONMENT = "debug_environment" const val DEVNET_SEED_URL = "debug_devnet_seed_url" + const val PRO_BACKEND_URL = "debug_pro_backend_url" + const val PRO_BACKEND_PUBKEY = "debug_pro_backend_pubkey" const val SNODE_POOL_SEED_MARKER = "snode_pool_seed_marker" const val MIGRATED_TO_GROUP_V2_CONFIG = "migrated_to_group_v2_config" const val MIGRATED_TO_DISABLING_KDF = "migrated_to_disabling_kdf" @@ -949,6 +964,18 @@ class AppTextSecurePreferences @Inject constructor( setStringPreference(DEVNET_SEED_URL, value) } + override fun getProBackendUrl(): String? = getStringPreference(PRO_BACKEND_URL, null) + + override fun setProBackendUrl(value: String?) { + setStringPreference(PRO_BACKEND_URL, value) + } + + override fun getProBackendPubkey(): String? = getStringPreference(PRO_BACKEND_PUBKEY, null) + + override fun setProBackendPubkey(value: String?) { + setStringPreference(PRO_BACKEND_PUBKEY, value) + } + override fun getSnodePoolSeedMarker(): String? = getStringPreference(SNODE_POOL_SEED_MARKER, null) override fun setSnodePoolSeedMarker(value: String?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProModule.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProModule.kt index 033d40e76e..b443f573fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProModule.kt @@ -4,19 +4,65 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import network.loki.messenger.BuildConfig import network.loki.messenger.libsession_util.pro.BackendRequests +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log @Module @InstallIn(SingletonComponent::class) class ProModule { @Provides - fun provideProBackendConfig(): ProBackendConfig { + fun provideProBackendConfig(prefs: TextSecurePreferences): ProBackendConfig { // The backend URL + Ed25519 signing pubkey come from libsession (single source of truth), so a // future change happens in exactly one place rather than a per-client copy. x25519 is derived // on the fly from the Ed key (see ProBackendConfig). - return ProBackendConfig( + val compiledIn = ProBackendConfig( url = BackendRequests.proBackendUrl(), ed25519PubKeyHex = BackendRequests.proBackendPubKeyHex(), ) + + return qaBackendOverride(prefs) ?: compiledIn + } + + /** + * A QA backend supplied as a launch extra (see `QaLaunchConfig`), or `null` for none. + * + * Gated on the same compile-time flag as the reader that writes the preference, so a release build + * cannot be repointed even if the preference were somehow populated. The launcher is an exported + * activity-alias, so this stays defence-in-depth rather than trusting the write path alone. + * + * Re-validated here rather than trusted from the preference: this builds the config used for every + * Pro request, and `ProBackendConfig` throws on a malformed URL or a bad-length key. Falling back + * to the compiled-in backend is the safe failure, so a bad value degrades rather than taking the + * app down during dependency-graph construction. + */ + private fun qaBackendOverride(prefs: TextSecurePreferences): ProBackendConfig? { + if (!BuildConfig.ALLOW_QA_LAUNCH_CONFIG) { + return null + } + + val url = prefs.getProBackendUrl()?.takeIf { it.isNotBlank() } ?: return null + val pubkey = prefs.getProBackendPubkey()?.takeIf { it.isNotBlank() } ?: return null + + val parsed = url.toHttpUrlOrNull() + if (parsed == null) { + Log.e(TAG, "Ignoring malformed Pro backend override URL: '$url'") + return null + } + + return try { + ProBackendConfig(url = parsed, ed25519PubKeyHex = pubkey).also { + Log.i(TAG, "Using Pro backend override: $parsed") + } + } catch (e: RuntimeException) { + Log.e(TAG, "Ignoring unusable Pro backend override", e) + null + } + } + + private companion object { + private const val TAG = "ProModule" } } 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 df73996851..9d8581139c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt @@ -58,6 +58,19 @@ object QaLaunchConfig { */ private const val EXTRA_SERVICE_NETWORK = "sessionServiceNetwork" + /** + * Session Pro backend to use instead of the one compiled into libsession, so a QA backend can be + * targeted without rebuilding. iOS's equivalents are `customProBackendUrl`/`customProBackendPubkey`. + * + * Both are required together, and [EXTRA_PRO_BACKEND_PUBKEY] must be the backend's **Ed25519** + * signing key (`signing_pubkey` from its `GET /status`), not the x25519 form — the x25519 key is + * derived from it (see ProBackendConfig). A URL paired with the production key verifies every + * QA-signed proof as invalid and silently strips Pro content, which reads as an app bug rather + * than a config mistake, so a half-supplied pair is rejected rather than half-applied. + */ + private const val EXTRA_PRO_BACKEND_URL = "sessionProBackendUrl" + private const val EXTRA_PRO_BACKEND_PUBKEY = "sessionProBackendPubkey" + /** * Read any supported extras off [intent] and persist them. Safe to call on every launch: absent * extras leave the corresponding preference untouched. @@ -86,6 +99,7 @@ object QaLaunchConfig { // Order matters: point the devnet at the right seed BEFORE switching the environment onto it. applyDevnetSeedUrl(intent, prefs) applyServiceNetwork(intent, prefs) + applyProBackend(intent, prefs) } catch (e: RuntimeException) { Log.e(TAG, "Ignoring unreadable launch extras", e) return @@ -144,6 +158,61 @@ object QaLaunchConfig { } } + /** + * Points the app at a different Session Pro backend. + * + * Only applied when BOTH extras are present and valid — see [EXTRA_PRO_BACKEND_URL] for why a + * mismatched pair is worse than no override at all. Passing an empty URL clears the override and + * falls back to the backend compiled into libsession. + */ + private fun applyProBackend(intent: Intent, prefs: TextSecurePreferences): Boolean { + if (!intent.hasExtra(EXTRA_PRO_BACKEND_URL) && !intent.hasExtra(EXTRA_PRO_BACKEND_PUBKEY)) { + return false + } + + val rawUrl = intent.getStringExtra(EXTRA_PRO_BACKEND_URL).orEmpty().trim() + val rawPubkey = intent.getStringExtra(EXTRA_PRO_BACKEND_PUBKEY).orEmpty().trim() + + // Deliberately distinguishes "absent" from "present but empty": an empty URL is how a test + // asks to clear a previous override. + if (rawUrl.isEmpty() && rawPubkey.isEmpty()) { + if (prefs.getProBackendUrl() == null && prefs.getProBackendPubkey() == null) { + return false + } + Log.i(TAG, "Clearing Pro backend override") + prefs.setProBackendUrl(null) + prefs.setProBackendPubkey(null) + return true + } + + if (rawUrl.toHttpUrlOrNull() == null) { + Log.e(TAG, "Ignoring Pro backend override: malformed '$EXTRA_PRO_BACKEND_URL' ('$rawUrl')") + return false + } + + if (!isEd25519PubKeyHex(rawPubkey)) { + Log.e( + TAG, + "Ignoring Pro backend override: '$EXTRA_PRO_BACKEND_PUBKEY' must be 64 hex characters " + + "(the backend's Ed25519 signing_pubkey), got '${rawPubkey.length}' characters" + ) + return false + } + + if (rawUrl == prefs.getProBackendUrl() && rawPubkey == prefs.getProBackendPubkey()) { + Log.i(TAG, "Pro backend override already set to $rawUrl") + return false + } + + Log.i(TAG, "Setting Pro backend override to $rawUrl (takes effect on next launch)") + prefs.setProBackendUrl(rawUrl) + prefs.setProBackendPubkey(rawPubkey) + return true + } + + private fun isEd25519PubKeyHex(value: String): Boolean = + value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + private fun applyDevnetSeedUrl(intent: Intent, prefs: TextSecurePreferences): Boolean { // Deliberately distinguishes "absent" from "present but empty": passing an empty value is how // a test asks to clear a previously-set override and fall back to the built-in seed. From 59060641d27b8b79101728992518a52a84c018ee Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 6 Aug 2026 11:37:25 +1000 Subject: [PATCH 2/6] Don't drag the native library into ProStatusManager's class initialisation Reading a SessionProtocol constant runs System.loadLibrary("session_util"), so doing it from the companion's initialiser made the class impossible to initialise wherever the native library is absent -- every JVM unit test. Mockito could not instrument it, and the nine tests constructing a ConversationViewModel failed with NoClassDefFoundError. The constants stay single-sourced from libsession; they are just read on first use rather than on class load. --- .../thoughtcrime/securesms/pro/ProStatusManager.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 2c15f2c880..4bfea2f904 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -503,8 +503,16 @@ class ProStatusManager @Inject constructor( private const val PURCHASE_POLL_MAX_MS = 150_000L // Single-sourced from libsession (see SessionProtocol) rather than hard-coded here. - val MAX_CHARACTER_PRO = SessionProtocol.PRO_HIGHER_CHARACTER_LIMIT // max message codepoints for pro users - private val MAX_CHARACTER_REGULAR = SessionProtocol.STANDARD_CHARACTER_LIMIT // max message codepoints for non-pro users + // + // Lazy, and it has to stay that way: SessionProtocol is a LibSessionUtilCApi object, so merely + // reading one of its constants runs System.loadLibrary("session_util"). Doing that from this + // companion's initialiser meant ProStatusManager could not be class-initialised anywhere the + // native library is absent — which is every JVM unit test — so Mockito could not instrument it + // and every test constructing a ConversationViewModel failed with NoClassDefFoundError. + // Deferring to first read keeps the constants single-sourced without dragging the native + // library into class initialisation. + val MAX_CHARACTER_PRO by lazy { SessionProtocol.PRO_HIGHER_CHARACTER_LIMIT } // max message codepoints for pro users + private val MAX_CHARACTER_REGULAR by lazy { SessionProtocol.STANDARD_CHARACTER_LIMIT } // max message codepoints for non-pro users const val MAX_PIN_REGULAR = 5 // max pinned conversation for non pro users const val URL_PRO_SUPPORT = "https://getsession.org/pro-form" From 445b74cc891118f1e46947e1873abfbd9d6027aa Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 7 Aug 2026 10:19:55 +1000 Subject: [PATCH 3/6] Pro: put a floor under proof acquisition A successful generate force-refreshes get_pro_status, which asks libsession for a renewal target, and `proofExpiry - PRO_RENEWAL_LEAD` is permanently in the past for any proof living less than the 60-minute lead -- so the worker rescheduled itself immediately and looped. Mirrors iOS SessionProManager.reconcileProofRenewal and Desktop, constants included: 60s while covered, 15s * attempt capped at 900s while dark, and re-arming rather than dropping the work, since `target <= now` is also the normal renewal-due signal. The state is in-memory as it is on the other two platforms; a process restart costs one extra request rather than a loop. --- .../securesms/pro/ProProofGenerationWorker.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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 7083549aba..17112b2324 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -69,6 +69,42 @@ class ProProofGenerationWorker @AssistedInject constructor( 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. + // + // 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 + + if (covered) darkAttempt = 0 + val intervalSeconds = if (covered) { + COVERED_INTERVAL_SECONDS + } else { + (DARK_STEP_SECONDS * darkAttempt).coerceAtMost(DARK_CAP_SECONDS) + } + + val sinceLast = now.epochSecond - lastProofRequestAt + if (sinceLast < intervalSeconds) { + val waitSeconds = intervalSeconds - sinceLast + Log.d( + WORK_NAME, + "Last proof request was ${sinceLast}s ago (interval ${intervalSeconds}s, " + + "covered=$covered); re-arming in ${waitSeconds}s" + ) + schedule(applicationContext, Duration.ofSeconds(waitSeconds)) + return Result.success() + } + + // Count the attempt before making it, so one that fails still advances the backoff. + lastProofRequestAt = now.epochSecond + if (!covered) darkAttempt++ + return try { // Rotating key is the deterministic seed derived from the Pro master key for the current // time (libsession owns the rotation schedule), so every device converges on the same key @@ -171,6 +207,25 @@ class ProProofGenerationWorker @AssistedInject constructor( companion object { private const val WORK_NAME = "ProProofGenerationWorker" + /** + * Minimum spacing between proof requests. **Shared cross-client contract** — iOS + * (`SessionProManager.reconcileProofRenewal`) and Desktop use exactly these values; keep them + * in step, and say why in the commit if they ever have to diverge. + */ + private const val COVERED_INTERVAL_SECONDS = 60L // holding a valid proof: brisk + private const val DARK_STEP_SECONDS = 15L // no valid proof: 15s * attempt … + private const val DARK_CAP_SECONDS = 900L // … capped at 15 minutes + + /** + * Pacing state, deliberately in-memory to match iOS and Desktop, which both hold it as an + * ordinary field. A process restart resets it, costing at most one extra request per launch + * — the loop this guards against was a tight re-schedule cycle within a single process. + */ + // 0 rather than a sentinel minimum: `now - lastProofRequestAt` would overflow from Long.MIN_VALUE + // and come out negative, throttling the very first request instead of letting it through. + @Volatile private var lastProofRequestAt = 0L + @Volatile private var darkAttempt = 0 + suspend fun schedule(context: Context, delay: Duration? = null) { WorkManager.getInstance(context) .enqueueUniqueWork(WORK_NAME, From cd2cbf307c8d04302b82246a83a0d9c8f6ea271b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 7 Aug 2026 16:24:10 +1000 Subject: [PATCH 4/6] Pro: allow the mocked Pro state to be set from launch extras The debug menu already drives these states through preferences that ProStatusManager and ProSettingsViewModel read; they were just unreachable from an automated launch, so the Appium suite could only cover Pro screens on iOS. sessionProBackendStatus and sessionProLoadingState are named for the state being simulated rather than for the preference behind them, matching the keys iOS already accepts, so one cross-platform test has one setup that means the same thing on both. `useActual` clears an override. Values are mapped explicitly rather than derived from enum names, so renaming a case cannot silently change what a test asks for. --- .../securesms/qa/QaLaunchConfig.kt | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) 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 9d8581139c..35d126711b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt @@ -6,6 +6,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.network.snode.SnodeDirectory import org.session.libsession.utilities.Environment import org.session.libsession.utilities.TextSecurePreferences +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel import org.session.libsignal.utilities.Log /** @@ -49,6 +50,9 @@ import org.session.libsignal.utilities.Log object QaLaunchConfig { private const val TAG = "QaLaunchConfig" + /** iOS's explicit-clear sentinel, accepted on every Pro mock key so both platforms spell it alike. */ + private const val USE_ACTUAL = "useactual" + /** Seed node to use when the environment is devnet. Must be a valid http(s) URL. */ private const val EXTRA_DEVNET_SEED_URL = "sessionDevnetSeedUrl" @@ -71,6 +75,27 @@ object QaLaunchConfig { private const val EXTRA_PRO_BACKEND_URL = "sessionProBackendUrl" private const val EXTRA_PRO_BACKEND_PUBKEY = "sessionProBackendPubkey" + /** + * Current user's Pro state. Named after the iOS concept rather than the Android preference, + * because this is a cross-platform contract the Appium suite is written against — iOS's key is + * `mockCurrentUserSessionProBackendStatus`. + * + * `useActual` | `never` | `active` | `expired`. `useActual` is the same explicit-clear sentinel + * iOS uses on every mockable Pro feature; an ABSENT extra leaves the preferences untouched. + * + * Maps to TWO preferences, because Android splits the concerns iOS keeps in one key: + * `forceCurrentUserAsPro` is the "use mocked state at all" gate, and `DEBUG_SUBSCRIPTION_STATUS` + * picks which state. Collapsing them here is what keeps one `bothPlatformsIt` setup meaning the + * same thing on both platforms. + */ + private const val EXTRA_PRO_BACKEND_STATUS = "sessionProBackendStatus" + + /** + * Load state of the Pro settings screen: `useActual` | `loading` | `error` | `success`. + * iOS's `mockCurrentUserSessionProLoadingState`. `success` maps to Android's `NORMAL`. + */ + private const val EXTRA_PRO_LOADING_STATE = "sessionProLoadingState" + /** * Read any supported extras off [intent] and persist them. Safe to call on every launch: absent * extras leave the corresponding preference untouched. @@ -100,6 +125,8 @@ object QaLaunchConfig { applyDevnetSeedUrl(intent, prefs) applyServiceNetwork(intent, prefs) applyProBackend(intent, prefs) + applyProBackendStatus(intent, prefs) + applyProLoadingState(intent, prefs) } catch (e: RuntimeException) { Log.e(TAG, "Ignoring unreadable launch extras", e) return @@ -248,4 +275,76 @@ object QaLaunchConfig { prefs.setDevnetSeedUrl(raw) return true } + + /** + * Sets the mocked Pro state for the current user. + * + * Values are mapped EXPLICITLY rather than derived from the enum names, deliberately: this is an + * external contract the Appium suite is written against, so it stays readable and stable + * independently of how [DebugMenuViewModel.DebugSubscriptionStatus] is renamed or reordered. The + * same reasoning iOS documents for its own key. + * + * `expired` is reachable because the debug enum already models it — no new product state was + * needed. Note the expiry it produces is a FIXED offset baked into `ProStatusManager` + * (`EXPIRED` = 2 days ago), so this key can express *that the account has lapsed* but not *when*; + * an arbitrary access-expiry instant is not expressible today. + */ + private fun applyProBackendStatus(intent: Intent, prefs: TextSecurePreferences): Boolean { + if (!intent.hasExtra(EXTRA_PRO_BACKEND_STATUS)) { + return false + } + + val raw = intent.getStringExtra(EXTRA_PRO_BACKEND_STATUS).orEmpty().trim() + // null = don't mock at all (fall through to the real backend-derived state). + val mocked: DebugMenuViewModel.DebugSubscriptionStatus? = when (raw.lowercase()) { + USE_ACTUAL, "never" -> null + "active" -> DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE + "expired" -> DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED + else -> { + Log.e( + TAG, + "Ignoring unknown '$EXTRA_PRO_BACKEND_STATUS' extra: '$raw'. " + + "Use $USE_ACTUAL | never | active | expired." + ) + return false + } + } + + // Written through the specific setters, not setStringPreference: these emit on + // TextSecurePreferences.events, which is what ProStatusManager.proDataState collects. A generic + // write would persist the value and emit nothing, so the mock would appear not to apply until + // the next launch. + prefs.setForceCurrentUserAsPro(mocked != null) + prefs.setDebugSubscriptionType(mocked) + Log.i(TAG, "Set mocked Pro state to '$raw' (debug subscription = ${mocked?.name ?: "off"})") + return true + } + + /** Sets the mocked load state of the Pro settings screen. See [EXTRA_PRO_LOADING_STATE]. */ + private fun applyProLoadingState(intent: Intent, prefs: TextSecurePreferences): Boolean { + if (!intent.hasExtra(EXTRA_PRO_LOADING_STATE)) { + return false + } + + val raw = intent.getStringExtra(EXTRA_PRO_LOADING_STATE).orEmpty().trim() + val mocked: DebugMenuViewModel.DebugProPlanStatus? = when (raw.lowercase()) { + USE_ACTUAL -> null + "loading" -> DebugMenuViewModel.DebugProPlanStatus.LOADING + "error" -> DebugMenuViewModel.DebugProPlanStatus.ERROR + "success" -> DebugMenuViewModel.DebugProPlanStatus.NORMAL + else -> { + Log.e( + TAG, + "Ignoring unknown '$EXTRA_PRO_LOADING_STATE' extra: '$raw'. " + + "Use $USE_ACTUAL | loading | error | success." + ) + return false + } + } + + prefs.setDebugProPlanStatus(mocked) + Log.i(TAG, "Set mocked Pro load state to '$raw' (${mocked?.name ?: "off"})") + return true + } + } From 215a30ba4cf53f11746c1708870bc7f36b468273 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 7 Aug 2026 16:24:10 +1000 Subject: [PATCH 5/6] Pro: name the debug expiry offsets, and correct the labels that lied Two of the three EXPIRING labels claimed 14 days while the code used 2, which is how a reader (and a test author) ends up with the wrong value: the label looks authoritative and is the first thing you see. EXPIRING_LATER moves 40 -> 30 days so both platforms can assert the same rendered string. It already sat outside the 7-day window that gates the expiring CTA and still does, so its behaviour is unchanged -- EXPIRING keeps its 2 days precisely because it is inside that window and is the only way to trigger the CTA by hand. --- .../securesms/debugmenu/DebugMenuViewModel.kt | 13 ++++++--- .../securesms/pro/ProStatusManager.kt | 27 ++++++++++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index 536a120427..1d874068b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -659,13 +659,20 @@ class DebugMenuViewModel @AssistedInject constructor( STOPPED, } + /** + * The `label` is what the debug menu shows for selection, so the day counts in it MUST match the + * offsets the fixtures actually use in `ProStatusManager`'s debug branch. Two of these were out of + * step (they said 14 days where the code did 2), which cost a wrong expected string in an Appium + * spec — the label was read as if it were the source of truth. If you change a fixture offset, + * change its label in the same commit. + */ enum class DebugSubscriptionStatus(val label: String) { AUTO_GOOGLE("Auto Renewing (Google, 3 months)"), AUTO_APPLE_REFUNDING("Refunding (Apple, 3 months)"), - EXPIRING_GOOGLE("Expiring/Cancelled (Expires in 14 days, Google, 12 months)"), - EXPIRING_GOOGLE_LATER("Expiring/Cancelled (Expires in 40 days, Google, 12 months)"), + EXPIRING_GOOGLE("Expiring/Cancelled (Expires in 2 days, Google, 12 months)"), + EXPIRING_GOOGLE_LATER("Expiring/Cancelled (Expires in 30 days, Google, 12 months)"), AUTO_APPLE("Auto Renewing (Apple, 1 months)"), - EXPIRING_APPLE("Expiring/Cancelled (Expires in 14 days, Apple, 1 months)"), + EXPIRING_APPLE("Expiring/Cancelled (Expires in 2 days, Apple, 1 months)"), EXPIRED("Expired (Expired 2 days ago, Google)"), EXPIRED_EARLIER("Expired (Expired 60 days ago, Google)"), EXPIRED_APPLE("Expired (Expired 2 days ago, Apple)"), 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 4bfea2f904..c286c65736 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -171,6 +171,12 @@ class ProStatusManager @Inject constructor( inGracePeriod = false ) + // 2 days is deliberate and load-bearing: it is INSIDE the 7-day window that + // gates the expiring CTA (`HomeViewModel`, `validUntil.isBefore(now.plus(7, DAYS))`), + // which is what makes this the fixture you pick to eyeball that CTA. The + // `_LATER` variant below is the deliberate opposite. Moving this outside 7 days + // would make the two behaviourally identical and leave no way to trigger the CTA + // by hand. DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE -> ProStatus.Active.Expiring( renewingAt = Instant.now() + Duration.ofDays(2), duration = ProSubscriptionDuration.TWELVE_MONTHS.period, @@ -180,7 +186,7 @@ class ProStatusManager @Inject constructor( ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE_LATER -> ProStatus.Active.Expiring( - renewingAt = Instant.now() + Duration.ofDays(40), + renewingAt = Instant.now() + Duration.ofDays(EXPIRING_LATER_DAYS), duration = ProSubscriptionDuration.TWELVE_MONTHS.period, providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), @@ -516,5 +522,24 @@ class ProStatusManager @Inject constructor( const val MAX_PIN_REGULAR = 5 // max pinned conversation for non pro users const val URL_PRO_SUPPORT = "https://getsession.org/pro-form" + + /** + * Remaining access for the `EXPIRING_GOOGLE_LATER` debug fixture, in days. **A test pins this + * value** — don't change it casually. + * + * Two non-obvious properties keep that safe, both worth preserving: + * + * The label reads "30 days" **only because `DateUtils.getExpiryString` rounds up.** The fixture + * sets `renewingAt = now + 30d` when `proDataState` recomputes, but the label is rendered from a + * *later* `now`, so the true remaining is always slightly under 30. That ceiling is load-bearing: + * make it floor for unrelated reasons and the label silently becomes "29 days". + * + * The `Instant.now()` here is understood, not an oversight — the rest of the Pro stack uses + * `SnodeClock`. It is safe because **both** sides of `Duration.between(now, renewingAt)` read the + * same device clock, so skew cancels and the ceiling absorbs the remainder. Don't tidy it into + * `SnodeClock` assuming it is a latent bug; it is safe by that cancellation, not by the clock + * being right. + */ + private const val EXPIRING_LATER_DAYS = 30L } } \ No newline at end of file From 194030e34408e014fc6ac04f1b91793ebceef1de Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 7 Aug 2026 16:24:10 +1000 Subject: [PATCH 6/6] Put the home avatar's content description on the node that is clicked It sat on the ComposeView host in XML while the tap target is the Avatar inside it. Compose publishes its own semantics tree, so whether the host's description survived depended on composition timing -- intermittently leaving the avatar unlabelled for accessibility services, and unfindable by anything addressing it by description. Removed from the XML rather than left in both places: the same description on two nodes of one tree is the ambiguity being fixed, not redundancy. Nothing read it there -- the id is used as a constraint anchor and for setThemedContent only. --- .../securesms/home/HomeActivity.kt | 24 +++++++++++++++---- app/src/main/res/layout/activity_home.xml | 8 +++++-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 2de9be253c..5566aba642 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -23,6 +23,9 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.WindowInsetsCompat @@ -265,14 +268,25 @@ class HomeActivity : ScreenLockActionBarActivity(), val pathStatus by pathManager.status.collectAsState() + // Carried on the Compose node rather than as an `android:contentDescription` on the hosting + // ComposeView, which is where it used to live. The host's attribute was unreliable: Compose + // publishes its own semantics tree for the content (the `clickable` below already gives this + // node a button role), so whether the host's description surfaced in the accessibility tree + // depended on composition timing — which showed up as an intermittent "element not found" in + // the Appium onboarding flow. On the tapped node it is deterministic, and it describes the + // thing that is actually actionable. + val openSettingsDescription = stringResource(R.string.AccessibilityId_profilePicture) + Avatar( size = LocalDimensions.current.iconMediumAvatar, data = avatarUtils.getUIDataFromRecipient(recipient), - modifier = Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = ::openSettings - ), + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = ::openSettings + ) + .semantics { contentDescription = openSettingsDescription }, badge = AvatarBadge.ComposeBadge( content = { val glowSize = LocalDimensions.current.xxxsSpacing diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index 8386355c4d..910c965caf 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -35,8 +35,12 @@ android:layout_height="@dimen/very_small_profile_picture_size" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent" - android:contentDescription="@string/AccessibilityId_profilePicture" /> + app:layout_constraintBottom_toBottomOf="parent" /> + +