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/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/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/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/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, 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..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), @@ -503,10 +509,37 @@ 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" + + /** + * 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 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..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" @@ -58,6 +62,40 @@ 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" + + /** + * 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. @@ -86,6 +124,9 @@ 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) + applyProBackendStatus(intent, prefs) + applyProLoadingState(intent, prefs) } catch (e: RuntimeException) { Log.e(TAG, "Ignoring unreadable launch extras", e) return @@ -144,6 +185,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. @@ -179,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 + } + } 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" /> + +