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..3c5615c098 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 @@ -69,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 @@ -190,6 +193,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. @@ -206,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 @@ -314,6 +330,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" @@ -368,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" @@ -949,6 +968,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?) { @@ -1216,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 536a120427..6cd4d63c24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -659,16 +659,29 @@ 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. + * + * 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)"), 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)"), - EXPIRED("Expired (Expired 2 days ago, Google)"), + EXPIRING_APPLE("Expiring/Cancelled (Expires in 2 days, Apple, 1 months)"), + 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/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/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/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..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,72 +157,87 @@ 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 ) + // 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), + 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(40), + 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, @@ -227,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) @@ -503,10 +543,44 @@ 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. **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 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 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 } } \ 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..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,12 +1,16 @@ 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 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. @@ -49,6 +53,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 +65,72 @@ 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" + + /** + * 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`. + */ + 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. @@ -83,9 +156,15 @@ 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) return @@ -102,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. * @@ -144,6 +263,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 +353,154 @@ 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. 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)) { + 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 + // 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( + 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 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)) { + 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/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/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" /> + + () + 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() + } +} 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