From 2c9ffafa50583949e4d4fae5b671e8df3e553284 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 5 Aug 2026 14:37:27 +1000 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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" /> + + Date: Tue, 11 Aug 2026 17:12:38 +1000 Subject: [PATCH 7/8] Pro: let a launch extra set the access expiry, and stop the QA config failing quietly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `sessionProBackendStatus=active` mapped to AUTO_GOOGLE, an auto-renewing plan with a fixed +14d renewal rendering `proAutoRenewTime`. iOS's `active` is not auto-renewing, so the shared token meant different things per platform. Map it to EXPIRING_GOOGLE_LATER, which matches iOS. Side effects, neither asserted today: the cancel-access row disappears (it is auto-renew only, as on iOS) and the plan length becomes 12 months. - New `sessionProAccessExpiry` extra, applied over whichever fixture was selected: epoch seconds, or a relative `+`, or `useactual`. Rejects a resolved instant more than ten years out, which catches milliseconds passed as seconds. Seconds because that is what iOS's field is; the preference keeps storing millis internally. - Log loudly on any unrecognised `session*` extra. Previously a typo was a silent no-op, so it failed as a wrong assertion rather than a setup error. - EXPIRED and EXPIRED_APPLE were labelled "Expired 2 days ago" while the code used now - 14 days. Correct the labels; the offsets are the contract. - Build every instant in the debug branch from `snodeClock`, read once per recomputation. The render side already used it, so the fixture's device-clock instants left the offset between the two clocks in the result — a 30-day fixture could render "31 days". `quickRefundExpiry` had the same defect. - Robolectric coverage over the new extra, driven through `QaLaunchConfig.apply` with real Intents so the parse, the range guard and the preference write are tested together. --- .../utilities/TextSecurePreferences.kt | 18 ++ .../securesms/debugmenu/DebugMenuViewModel.kt | 10 +- .../securesms/pro/ProStatusManager.kt | 101 +++++++--- .../securesms/qa/QaLaunchConfig.kt | 164 +++++++++++++++- .../qa/QaLaunchConfigProAccessExpiryTest.kt | 175 ++++++++++++++++++ 5 files changed, 432 insertions(+), 36 deletions(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/qa/QaLaunchConfigProAccessExpiryTest.kt 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 7176e41c95..3c5615c098 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -71,6 +71,7 @@ import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel import org.thoughtcrime.securesms.pro.toProMessageFeatures import org.thoughtcrime.securesms.pro.toProProfileFeatures import java.io.IOException +import java.time.Instant import java.time.ZonedDateTime import javax.inject.Inject import javax.inject.Singleton @@ -219,6 +220,8 @@ interface TextSecurePreferences { fun getDebugSubscriptionType(): DebugMenuViewModel.DebugSubscriptionStatus? fun setDebugSubscriptionType(status: DebugMenuViewModel.DebugSubscriptionStatus?) + fun getDebugProAccessExpiry(): Instant? + fun setDebugProAccessExpiry(expiry: Instant?) fun getDebugProPlanStatus(): DebugMenuViewModel.DebugProPlanStatus? fun setDebugProPlanStatus(status: DebugMenuViewModel.DebugProPlanStatus?) fun getDebugForceNoBilling(): Boolean @@ -383,6 +386,7 @@ interface TextSecurePreferences { const val DEBUG_PRO_MESSAGE_FEATURES = "debug_pro_message_features" const val DEBUG_PRO_PROFILE_FEATURES = "debug_pro_profile_features" const val DEBUG_SUBSCRIPTION_STATUS = "debug_subscription_status" + const val DEBUG_PRO_ACCESS_EXPIRY = "debug_pro_access_expiry" const val DEBUG_PRO_PLAN_STATUS = "debug_pro_plan_status" const val DEBUG_FORCE_NO_BILLING = "debug_pro_has_billing" const val DEBUG_WITHIN_QUICK_REFUND = "debug_within_quick_refund" @@ -1243,6 +1247,20 @@ class AppTextSecurePreferences @Inject constructor( _events.tryEmit(TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS) } + override fun getDebugProAccessExpiry(): Instant? { + return getStringPreference(TextSecurePreferences.DEBUG_PRO_ACCESS_EXPIRY, null) + ?.toLongOrNull() + ?.let(Instant::ofEpochMilli) + } + + override fun setDebugProAccessExpiry(expiry: Instant?) { + setStringPreference( + TextSecurePreferences.DEBUG_PRO_ACCESS_EXPIRY, + expiry?.toEpochMilli()?.toString() + ) + _events.tryEmit(TextSecurePreferences.DEBUG_PRO_ACCESS_EXPIRY) + } + override fun getDebugProPlanStatus(): DebugMenuViewModel.DebugProPlanStatus? { return getStringPreference(TextSecurePreferences.DEBUG_PRO_PLAN_STATUS, null)?.let { DebugMenuViewModel.DebugProPlanStatus.valueOf(it) 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 1d874068b6..6cd4d63c24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -665,6 +665,12 @@ class DebugMenuViewModel @AssistedInject constructor( * 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. + * + * That warning was then proved on the very next pair: `EXPIRED`/`EXPIRED_APPLE` claimed 2 days + * while the code did 14, because the fix above only covered the `EXPIRING` labels. **Correcting + * the labels that lie is not the same as checking the ones that didn't**, so when this drifts + * again, re-read every offset rather than the ones a report names — the labels are now the + * documented contract an Appium spec is written against. */ enum class DebugSubscriptionStatus(val label: String) { AUTO_GOOGLE("Auto Renewing (Google, 3 months)"), @@ -673,9 +679,9 @@ class DebugMenuViewModel @AssistedInject constructor( 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 2 days, Apple, 1 months)"), - EXPIRED("Expired (Expired 2 days ago, Google)"), + EXPIRED("Expired (Expired 14 days ago, Google)"), EXPIRED_EARLIER("Expired (Expired 60 days ago, Google)"), - EXPIRED_APPLE("Expired (Expired 2 days ago, Apple)"), + EXPIRED_APPLE("Expired (Expired 14 days ago, Apple)"), } enum class DebugProPlanStatus(val label: String){ 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 c286c65736..144b573eff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -106,9 +106,15 @@ class ProStatusManager @Inject constructor( } .distinctUntilChanged(), proStatusRepository.get().loadState, - (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS } as Flow<*>) + // The fixture and its expiry override are collected as ONE flow, not two: `combine` is only + // overloaded to five typed flows, and these two answer one question ("what state do we + // want mocked") so splitting them would buy nothing. + (TextSecurePreferences.events.filter { + it == TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS || + it == TextSecurePreferences.DEBUG_PRO_ACCESS_EXPIRY + } as Flow<*>) .onStart { emit(Unit) } - .map { prefs.getDebugSubscriptionType() }, + .map { prefs.getDebugSubscriptionType() to prefs.getDebugProAccessExpiry() }, (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_PRO_PLAN_STATUS } as Flow<*>) .onStart { emit(Unit) } .map { prefs.getDebugProPlanStatus() }, @@ -116,7 +122,7 @@ class ProStatusManager @Inject constructor( .onStart { emit(Unit) } .map { prefs.forceCurrentUserAsPro() }, ){ showProBadgePreference, proStatusState, - debugSubscription, debugProPlanStatus, forceCurrentUserAsPro -> + (debugSubscription, debugAccessExpiry), debugProPlanStatus, forceCurrentUserAsPro -> val proDataRefreshState = when(debugProPlanStatus){ DebugMenuViewModel.DebugProPlanStatus.LOADING -> State.Loading DebugMenuViewModel.DebugProPlanStatus.ERROR -> State.Error(Exception()) @@ -151,22 +157,31 @@ class ProStatusManager @Inject constructor( Log.d(DebugLogGroup.PRO_DATA.label, "ProStatusManager: Getting DEBUG Pro data state") val subscriptionState = debugSubscription ?: DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE + // SnodeClock, not Instant.now(), because every consumer of these instants reads + // SnodeClock: the expiry label renders from `clock.currentTime()` + // (ProSettingsViewModel) and `isWithinQuickRefundWindow` documents the same + // requirement. Building a fixture off the device clock and rendering it against the + // snode clock leaves the offset between them in the result — which is how a "30 days" + // fixture rendered "31 days". Read ONCE so every instant in one recomputation shares + // an origin; 15 separate reads could straddle a clock update mid-fixture. + val now = snodeClock.currentTime() + ProDataState( type = when(subscriptionState){ DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE -> ProStatus.Active.AutoRenewing( - renewingAt = Instant.now() + Duration.ofDays(14), + renewingAt = now + Duration.ofDays(14), duration = ProSubscriptionDuration.THREE_MONTHS.period, providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), - quickRefundExpiry = Instant.now() + Duration.ofDays(7), + quickRefundExpiry = now + Duration.ofDays(7), refundInProgress = false, inGracePeriod = false ) DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE_REFUNDING -> ProStatus.Active.AutoRenewing( - renewingAt = Instant.now() + Duration.ofDays(14), + renewingAt = now + Duration.ofDays(14), duration = ProSubscriptionDuration.THREE_MONTHS.period, providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application), - quickRefundExpiry = Instant.now() + Duration.ofDays(7), + quickRefundExpiry = now + Duration.ofDays(7), refundInProgress = true, inGracePeriod = false ) @@ -178,51 +193,51 @@ class ProStatusManager @Inject constructor( // 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), + renewingAt = now + Duration.ofDays(2), duration = ProSubscriptionDuration.TWELVE_MONTHS.period, providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), - quickRefundExpiry = Instant.now() + Duration.ofDays(7), + quickRefundExpiry = now + Duration.ofDays(7), refundInProgress = false ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE_LATER -> ProStatus.Active.Expiring( - renewingAt = Instant.now() + Duration.ofDays(EXPIRING_LATER_DAYS), + renewingAt = now + Duration.ofDays(EXPIRING_LATER_DAYS), duration = ProSubscriptionDuration.TWELVE_MONTHS.period, providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), - quickRefundExpiry = Instant.now() + Duration.ofDays(7), + quickRefundExpiry = now + Duration.ofDays(7), refundInProgress = false ) DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE -> ProStatus.Active.AutoRenewing( - renewingAt = Instant.now() + Duration.ofDays(14), + renewingAt = now + Duration.ofDays(14), duration = ProSubscriptionDuration.ONE_MONTH.period, providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application), - quickRefundExpiry = Instant.now() + Duration.ofDays(7), + quickRefundExpiry = now + Duration.ofDays(7), refundInProgress = false, inGracePeriod = false ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_APPLE -> ProStatus.Active.Expiring( - renewingAt = Instant.now() + Duration.ofDays(2), + renewingAt = now + Duration.ofDays(2), duration = ProSubscriptionDuration.ONE_MONTH.period, providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application), - quickRefundExpiry = Instant.now() + Duration.ofDays(7), + quickRefundExpiry = now + Duration.ofDays(7), refundInProgress = false ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED -> ProStatus.Expired( - expiredAt = Instant.now() - Duration.ofDays(14), + expiredAt = now - Duration.ofDays(14), providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application) ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_EARLIER -> ProStatus.Expired( - expiredAt = Instant.now() - Duration.ofDays(60), + expiredAt = now - Duration.ofDays(60), providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application) ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_APPLE -> ProStatus.Expired( - expiredAt = Instant.now() - Duration.ofDays(14), + expiredAt = now - Duration.ofDays(14), providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application) ) - }, + }.withMockedExpiry(debugAccessExpiry), refreshState = proDataRefreshState, showProBadge = showProBadgePreference, @@ -233,6 +248,25 @@ class ProStatusManager @Inject constructor( initialValue = getDefaultSubscriptionStateData() ) + /** + * Replaces the fixed offset a debug fixture carries with an explicitly requested instant, leaving + * everything else about the fixture (plan length, provider, grace/refund flags) alone. + * + * This is what lets one fixture serve any expiry window, so a test that only cares *when* access + * ends doesn't need a new fixture — see `QaLaunchConfig.EXTRA_PRO_ACCESS_EXPIRY`. Null means "use + * the fixture's own offset", which is the default and the debug menu's behaviour. + * + * [ProStatus.NeverSubscribed] is returned untouched deliberately: it has no expiry to override, + * and inventing one would turn "never subscribed" into a subscription. + */ + private fun ProStatus.withMockedExpiry(expiry: Instant?): ProStatus = when { + expiry == null -> this + this is ProStatus.Active.AutoRenewing -> copy(renewingAt = expiry) + this is ProStatus.Active.Expiring -> copy(renewingAt = expiry) + this is ProStatus.Expired -> copy(expiredAt = expiry) + else -> this + } + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState): Unit = supervisorScope { launch { RevocationListPollingWorker.schedule(application) @@ -524,21 +558,28 @@ class ProStatusManager @Inject constructor( 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: + * Remaining access for the `EXPIRING_GOOGLE_LATER` debug fixture, in days. **An Appium spec + * pins this value** — it is what `sessionProBackendStatus=active` selects — so don't change it + * casually. Prefer overriding the instant per-test with `sessionProAccessExpiry` over editing + * this. * * 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". + * *later* `now`, so the true remaining is normally slightly under 30. That ceiling is + * load-bearing: make it floor for unrelated reasons and the label silently becomes "29 days". + * + * ## Both sides read `SnodeClock`, and that is what makes the ceiling safe + * + * The fixture builds `renewingAt` from `snodeClock.currentTime()` and the label renders from + * `clock.currentTime()` — the same clock — so the offset between snode and device time cancels + * and only elapsed time remains, which the ceiling absorbs. **Don't "tidy" the fixture back to + * `Instant.now()`:** that is the version this had, and it left the snode-vs-device offset in + * the result. A snode clock running *behind* the device then made remaining exceed 30d and the + * ceiling rendered **"31 days"** — a one-day flake that reads as a test bug. * - * 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. + * The comment here used to assert this cancellation as already true while the code did the + * opposite. It is true now because both sides were changed to agree, not because it was ever + * self-evident — so if you change either side, check the other. */ private const val EXPIRING_LATER_DAYS = 30L } 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 35d126711b..48a7accc0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.qa import android.content.Intent +import android.os.Bundle import network.loki.messenger.BuildConfig import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.network.snode.SnodeDirectory @@ -8,6 +9,8 @@ import org.session.libsession.utilities.Environment import org.session.libsession.utilities.TextSecurePreferences import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel import org.session.libsignal.utilities.Log +import java.time.Duration +import java.time.Instant /** * Applies automated-test configuration supplied as launch intent extras. @@ -90,6 +93,38 @@ object QaLaunchConfig { */ private const val EXTRA_PRO_BACKEND_STATUS = "sessionProBackendStatus" + /** + * 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 + * independent key there too. + * + * Two accepted forms, and neither can contain a space or start with `-` because `appium-adb` + * reads a space-preceded `-`-prefixed token as a new flag: + * + * - **Absolute:** epoch **SECONDS**, e.g. `1786407165`. Always positive, so this is how a PAST + * instant is expressed — there is deliberately no `-30d` form. + * - **Relative:** `+` with unit `s`|`m`|`h`|`d`, e.g. `+30d`. Future only, and unit-free + * by construction, so prefer it wherever a test only needs an offset. + * + * `useActual` clears the override and restores the fixture's own offset. + * + * **Seconds, not milliseconds, and this is a cross-platform contract rather than a preference:** + * iOS's mock is a `TimeInterval` feeding `accessExpiryTimestampSeconds`, and the harness builds the + * value with `Math.floor(Date.now() / 1000)`. One key name and one value shape per platform is the + * standing rule for these keys — a per-platform dialect is how a shared spec silently means two + * things. Note the app's own field name records the unit; prefer it over any doc, including this one. + * + * A resolved instant more than [MAX_EXPIRY_SKEW_YEARS] years from now is REJECTED rather than + * applied, which is what makes a unit slip loud: milliseconds read as seconds lands around the year + * 58,000, which no test means. The check is deliberately **direction-agnostic** — it bounds the + * resolved instant rather than inspecting the input's magnitude, so it catches the slip either way + * and needs no second unit-specific test beside it. + */ + private const val EXTRA_PRO_ACCESS_EXPIRY = "sessionProAccessExpiry" + + /** Bound on [EXTRA_PRO_ACCESS_EXPIRY], in years either side of now. See its docs. */ + private const val MAX_EXPIRY_SKEW_YEARS = 10L + /** * Load state of the Pro settings screen: `useActual` | `loading` | `error` | `success`. * iOS's `mockCurrentUserSessionProLoadingState`. `success` maps to Android's `NORMAL`. @@ -121,11 +156,14 @@ object QaLaunchConfig { return } + warnOnUnrecognisedExtras(extras) + // Order matters: point the devnet at the right seed BEFORE switching the environment onto it. applyDevnetSeedUrl(intent, prefs) applyServiceNetwork(intent, prefs) applyProBackend(intent, prefs) applyProBackendStatus(intent, prefs) + applyProAccessExpiry(intent, prefs) applyProLoadingState(intent, prefs) } catch (e: RuntimeException) { Log.e(TAG, "Ignoring unreadable launch extras", e) @@ -143,6 +181,46 @@ object QaLaunchConfig { snodeDirectory.discardPoolIfSeedChangedAsync() } + /** Every extra this class acts on. Used only to report the ones it doesn't. */ + private val SUPPORTED_EXTRAS = setOf( + EXTRA_DEVNET_SEED_URL, + EXTRA_SERVICE_NETWORK, + EXTRA_PRO_BACKEND_URL, + EXTRA_PRO_BACKEND_PUBKEY, + EXTRA_PRO_BACKEND_STATUS, + EXTRA_PRO_ACCESS_EXPIRY, + EXTRA_PRO_LOADING_STATE, + ) + + /** + * Logs any `session`-prefixed extra this class does not act on. + * + * Exists because the rest of the class CANNOT report an unsupported key by construction: each + * `applyX` asks `hasExtra` for a name it already knows, so a typo'd or not-yet-implemented key is + * silently a no-op. That makes a setup mistake surface later as a wrong assertion in a spec — + * the failure arrives far from its cause and looks like a product bug. A key that does nothing is + * worse than one that errors. + * + * Deliberately scoped to the `session` prefix: the launcher also receives Android's own extras + * (and anything another app cares to send, since the alias is exported), and warning about those + * would be noise that trains readers to ignore this log. + */ + private fun warnOnUnrecognisedExtras(extras: Bundle) { + val unrecognised = extras.keySet() + .filter { it.startsWith("session") && it !in SUPPORTED_EXTRAS } + + if (unrecognised.isEmpty()) { + return + } + + Log.e( + TAG, + "Ignoring ${unrecognised.size} unrecognised launch extra(s): " + + "${unrecognised.sorted()}. Supported: ${SUPPORTED_EXTRAS.sorted()}. " + + "These had NO effect — check for a typo, or for a key this build does not implement." + ) + } + /** * Switches the service network, mirroring iOS's `serviceNetwork` launch variable. * @@ -285,9 +363,22 @@ object QaLaunchConfig { * 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. + * needed. The offsets these fixtures carry are FIXED, so this key expresses *which state* and not + * *when*; pass [EXTRA_PRO_ACCESS_EXPIRY] alongside it to choose the instant. + * + * ## Why `active` selects an EXPIRING fixture rather than an auto-renewing one + * + * It looks wrong and is deliberate: iOS's `autoRenewing` is a plain field defaulting to **false** + * with **no mock key of its own**, so `active` on iOS means "active, not auto-renewing, expiring + * at the access expiry you gave me" — which is [ProStatus.Active.Expiring] here, not + * `AutoRenewing`. Mapping to `AUTO_GOOGLE` made the same token mean different things per platform + * and rendered `proAutoRenewTime` where the shared spec asserts `proExpiringTime`. + * + * The deeper mismatch worth knowing before adding another token: **iOS mocks are orthogonal + * fields, Android's are bundled fixtures.** `active` constrains exactly one field on iOS, while + * here it selects a whole tuple (status + offset + plan length + provider). That is why + * [EXTRA_PRO_ACCESS_EXPIRY] exists — it peels the one dimension tests actually vary back out of + * the bundle. Prefer widening that seam over adding fixtures. */ private fun applyProBackendStatus(intent: Intent, prefs: TextSecurePreferences): Boolean { if (!intent.hasExtra(EXTRA_PRO_BACKEND_STATUS)) { @@ -298,7 +389,8 @@ object QaLaunchConfig { // 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 + // Expiring, NOT auto-renewing — see the KDoc on EXTRA_PRO_BACKEND_STATUS for why. + "active" -> DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE_LATER "expired" -> DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED else -> { Log.e( @@ -320,6 +412,70 @@ object QaLaunchConfig { return true } + /** Sets the mocked Pro access expiry. See [EXTRA_PRO_ACCESS_EXPIRY] for the accepted forms. */ + private fun applyProAccessExpiry(intent: Intent, prefs: TextSecurePreferences): Boolean { + if (!intent.hasExtra(EXTRA_PRO_ACCESS_EXPIRY)) { + return false + } + + val raw = intent.getStringExtra(EXTRA_PRO_ACCESS_EXPIRY).orEmpty().trim() + + // Deliberately distinguishes "absent" from the explicit-clear sentinel, as the other keys do. + if (raw.equals(USE_ACTUAL, ignoreCase = true)) { + if (prefs.getDebugProAccessExpiry() == null) { + return false + } + Log.i(TAG, "Clearing mocked Pro access expiry") + prefs.setDebugProAccessExpiry(null) + return true + } + + val parsed = parseExpiry(raw) + if (parsed == null) { + Log.e( + TAG, + "Ignoring unparseable '$EXTRA_PRO_ACCESS_EXPIRY' extra: '$raw'. " + + "Use epoch SECONDS, +[smhd], or $USE_ACTUAL." + ) + return false + } + + // Bounded rather than trusted: see EXTRA_PRO_ACCESS_EXPIRY on why a unit slip must be loud. + val now = Instant.now() + val limit = Duration.ofDays(MAX_EXPIRY_SKEW_YEARS * 365) + if (parsed.isBefore(now - limit) || parsed.isAfter(now + limit)) { + Log.e( + TAG, + "Ignoring '$EXTRA_PRO_ACCESS_EXPIRY' extra: '$raw' resolves to $parsed, more than " + + "$MAX_EXPIRY_SKEW_YEARS years from now. Epoch MILLISECONDS passed where SECONDS " + + "are expected is the usual cause." + ) + return false + } + + Log.i(TAG, "Setting mocked Pro access expiry to $parsed") + prefs.setDebugProAccessExpiry(parsed) + return true + } + + /** `+` relative, or bare epoch milliseconds. Null when neither parses. */ + private fun parseExpiry(raw: String): Instant? { + if (raw.startsWith("+")) { + val body = raw.substring(1) + val amount = body.dropLast(1).toLongOrNull() ?: return null + val duration = when (body.lastOrNull()?.lowercaseChar()) { + 's' -> Duration.ofSeconds(amount) + 'm' -> Duration.ofMinutes(amount) + 'h' -> Duration.ofHours(amount) + 'd' -> Duration.ofDays(amount) + else -> return null + } + return Instant.now() + duration + } + + return raw.toLongOrNull()?.let(Instant::ofEpochSecond) + } + /** 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)) { diff --git a/app/src/test/java/org/thoughtcrime/securesms/qa/QaLaunchConfigProAccessExpiryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/qa/QaLaunchConfigProAccessExpiryTest.kt new file mode 100644 index 0000000000..b081489aa2 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/qa/QaLaunchConfigProAccessExpiryTest.kt @@ -0,0 +1,175 @@ +package org.thoughtcrime.securesms.qa + +import android.content.Intent +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import network.loki.messenger.BuildConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsession.utilities.TextSecurePreferences +import java.time.Duration +import java.time.Instant + +/** + * Covers the `sessionProAccessExpiry` launch extra. + * + * Written because that path had **never run end to end**: the Appium harness did not send the key yet, + * so its first exercise would otherwise have been inside a spec, where a parse bug reads as a product + * bug rather than a setup failure. The wrong-unit case below is the whole reason the wire unit is + * seconds, so it is the test that matters most here. + * + * Driven through [QaLaunchConfig.apply] rather than the private parser on purpose — the parse, the + * range guard and the preference write are one contract, and testing the parser alone would not catch a + * value that parses but is never persisted. + */ +@RunWith(RobolectricTestRunner::class) +class QaLaunchConfigProAccessExpiryTest { + + private lateinit var prefs: TextSecurePreferences + private lateinit var snodeDirectory: SnodeDirectory + + @Before + fun setUp() { + // The whole class is compiled out when this is false, so every assertion below would vacuously + // pass on a build type that doesn't opt in. Asserted rather than assumed. + assertTrue( + "Unit tests must run on a variant with ALLOW_QA_LAUNCH_CONFIG=true", + BuildConfig.ALLOW_QA_LAUNCH_CONFIG + ) + + prefs = mockk(relaxed = true) + snodeDirectory = mockk(relaxed = true) + every { prefs.getDebugProAccessExpiry() } returns null + } + + private fun applyExpiry(value: String) { + QaLaunchConfig.apply( + Intent().putExtra("sessionProAccessExpiry", value), + prefs, + snodeDirectory + ) + } + + private fun capturedExpiry(): Instant? { + val captured = slot() + verify { prefs.setDebugProAccessExpiry(captureNullable(captured)) } + return captured.captured + } + + private fun assertRejected() { + verify(exactly = 0) { prefs.setDebugProAccessExpiry(any()) } + } + + @Test + fun `absolute epoch seconds is applied`() { + val expected = Instant.now().plus(Duration.ofDays(30)).epochSecond + + applyExpiry(expected.toString()) + + assertEquals(Instant.ofEpochSecond(expected), capturedExpiry()) + } + + @Test + fun `a past instant is expressible as epoch seconds`() { + // There is deliberately no `-30d` form — a leading hyphen is unsafe through appium-adb — so + // this is the ONLY way to mock an already-lapsed account. If it regresses, the expired-state + // specs lose their only expiry control. + val expected = Instant.now().minus(Duration.ofDays(14)).epochSecond + + applyExpiry(expected.toString()) + + assertEquals(Instant.ofEpochSecond(expected), capturedExpiry()) + } + + @Test + fun `epoch MILLISECONDS is rejected rather than silently mocking the year 58000`() { + // The reason the wire unit is seconds. iOS sends Math.floor(Date.now() / 1000); a harness that + // forgets the divide would land ~56,000 years out, and applying it would surface as a wrong + // rendered date somewhere far from the cause. + applyExpiry(System.currentTimeMillis().toString()) + + assertRejected() + } + + @Test + fun `relative offsets are applied for every accepted unit`() { + val cases = mapOf( + "+30d" to Duration.ofDays(30), + "+12h" to Duration.ofHours(12), + "+45m" to Duration.ofMinutes(45), + "+90s" to Duration.ofSeconds(90), + ) + + cases.forEach { (raw, offset) -> + prefs = mockk(relaxed = true) + every { prefs.getDebugProAccessExpiry() } returns null + + val before = Instant.now() + applyExpiry(raw) + val after = Instant.now() + + // Bounded rather than exact: the value is built from a clock read inside the call. + val captured = capturedExpiry()!! + assertTrue( + "$raw resolved to $captured, outside [$before, $after] + $offset", + !captured.isBefore(before.plus(offset)) && !captured.isAfter(after.plus(offset)) + ) + } + } + + @Test + fun `useActual clears a previously set override`() { + every { prefs.getDebugProAccessExpiry() } returns Instant.now() + + applyExpiry("useactual") + + verify { prefs.setDebugProAccessExpiry(null) } + } + + @Test + fun `useActual is a no-op when no override is set`() { + every { prefs.getDebugProAccessExpiry() } returns null + + applyExpiry("useactual") + + assertRejected() + } + + @Test + fun `unparseable values are rejected`() { + // "30d" without the `+` is the plausible typo: it must not be read as 30 epoch seconds. + listOf("30d", "+", "+d", "+30x", "later", "", "+30 d").forEach { raw -> + prefs = mockk(relaxed = true) + every { prefs.getDebugProAccessExpiry() } returns null + + applyExpiry(raw) + + verify(exactly = 0) { prefs.setDebugProAccessExpiry(any()) } + } + } + + @Test + fun `an absent extra leaves the preference untouched`() { + QaLaunchConfig.apply( + Intent().putExtra("sessionProBackendStatus", "active"), + prefs, + snodeDirectory + ) + + assertRejected() + } + + @Test + fun `an out of range relative offset is rejected`() { + applyExpiry("+9999d") + + assertRejected() + } +} From 7010bf6d5dadc20135f557399e3acfaa6542c50b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 11 Aug 2026 17:12:53 +1000 Subject: [PATCH 8/8] Pro: stop the settings header erasing the status banner, and name the remaining surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `SessionProSettingsHeader` applied `clearAndSetSemantics` to the whole column, which erases descendants rather than merging them — so the status banner was absent from the tree entirely, text and all, and TalkBack announced "Session Pro" over a message users never heard. Narrow it to the decorative logo and badge, leaving `extraContent` a sibling inside the column that carries `onSizeChanged` (moving it out would change the gradient ratio). - Tag both banner variants with `pro-settings-status-banner`. One id for the slot; the four states are told apart by their text. - Section-header ids for Stats, Manage and Pro Beta Features, via one optional `CategoryCell` parameter so the other call sites are untouched. - Name the conversation-header badge, matching iOS. --- .../prosettings/ProSettingsHomeScreen.kt | 13 +++ .../thoughtcrime/securesms/ui/Components.kt | 15 +++- .../securesms/ui/ProComponents.kt | 81 ++++++++++++------- .../ui/components/ConversationAppBar.kt | 8 +- .../src/main/res/values/strings.xml | 15 ++++ 5 files changed, 103 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 7742c407bf..9e490a1192 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -157,6 +157,10 @@ fun ProSettingsHome( horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) ) { Text( + // One id for the slot, shared with the error state below: the four + // possible messages are told apart by their text, not by separate ids, so + // it belongs on the node that carries the message rather than the Row. + modifier = Modifier.qaTag(R.string.qa_pro_settings_status_banner), text = Phrase.from(context.getText( when(subscriptionType){ is ProStatus.Active -> R.string.proStatusLoadingSubtitle @@ -178,6 +182,8 @@ fun ProSettingsHome( horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing) ) { Text( + // Same id as the loading state above — deliberately. See there. + modifier = Modifier.qaTag(R.string.qa_pro_settings_status_banner), text = Phrase.from(context.getText( when(subscriptionType){ is ProStatus.Active -> R.string.proErrorRefreshingStatus @@ -318,6 +324,7 @@ fun ProStats( dropShadow = LocalColors.current.isLight, title = Phrase.from(LocalContext.current, R.string.proStats) .format().toString(), + titleQaTag = R.string.qa_pro_settings_stats_header, titleIcon = { val tooltipState = rememberTooltipState(isPersistent = true) val scope = rememberCoroutineScope() @@ -519,6 +526,7 @@ fun ProSettings( modifier = modifier, title = Phrase.from(LocalContext.current, R.string.proSettings) .format().toString(), + titleQaTag = R.string.qa_pro_settings_manage_header, ) { val refunding = proStatus.refundInProgress @@ -590,6 +598,10 @@ fun ProSettings( } }, qaTag = R.string.qa_pro_settings_action_update_plan, + // The remaining-access line. Uniquely identified rather than left as the shared + // `action-item-subtitle`, which is on every row here and so can only be addressed by + // traversing from this row — a traversal that breaks whenever the layout is restructured. + subtitleQaTag = R.string.qa_pro_settings_update_plan_subtitle, onClick = { sendCommand(GoToChoosePlan(inSheet)) } ) Divider() @@ -623,6 +635,7 @@ fun ProFeatures( modifier = modifier, title = Phrase.from(LocalContext.current, R.string.proBetaFeatures) .format().toString(), + titleQaTag = R.string.qa_pro_settings_features_header, ) { // Cell content Column( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index a57e43a786..c4ea1f821b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -462,6 +462,12 @@ fun getCellBottomShape() = RoundedCornerShape( fun CategoryCell( modifier: Modifier = Modifier, title: String? = null, + /** + * QA id for the section header. Off by default, since most cells are addressed by their content + * rather than their heading; pass one where a test needs the section itself. Note the header only + * composes when [title] or [titleIcon] is present, so the id follows the header's existence. + */ + @StringRes titleQaTag: Int? = null, titleIcon: @Composable (() -> Unit)? = null, dropShadow: Boolean = false, content: @Composable () -> Unit, @@ -482,6 +488,7 @@ fun CategoryCell( if (!title.isNullOrEmpty()) { Text( text = title, + modifier = Modifier.qaTag(titleQaTag), style = LocalType.current.base, color = LocalColors.current.textSecondary ) @@ -1414,6 +1421,12 @@ fun ActionRowItem( modifier: Modifier = Modifier, enabled: Boolean = true, subtitle: AnnotatedString? = null, + /** + * Overrides the subtitle's QA id. Defaults to the shared [R.string.qa_action_item_subtitle], + * which is on every action row and so cannot identify a particular one. Mirrors the + * `subtitleQaTag` parameter the other row composables in this file already take. + */ + @StringRes subtitleQaTag: Int? = null, titleColor: Color = LocalColors.current.text, subtitleColor: Color = LocalColors.current.text, textStyle: TextStyle = LocalType.current.h8, @@ -1454,7 +1467,7 @@ fun ActionRowItem( text = it, modifier = Modifier .fillMaxWidth() - .qaTag(R.string.qa_action_item_subtitle), + .qaTag(subtitleQaTag ?: R.string.qa_action_item_subtitle), style = subtitleStyle, color = subtitleColor ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index 7d51a6b5c0..4847304e65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.animation.Crossfade import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState @@ -162,7 +163,14 @@ fun ProBadgeText( showBadge: Boolean = true, badgeColors: ProBadgeColors = proBadgeColorStandard(), badgeAtStart: Boolean = false, - onBadgeClick: (() -> Unit)? = null + onBadgeClick: (() -> Unit)? = null, + /** + * Overrides the badge icon's QA id. Defaults to the shared [R.string.qa_pro_badge_icon], which is + * on every badge in the app and so cannot identify a particular one. Pass a unique id where a test + * needs to address *this* badge, rather than having it reach the shared id by traversing from a + * parent — a traversal encodes a layout detail and breaks when the layout is restructured. + */ + @StringRes badgeQaTag: Int = R.string.qa_pro_badge_icon ) { Row( modifier = modifier.qaTag(stringResource(R.string.qa_pro_badge_component)), @@ -179,7 +187,7 @@ fun ProBadgeText( } ProBadge( modifier = proBadgeModifier.height(textStyle.lineHeight.value.dp * 0.8f) - .qaTag(stringResource(R.string.qa_pro_badge_icon)), + .qaTag(stringResource(badgeQaTag)), colors = badgeColors ) } @@ -1069,40 +1077,59 @@ fun SessionProSettingsHeader( .padding(horizontal = LocalDimensions.current.spacing) .onSizeChanged { newSizeDp -> headerSize = newSizeDp - } - .clearAndSetSemantics{ - contentDescription = NonTranslatableStringConstants.APP_PRO }, horizontalAlignment = Alignment.CenterHorizontally ) { - Image( - modifier = Modifier.size(LocalDimensions.current.iconXXLarge), - painter = painterResource(id = R.drawable.session_logo), - contentDescription = null, - colorFilter = ColorFilter.tint(color) - ) + // The semantics collapse covers the DECORATIVE branding only — a logo, an icon and a + // badge that would otherwise surface as three unlabelled images. It deliberately does + // NOT extend to [extraContent]. + // + // It used to sit on the outer Column, which swallowed extraContent too. Note + // `clearAndSetSemantics` ERASES descendants rather than merging them, so that had two + // effects worth remembering: any test tag inside was deleted rather than absorbed, and + // the status message's TEXT was absent from the tree entirely — meaning a TalkBack user + // heard "Session Pro" for the whole header and never heard "Checking Pro status…" or + // the error. If you widen this again, that is what you are re-breaking. + // + // The collapse stays OFF the outer Column for a second, unrelated reason: that one + // carries `onSizeChanged`, and `headerSize` drives the radial gradient's ratio above. + // Keeping extraContent inside the measured node is what stops the gradient's scale + // shifting whenever the banner is visible. + Column( + modifier = Modifier.clearAndSetSemantics { + contentDescription = NonTranslatableStringConstants.APP_PRO + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + modifier = Modifier.size(LocalDimensions.current.iconXXLarge), + painter = painterResource(id = R.drawable.session_logo), + contentDescription = null, + colorFilter = ColorFilter.tint(color) + ) - Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) - // Force the row to remain in LTR to preserve the image+icon order - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { - Row( - modifier = Modifier.height(LocalDimensions.current.smallSpacing) - ) { - Image( - painter = painterResource(R.drawable.ic_session), - contentDescription = null, - colorFilter = ColorFilter.tint(LocalColors.current.text) - ) + // Force the row to remain in LTR to preserve the image+icon order + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + modifier = Modifier.height(LocalDimensions.current.smallSpacing) + ) { + Image( + painter = painterResource(R.drawable.ic_session), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.text) + ) - Spacer(Modifier.width(LocalDimensions.current.xxxsSpacing)) + Spacer(Modifier.width(LocalDimensions.current.xxxsSpacing)) - ProBadge( - colors = proBadgeColorStandard().copy( - backgroundColor = color + ProBadge( + colors = proBadgeColorStandard().copy( + backgroundColor = color + ) ) - ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt index 32020dba2d..11c7cca4bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ConversationAppBar.kt @@ -107,7 +107,13 @@ fun ConversationAppBar( ProBadgeText( modifier = titleModifier.qaTag(R.string.AccessibilityId_conversationTitle), text = data.title, - showBadge = data.showProBadge + showBadge = data.showProBadge, + // The shared `pro-badge-icon` is on every badge in the app, and the + // name text beside it renders unconditionally — so a test reaching + // this badge by traversing to the *text* would pass whether or not + // the badge was shown. This id addresses the badge itself, which is + // the thing worth asserting. + badgeQaTag = R.string.qa_conversation_header_pro_badge ) if (data.pagerData.isNotEmpty()) { diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index c96013b231..6856e1861e 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -354,6 +354,21 @@ pro-badge-component pro-badge-text pro-badge-icon + + conversation-header-pro-badge + pro-settings-update-plan-subtitle + + pro-settings-stats-header + pro-settings-manage-header + pro-settings-features-header + + pro-settings-status-banner action-item-title action-item-subtitle action-item-icon