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 52b3833862..c6c6f88111 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -234,8 +234,60 @@ interface TextSecurePreferences { fun setDebugProPlanStatus(status: DebugMenuViewModel.DebugProPlanStatus?) fun getDebugForceNoBilling(): Boolean fun setDebugForceNoBilling(hasBilling: Boolean) - fun getDebugIsWithinQuickRefund(): Boolean - fun setDebugIsWithinQuickRefund(isWithin: Boolean) + /** + * Mocked quick-refund window: `true` forces it open, `false` forces it closed, `null` for no override + * so the fixture's own window stands. + * + * Tri-state because every debug fixture sets the window open; a plain boolean defaulting to false + * would close it for every existing test. Applied by moving `quickRefundExpiry`, which is the single + * representation of the window — see `ProStatusManager.withMockedQuickRefundWindow`. + */ + fun getDebugQuickRefundWindowOverride(): Boolean? + fun setDebugQuickRefundWindowOverride(isWithin: Boolean?) + + /** + * Mocked refund-pending override: `true` forces a refund in progress, `false` forces none, `null` + * means no override so the real state governs. + * + * Tri-state as [getDebugProAccessOverride] is: the real state is a synced config flag another device + * can have set, so `false` and `null` differ. Orthogonal to [getDebugSubscriptionType] so it composes + * with any fixture. + */ + fun getDebugRefundInProgressOverride(): Boolean? + fun setDebugRefundInProgressOverride(refunding: Boolean?) + + /** + * Mocked originating payment provider (a `BackendRequests.PAYMENT_PROVIDER_*` slug), or `null` for no + * override so the fixture's own provider stands. + * + * This is Android's equivalent of iOS's `mockCurrentUserSessionProOriginatingPlatform`: the provider + * slug is what every "was this bought elsewhere" decision reads, via + * `PaymentProviderMetadata.isFromAnotherPlatform`. + */ + fun getDebugOriginatingProvider(): String? + fun setDebugOriginatingProvider(providerSlug: String?) + + /** + * Mocked auto-renewing override: `true` forces a renewing plan, `false` forces one that runs to its + * end date, `null` for no override. + * + * The flag the "Pro auto-renewing in {time}" line, the renewal-unsuccessful state and the Cancel Pro + * Access action all read. Tri-state like the others so "force not renewing" and "do not mock" stay + * distinguishable. + */ + fun getDebugAutoRenewingOverride(): Boolean? + fun setDebugAutoRenewingOverride(autoRenewing: Boolean?) + + /** + * Mocked originating-account override: `true` forces the store account currently signed in to be the + * one that bought the subscription, `false` forces a different one, `null` for no override. + * + * Overrides `SubscriptionManager.hasValidSubscription()`, which is what the screens read as "same + * platform but a different account" — iOS spells the same fact + * `mockCurrentUserOriginatingAccount`. + */ + fun getDebugOriginatingAccountOverride(): Boolean? + fun setDebugOriginatingAccountOverride(isOriginating: Boolean?) fun setSubscriptionProvider(provider: String) fun getSubscriptionProvider(): String? @@ -399,6 +451,10 @@ interface TextSecurePreferences { 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" + const val DEBUG_PRO_REFUND_IN_PROGRESS = "debug_pro_refund_in_progress" + const val DEBUG_PRO_ORIGINATING_PROVIDER = "debug_pro_originating_provider" + const val DEBUG_PRO_AUTO_RENEWING = "debug_pro_auto_renewing" + const val DEBUG_PRO_ORIGINATING_ACCOUNT = "debug_pro_originating_account" const val SUBSCRIPTION_PROVIDER = "session_subscription_provider" const val DEBUG_AVATAR_REUPLOAD = "debug_avatar_reupload" @@ -1298,13 +1354,44 @@ class AppTextSecurePreferences @Inject constructor( _events.tryEmit(TextSecurePreferences.DEBUG_FORCE_NO_BILLING) } - override fun getDebugIsWithinQuickRefund(): Boolean { - return getBooleanPreference(TextSecurePreferences.DEBUG_WITHIN_QUICK_REFUND, false) + override fun getDebugOriginatingAccountOverride(): Boolean? = + getStringPreference(TextSecurePreferences.DEBUG_PRO_ORIGINATING_ACCOUNT, null)?.toBooleanStrictOrNull() + + override fun setDebugOriginatingAccountOverride(isOriginating: Boolean?) { + setStringPreference(TextSecurePreferences.DEBUG_PRO_ORIGINATING_ACCOUNT, isOriginating?.toString()) + _events.tryEmit(TextSecurePreferences.DEBUG_PRO_ORIGINATING_ACCOUNT) } - override fun setDebugIsWithinQuickRefund(isWithin: Boolean) { - setBooleanPreference(TextSecurePreferences.DEBUG_WITHIN_QUICK_REFUND, isWithin) - _events.tryEmit(TextSecurePreferences.DEBUG_FORCE_NO_BILLING) + override fun getDebugAutoRenewingOverride(): Boolean? = + getStringPreference(TextSecurePreferences.DEBUG_PRO_AUTO_RENEWING, null)?.toBooleanStrictOrNull() + + override fun setDebugAutoRenewingOverride(autoRenewing: Boolean?) { + setStringPreference(TextSecurePreferences.DEBUG_PRO_AUTO_RENEWING, autoRenewing?.toString()) + _events.tryEmit(TextSecurePreferences.DEBUG_PRO_AUTO_RENEWING) + } + + override fun getDebugOriginatingProvider(): String? = + getStringPreference(TextSecurePreferences.DEBUG_PRO_ORIGINATING_PROVIDER, null) + + override fun setDebugOriginatingProvider(providerSlug: String?) { + setStringPreference(TextSecurePreferences.DEBUG_PRO_ORIGINATING_PROVIDER, providerSlug) + _events.tryEmit(TextSecurePreferences.DEBUG_PRO_ORIGINATING_PROVIDER) + } + + override fun getDebugRefundInProgressOverride(): Boolean? = + getStringPreference(TextSecurePreferences.DEBUG_PRO_REFUND_IN_PROGRESS, null)?.toBooleanStrictOrNull() + + override fun setDebugRefundInProgressOverride(refunding: Boolean?) { + setStringPreference(TextSecurePreferences.DEBUG_PRO_REFUND_IN_PROGRESS, refunding?.toString()) + _events.tryEmit(TextSecurePreferences.DEBUG_PRO_REFUND_IN_PROGRESS) + } + + override fun getDebugQuickRefundWindowOverride(): Boolean? = + getStringPreference(TextSecurePreferences.DEBUG_WITHIN_QUICK_REFUND, null)?.toBooleanStrictOrNull() + + override fun setDebugQuickRefundWindowOverride(isWithin: Boolean?) { + setStringPreference(TextSecurePreferences.DEBUG_WITHIN_QUICK_REFUND, isWithin?.toString()) + _events.tryEmit(TextSecurePreferences.DEBUG_WITHIN_QUICK_REFUND) } override fun getSubscriptionProvider(): String? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt index 2f63c5abb2..55c6c89fec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt @@ -683,13 +683,21 @@ class RecipientRepository @Inject constructor( configFactory.withUserConfigs { configs -> val pro = configs.userProfile.getProConfig() - if (prefs.forceCurrentUserAsPro()) { + // Honours the tri-state access override first, then the legacy flag. Without this + // the two levers disagree: a `proProof=none` fixture would still be given Pro data + // here whenever the legacy flag happened to be set, and a `proProof=valid` one + // would not be given any unless it was. + if (prefs.getDebugProAccessOverride() ?: prefs.forceCurrentUserAsPro()) { proDataContext?.addProData( RecipientSettings.ProData( showProBadge = configs.userProfile.getProFeatures().contains( ProProfileFeature.PRO_BADGE ), - expiry = Instant.now().plusSeconds(3600), + // The mocked access expiry when one was set, so this and the Pro state + // report the same date. It used to hard-code an hour from now, which made + // it a second and silently disagreeing source for the same fact. + expiry = prefs.getDebugProAccessExpiry() + ?: Instant.now().plusSeconds(3600), revocationTag = "a1b2c3d4", ) ) 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 13762dcca3..60d2270261 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -133,7 +133,7 @@ class DebugMenuViewModel @AssistedInject constructor( .flatMap { it.availablePlans.asSequence().map { plan -> DebugProPlan(it, plan) } } .toList(), forceNoBilling = textSecurePreferences.getDebugForceNoBilling(), - withinQuickRefund = textSecurePreferences.getDebugIsWithinQuickRefund(), + withinQuickRefund = textSecurePreferences.getDebugQuickRefundWindowOverride() == true, availableAltFileServers = TEST_FILE_SERVERS, alternativeFileServer = textSecurePreferences.alternativeFileServer, showToastForGroups = getDebugGroupToastPref(), @@ -342,7 +342,7 @@ class DebugMenuViewModel @AssistedInject constructor( } is Commands.WithinQuickRefund -> { - textSecurePreferences.setDebugIsWithinQuickRefund(command.set) + textSecurePreferences.setDebugQuickRefundWindowOverride(command.set) _uiState.update { it.copy(withinQuickRefund = command.set) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index 7a2497e9f3..fd56b19de8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.preferences.prosettings import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -46,6 +47,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.dialog.DialogBg import org.thoughtcrime.securesms.ui.SessionProSettingsHeader @@ -75,6 +77,8 @@ fun BaseProSettingsScreen( onBack: () -> Unit, onHeaderClick: (() -> Unit)? = null, extraHeaderContent: @Composable (() -> Unit)? = null, + /** Identifies WHICH store-flow screen this is, so a spec can assert the destination a state opens. */ + @StringRes screenQaTag: Int? = null, content: @Composable LazyItemScope.() -> Unit ){ // Calculate scroll fraction @@ -123,7 +127,8 @@ fun BaseProSettingsScreen( LazyColumn( modifier = Modifier .fillMaxWidth() - .consumeWindowInsets(paddings), + .consumeWindowInsets(paddings) + .qaTag(screenQaTag), state = listState, contentPadding = safeInsetsPadding, horizontalAlignment = CenterHorizontally @@ -153,17 +158,20 @@ fun BaseCellButtonProSettingsScreen( dangerButton: Boolean, onButtonClick: () -> Unit, title: CharSequence? = null, + @StringRes screenQaTag: Int? = null, content: @Composable LazyItemScope.() -> Unit ) { BaseProSettingsScreen( disabled = disabled, onBack = onBack, + screenQaTag = screenQaTag, ) { Spacer(Modifier.height(LocalDimensions.current.spacing)) if(!title.isNullOrEmpty()) { Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() + .qaTag(R.string.qa_pro_screen_header), text = annotatedStringResource(title), textAlign = TextAlign.Center, style = LocalType.current.base, @@ -189,14 +197,16 @@ fun BaseCellButtonProSettingsScreen( if (dangerButton) { DangerFillButtonRect( modifier = Modifier.fillMaxWidth() - .widthIn(max = LocalDimensions.current.maxContentWidth), + .widthIn(max = LocalDimensions.current.maxContentWidth) + .qaTag(R.string.qa_pro_screen_action), text = buttonText, onClick = onButtonClick ) } else { AccentFillButtonRect( modifier = Modifier.fillMaxWidth() - .widthIn(max = LocalDimensions.current.maxContentWidth), + .widthIn(max = LocalDimensions.current.maxContentWidth) + .qaTag(R.string.qa_pro_screen_action), text = buttonText, onClick = onButtonClick ) @@ -245,6 +255,7 @@ fun BaseNonOriginatingProSettingsScreen( contentClick: (() -> Unit)? = null, linkCellsInfo: String?, linkCells: List = emptyList(), + @StringRes screenQaTag: Int? = null, ) { BaseCellButtonProSettingsScreen( disabled = disabled, @@ -253,9 +264,11 @@ fun BaseNonOriginatingProSettingsScreen( dangerButton = dangerButton, onButtonClick = onButtonClick, title = headerTitle, + screenQaTag = screenQaTag, ){ if (contentTitle != null) { Text( + modifier = Modifier.qaTag(R.string.qa_pro_screen_title), text = contentTitle, style = LocalType.current.h7, color = LocalColors.current.text, @@ -265,7 +278,7 @@ fun BaseNonOriginatingProSettingsScreen( if (contentDescription != null) { Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) Text( - modifier = Modifier.then( + modifier = Modifier.qaTag(R.string.qa_pro_screen_description).then( // make the component clickable is there is an action if (contentClick != null) Modifier.clickable( interactionSource = remember { MutableInteractionSource() }, @@ -313,6 +326,7 @@ fun NonOriginatingLinkCell( ) { Row( modifier = Modifier.fillMaxWidth() + .qaTag(data.qaTag) .padding(LocalDimensions.current.smallSpacing), horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) ) { @@ -348,6 +362,7 @@ fun NonOriginatingLinkCell( modifier = Modifier.weight(1f) ) { Text( + modifier = Modifier.qaTag(data.titleQaTag), text = annotatedStringResource(data.title), style = LocalType.current.base.bold(), color = LocalColors.current.text, @@ -356,6 +371,7 @@ fun NonOriginatingLinkCell( Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) Text( + modifier = Modifier.qaTag(data.descriptionQaTag), text = annotatedStringResource(data.info), style = LocalType.current.base, color = LocalColors.current.text, @@ -370,7 +386,16 @@ data class NonOriginatingLinkCellData( val title: CharSequence, val info: CharSequence, @DrawableRes val iconRes: Int, - val onClick: (() -> Unit)? = null + val onClick: (() -> Unit)? = null, + /** + * QA ids for this option's cell, title and description. + * + * Named per option rather than generically like the rest of the screen, because two of these are + * shown at once so there is no single "the description" to address. + */ + @StringRes val qaTag: Int? = null, + @StringRes val titleQaTag: Int? = null, + @StringRes val descriptionQaTag: Int? = null, ) @Preview diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt index 1f81e98db9..3f530795d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt @@ -31,6 +31,7 @@ fun CancelPlanNonOriginating( val context = LocalContext.current BaseNonOriginatingProSettingsScreen( + screenQaTag = R.string.qa_pro_screen_cancel_plan_non_originating, disabled = true, onBack = onBack, headerTitle = Phrase.from(context.getText(R.string.proCancelSorry)) @@ -58,7 +59,10 @@ fun CancelPlanNonOriginating( .put(DEVICE_TYPE_KEY, providerData.device) .put(PLATFORM_ACCOUNT_KEY, providerData.platformAccount) .format(), - iconRes = R.drawable.ic_smartphone + iconRes = R.drawable.ic_smartphone, + qaTag = R.string.qa_pro_link_cell_device, + titleQaTag = R.string.qa_pro_link_cell_device_title, + descriptionQaTag = R.string.qa_pro_link_cell_device_description, ), NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onPlatformWebsite)) @@ -68,7 +72,10 @@ fun CancelPlanNonOriginating( .put(PLATFORM_STORE_KEY, providerData.store) .put(PLATFORM_ACCOUNT_KEY, providerData.platformAccount) .format(), - iconRes = R.drawable.ic_globe + iconRes = R.drawable.ic_globe, + qaTag = R.string.qa_pro_link_cell_website, + titleQaTag = R.string.qa_pro_link_cell_website_title, + descriptionQaTag = R.string.qa_pro_link_cell_website_description, ) ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt index 65b245055e..07d7331509 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt @@ -97,6 +97,7 @@ fun CancelPlan( } BaseCellButtonProSettingsScreen( + screenQaTag = R.string.qa_pro_screen_cancel_plan, disabled = true, onBack = onBack, buttonText = Phrase.from(context.getText(R.string.cancelAccess)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt index edc1325934..e1cce26d1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt @@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.pro.ProDataState import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.previewAutoRenewingApple import org.thoughtcrime.securesms.pro.previewExpiredApple +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.SessionProSettingsHeader import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -90,6 +91,7 @@ fun PlanConfirmation( modifier = Modifier .fillMaxSize() .consumeWindowInsets(paddings) + .qaTag(R.string.qa_pro_screen_plan_confirmation) .padding( horizontal = LocalDimensions.current.spacing, ) @@ -105,7 +107,8 @@ fun PlanConfirmation( Spacer(Modifier.height(LocalDimensions.current.spacing)) Text( - modifier = Modifier.align(CenterHorizontally), + modifier = Modifier.align(CenterHorizontally) + .qaTag(R.string.qa_pro_screen_title), text = stringResource(R.string.proAllSet), style = LocalType.current.h6, color = LocalColors.current.text, @@ -133,7 +136,8 @@ fun PlanConfirmation( Text( modifier = Modifier.align(CenterHorizontally) - .safeContentWidth(), + .safeContentWidth() + .qaTag(R.string.qa_pro_screen_description), text = annotatedStringResource(description), textAlign = TextAlign.Center, style = LocalType.current.base, @@ -154,7 +158,8 @@ fun PlanConfirmation( AccentFillButtonRect( modifier = Modifier.fillMaxWidth() - .widthIn(max = LocalDimensions.current.maxContentWidth), + .widthIn(max = LocalDimensions.current.maxContentWidth) + .qaTag(R.string.qa_pro_screen_action), text = buttonLabel, onClick = { sendCommand(ProSettingsViewModel.Commands.OnPostPlanConfirmation) 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 bb29b4c033..616255859b 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 @@ -388,7 +388,8 @@ fun ProStats( ) { // Long Messages ProStatItem( - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) + .qaTag(R.string.qa_pro_stats_longer_messages), title = pluralStringResource( R.plurals.proLongerMessagesSent, stats?.longMessages ?: 0, @@ -401,7 +402,8 @@ fun ProStats( // Pinned Convos ProStatItem( - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) + .qaTag(R.string.qa_pro_stats_pinned_conversations), title = pluralStringResource( R.plurals.proPinnedConversations, stats?.pinnedConversations ?: 0, @@ -419,7 +421,8 @@ fun ProStats( ) { // Pro Badges ProStatItem( - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) + .qaTag(R.string.qa_pro_stats_badges_sent), title = pluralStringResource( R.plurals.proBadgesSent, stats?.proBadges ?: 0, @@ -433,7 +436,8 @@ fun ProStats( // groups updated ProStatItem( - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f) + .qaTag(R.string.qa_pro_stats_groups_upgraded), title = pluralStringResource( R.plurals.proGroupsUpgraded, stats?.groupsUpdated ?: 0, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 6cc28ea357..8f84751335 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -294,7 +294,10 @@ class ProSettingsViewModel @AssistedInject constructor( // first check if the user has a valid subscription and billing val hasBillingCapacity = subscriptionCoordinator.getCurrentManager().supportsBilling.value - val hasValidSub = subscriptionCoordinator.getCurrentManager().hasValidSubscription() + // The override is what makes "same platform, different store account" expressible; without it + // this always reports the signed-in account as the buyer on a mocked fixture. + val hasValidSub = prefs.getDebugOriginatingAccountOverride() + ?: subscriptionCoordinator.getCurrentManager().hasValidSubscription() // next get the plans, including their pricing, unless there is no billing // or the user is pro without a valid subscription @@ -337,7 +340,8 @@ class ProSettingsViewModel @AssistedInject constructor( _cancelPlanState.update { State.Loading } viewModelScope.launch { _cancelPlanState.update { State.Loading } - val hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription() + val hasValidSubscription = prefs.getDebugOriginatingAccountOverride() + ?: subscriptionCoordinator.getCurrentManager().hasValidSubscription() _cancelPlanState.update { State.Success( @@ -358,14 +362,20 @@ class ProSettingsViewModel @AssistedInject constructor( viewModelScope.launch { _refundPlanState.update { - val isQuickRefund = if(prefs.forceCurrentUserAsPro()) prefs.getDebugIsWithinQuickRefund()// debug mode - else sub.isWithinQuickRefundWindow(clock.currentTime()) + // One source for the window: the plan's own `quickRefundExpiry`. A debug override moves that + // date (see `ProStatusManager.withMockedQuickRefundWindow`) rather than being read here, so + // this no longer needs a debug branch — and no longer depends on the legacy + // `forceCurrentUserAsPro`, which made the override inert for any fixture granting access + // through the current lever. + val isQuickRefund = sub.isWithinQuickRefundWindow(clock.currentTime()) State.Success( RefundPlanState( proStatus = sub, isQuickRefund = isQuickRefund, - quickRefundUrl = sub.providerData.refundPlatformUrl + quickRefundUrl = sub.providerData.refundPlatformUrl, + hasValidSubscription = prefs.getDebugOriginatingAccountOverride() + ?: subscriptionCoordinator.getCurrentManager().hasValidSubscription() ) ) } @@ -1033,7 +1043,11 @@ class ProSettingsViewModel @AssistedInject constructor( data class RefundPlanState( val proStatus: ProStatus.Active.WithPlan, val isQuickRefund: Boolean, - val quickRefundUrl: String? + val quickRefundUrl: String?, + // Whether the store account signed in here is the one that bought the plan. The refund screen + // needs it for the same reason cancel and choose-plan do: a refund can only be requested from the + // buying account, so a different one has to be sent to the non-originating screen. + val hasValidSubscription: Boolean ) data class ProStats( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundInProgress.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundInProgress.kt index e72a30955f..d2c742e403 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundInProgress.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundInProgress.kt @@ -62,6 +62,7 @@ fun RefundInProgress( val context = LocalContext.current BaseCellButtonProSettingsScreen( + screenQaTag = R.string.qa_pro_screen_refund_in_progress, disabled = true, onBack = onBack, buttonText = stringResource(R.string.theReturn), diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt index 3ba83d4cec..bb987d3b0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt @@ -30,6 +30,7 @@ fun RefundPlanNonOriginating( val context = LocalContext.current BaseNonOriginatingProSettingsScreen( + screenQaTag = R.string.qa_pro_screen_refund_plan_non_originating, disabled = true, onBack = onBack, headerTitle = stringResource(R.string.proRefundDescription), @@ -56,7 +57,10 @@ fun RefundPlanNonOriginating( .put(DEVICE_TYPE_KEY, subscription.providerData.device) .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) .format(), - iconRes = R.drawable.ic_smartphone + iconRes = R.drawable.ic_smartphone, + qaTag = R.string.qa_pro_link_cell_device, + titleQaTag = R.string.qa_pro_link_cell_device_title, + descriptionQaTag = R.string.qa_pro_link_cell_device_description, ), NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onPlatformWebsite)) @@ -66,7 +70,10 @@ fun RefundPlanNonOriginating( .put(PLATFORM_KEY, subscription.providerData.platform) .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) .format(), - iconRes = R.drawable.ic_globe + iconRes = R.drawable.ic_globe, + qaTag = R.string.qa_pro_link_cell_website, + titleQaTag = R.string.qa_pro_link_cell_website_title, + descriptionQaTag = R.string.qa_pro_link_cell_website_description, ) ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt index 99d2b001d3..9349b32977 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.C import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.isFromAnotherPlatform import org.thoughtcrime.securesms.pro.previewAutoRenewingApple +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -54,8 +55,15 @@ fun RefundPlanScreen( // there are different UI depending on the state when { - // there is an active subscription but from a different platform - activePlan.providerData.isFromAnotherPlatform() -> + // there is an active subscription but from a different platform or from the same platform + // but a different account + // + // The account half matters as much as the platform half: a refund can only be requested from + // the account that bought the plan, so offering the originating screen to a different account + // offers an action it cannot complete. `CancelPlanScreen` and `ChoosePlanHomeScreen` have + // always made both checks; this screen only made the first. + activePlan.providerData.isFromAnotherPlatform() + || !refundData.hasValidSubscription -> RefundPlanNonOriginating( subscription = activePlan, sendCommand = viewModel::onCommand, @@ -86,6 +94,7 @@ fun RefundPlan( val context = LocalContext.current BaseCellButtonProSettingsScreen( + screenQaTag = R.string.qa_pro_screen_refund_plan, disabled = true, onBack = onBack, buttonText = if(isQuickRefund) Phrase.from(context.getText(R.string.openPlatformWebsite)) @@ -104,6 +113,7 @@ fun RefundPlan( ){ Column { Text( + modifier = Modifier.qaTag(R.string.qa_pro_screen_title), text = Phrase.from(context.getText(R.string.proRefunding)) .format().toString(), style = LocalType.current.base.bold(), @@ -113,6 +123,9 @@ fun RefundPlan( Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) Text( + // The one line that separates the two refund routes this screen offers: inside the store's + // own window it points at the store, outside it at Session Support. + modifier = Modifier.qaTag(R.string.qa_pro_screen_description), text = annotatedStringResource( if(isQuickRefund) Phrase.from(context.getText(R.string.proRefundRequestStorePolicies)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt index 9a12e099f6..3d181147d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt @@ -92,6 +92,7 @@ fun ChoosePlan( BaseProSettingsScreen( disabled = false, onBack = onBack, + screenQaTag = R.string.qa_pro_screen_choose_plan, ) { // Keeps track of the badge height dynamically so we can adjust the padding accordingly // This is better than a static badge height since users can change their font settings diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index b6416890cb..0958f5e879 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -132,7 +132,10 @@ fun ChoosePlanNoBilling( NonOriginatingLinkCellData( title = stringResource(R.string.proNewInstallation), info = cell2Text, - iconRes = R.drawable.ic_smartphone + iconRes = R.drawable.ic_smartphone, + qaTag = R.string.qa_pro_link_cell_device, + titleQaTag = R.string.qa_pro_link_cell_device_title, + descriptionQaTag = R.string.qa_pro_link_cell_device_description, ) ) @@ -147,7 +150,10 @@ fun ChoosePlanNoBilling( .put(PLATFORM_KEY, subscription.providerData.getPlatformDisplayName()) .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) .format(), - iconRes = R.drawable.ic_globe + iconRes = R.drawable.ic_globe, + qaTag = R.string.qa_pro_link_cell_website, + titleQaTag = R.string.qa_pro_link_cell_website_title, + descriptionQaTag = R.string.qa_pro_link_cell_website_description, ) ) } @@ -155,6 +161,7 @@ fun ChoosePlanNoBilling( BaseNonOriginatingProSettingsScreen( + screenQaTag = R.string.qa_pro_screen_choose_plan_no_billing, disabled = false, onBack = onBack, headerTitle = headerTitle, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt index 802339828c..c3bc50e31f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt @@ -52,6 +52,7 @@ fun ChoosePlanNonOriginating( } BaseNonOriginatingProSettingsScreen( + screenQaTag = R.string.qa_pro_screen_choose_plan_non_originating, disabled = false, onBack = onBack, headerTitle = headerTitle, @@ -79,7 +80,10 @@ fun ChoosePlanNonOriginating( .put(DEVICE_TYPE_KEY, subscription.providerData.device) .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) .format(), - iconRes = R.drawable.ic_smartphone + iconRes = R.drawable.ic_smartphone, + qaTag = R.string.qa_pro_link_cell_device, + titleQaTag = R.string.qa_pro_link_cell_device_title, + descriptionQaTag = R.string.qa_pro_link_cell_device_description, ), NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.viaStoreWebsite)) @@ -89,7 +93,10 @@ fun ChoosePlanNonOriginating( .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) .put(PLATFORM_STORE_KEY, platformOverride) .format(), - iconRes = R.drawable.ic_globe + iconRes = R.drawable.ic_globe, + qaTag = R.string.qa_pro_link_cell_website, + titleQaTag = R.string.qa_pro_link_cell_website_title, + descriptionQaTag = R.string.qa_pro_link_cell_website_description, ) ) ) 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 c590717428..75eb5934fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.pro +import android.content.Context import android.app.Application import androidx.collection.ArraySet import androidx.collection.arraySetOf @@ -110,15 +111,27 @@ class ProStatusManager @Inject constructor( } .distinctUntilChanged(), proStatusRepository.get().loadState, - // 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. + // The fixture and its overrides are collected as ONE flow: `combine` is only overloaded to + // five typed flows, and these answer one question ("what state do we want mocked"). (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS || - it == TextSecurePreferences.DEBUG_PRO_ACCESS_EXPIRY + it == TextSecurePreferences.DEBUG_PRO_ACCESS_EXPIRY || + it == TextSecurePreferences.DEBUG_PRO_REFUND_IN_PROGRESS || + it == TextSecurePreferences.DEBUG_PRO_ORIGINATING_PROVIDER || + it == TextSecurePreferences.DEBUG_PRO_AUTO_RENEWING || + it == TextSecurePreferences.DEBUG_WITHIN_QUICK_REFUND } as Flow<*>) .onStart { emit(Unit) } - .map { prefs.getDebugSubscriptionType() to prefs.getDebugProAccessExpiry() }, + .map { + DebugProOverrides( + subscription = prefs.getDebugSubscriptionType(), + accessExpiry = prefs.getDebugProAccessExpiry(), + refundInProgress = prefs.getDebugRefundInProgressOverride(), + originatingProvider = prefs.getDebugOriginatingProvider(), + autoRenewing = prefs.getDebugAutoRenewingOverride(), + withinQuickRefund = prefs.getDebugQuickRefundWindowOverride() + ) + }, (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_PRO_PLAN_STATUS } as Flow<*>) .onStart { emit(Unit) } .map { prefs.getDebugProPlanStatus() }, @@ -126,7 +139,13 @@ class ProStatusManager @Inject constructor( .onStart { emit(Unit) } .map { prefs.forceCurrentUserAsPro() }, ){ showProBadgePreference, proStatusState, - (debugSubscription, debugAccessExpiry), debugProPlanStatus, forceCurrentUserAsPro -> + debugOverrides, debugProPlanStatus, forceCurrentUserAsPro -> + val debugSubscription = debugOverrides.subscription + val debugAccessExpiry = debugOverrides.accessExpiry + val debugRefundInProgress = debugOverrides.refundInProgress + val debugOriginatingProvider = debugOverrides.originatingProvider + val debugAutoRenewing = debugOverrides.autoRenewing + val debugWithinQuickRefund = debugOverrides.withinQuickRefund val proDataRefreshState = when(debugProPlanStatus){ DebugMenuViewModel.DebugProPlanStatus.LOADING -> State.Loading DebugMenuViewModel.DebugProPlanStatus.ERROR -> State.Error(Exception()) @@ -177,8 +196,10 @@ class ProStatusManager @Inject constructor( // Refund-requested is now a synced config flag (set by whichever device — e.g. iOS — // initiated the refund), not a get_pro_status field; read it for cross-device display. - val refundInProgress = configFactory.get() - .withUserConfigs { it.userProfile.getRefundRequested() != null } + // QA override wins when set, else the synced flag. + val refundInProgress = debugRefundInProgress + ?: configFactory.get() + .withUserConfigs { it.userProfile.getRefundRequested() != null } ProDataState( type = proStatusState.lastUpdated?.let { (response, confirmedAt) -> response.toProStatus(nowMs, application, refundInProgress, confirmedAt) @@ -280,7 +301,11 @@ class ProStatusManager @Inject constructor( gracePeriod = Duration.ZERO, providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application) ) - }.withMockedExpiry(debugAccessExpiry), + }.withMockedExpiry(debugAccessExpiry) + .withMockedRefundInProgress(debugRefundInProgress) + .withMockedOriginatingProvider(debugOriginatingProvider, application) + .withMockedAutoRenewing(debugAutoRenewing) + .withMockedQuickRefundWindow(debugWithinQuickRefund, now), refreshState = proDataRefreshState, showProBadge = showProBadgePreference, @@ -310,6 +335,111 @@ class ProStatusManager @Inject constructor( else -> this } + /** + * The debug Pro overrides, collected as one value because `combine` is only overloaded to five typed + * flows and these all answer the same question: what state do we want mocked. + */ + private data class DebugProOverrides( + val subscription: DebugMenuViewModel.DebugSubscriptionStatus?, + val accessExpiry: Instant?, + val refundInProgress: Boolean?, + val originatingProvider: String?, + val autoRenewing: Boolean?, + val withinQuickRefund: Boolean?, + ) + + /** + * Forces the store's quick-refund window open or closed by moving the fixture's `quickRefundExpiry`, + * which is the **only** representation of it — `isWithinQuickRefundWindow` compares that date against + * network time, and every reader goes through it. + * + * Deliberately not a second boolean carried alongside the date. It used to be: `ensureRefundState` + * read a debug flag directly and only when the legacy `forceCurrentUserAsPro` was set, so the flag was + * inert for any fixture granting access the modern way, and "is the window open" had two sources that + * could disagree. + * + * Tri-state: null leaves the fixture's own window alone, which every fixture sets open. A plain + * boolean defaulting to false would have closed it for every existing test. + */ + private fun ProStatus.withMockedQuickRefundWindow(open: Boolean?, now: Instant): ProStatus = when { + open == null -> this + + this is ProStatus.Active.AutoRenewing -> + copy(quickRefundExpiry = if (open) now + Duration.ofDays(7) else null) + + this is ProStatus.Active.Expiring -> + copy(quickRefundExpiry = if (open) now + Duration.ofDays(7) else null) + + else -> this + } + + /** + * Forces whether a debug fixture renews itself, converting between the two active shapes and keeping + * the dates and provider as they were. Null keeps the fixture's own shape. + * + * A conversion rather than a flag because the distinction is a type here: `Expiring` runs to its end + * date, `AutoRenewing` renews and is what gates the Cancel Pro Access action. Doing it this way keeps + * the lever orthogonal - the `AUTO_*` fixtures reach a renewing plan too, but each bundles a provider + * and duration with it, so they cannot answer "this plan, but renewing". + * + * `inGracePeriod` is false on conversion: an overdue renewal is a separate state, reached by pairing + * this with an access expiry in the past. + */ + private fun ProStatus.withMockedAutoRenewing(autoRenewing: Boolean?): ProStatus = when { + autoRenewing == null -> this + + autoRenewing && this is ProStatus.Active.Expiring -> ProStatus.Active.AutoRenewing( + renewingAt = renewingAt, + duration = duration, + providerData = providerData, + quickRefundExpiry = quickRefundExpiry, + refundInProgress = refundInProgress, + inGracePeriod = false + ) + + !autoRenewing && this is ProStatus.Active.AutoRenewing -> ProStatus.Active.Expiring( + renewingAt = renewingAt, + duration = duration, + providerData = providerData, + quickRefundExpiry = quickRefundExpiry, + refundInProgress = refundInProgress + ) + + else -> this + } + + /** + * Replaces the payment provider a debug fixture carries, which is what decides whether the plan reads + * as bought on this platform or elsewhere (`PaymentProviderMetadata.isFromAnotherPlatform`). Null + * keeps the fixture's own provider. + * + * Only [ProStatus.Active.WithPlan] and [ProStatus.Expired.WithPlan] carry provider data; anything + * else has no purchase to attribute. + */ + private fun ProStatus.withMockedOriginatingProvider( + providerSlug: String?, + context: Context + ): ProStatus = when { + providerSlug == null -> this + this is ProStatus.Active.AutoRenewing -> copy(providerData = providerMetadata(providerSlug, context)) + this is ProStatus.Active.Expiring -> copy(providerData = providerMetadata(providerSlug, context)) + this is ProStatus.Expired.WithPlan -> copy(providerData = providerMetadata(providerSlug, context)) + else -> this + } + + /** + * Forces the refund-pending flag on a debug fixture, leaving the rest of it alone, so refunding + * composes with any fixture rather than only `AUTO_APPLE_REFUNDING`. Null keeps the fixture's flag. + * + * Only [ProStatus.Active.WithPlan] carries the flag; anything else has no refund to be pending. + */ + private fun ProStatus.withMockedRefundInProgress(refunding: Boolean?): ProStatus = when { + refunding == null -> this + this is ProStatus.Active.AutoRenewing -> copy(refundInProgress = refunding) + this is ProStatus.Active.Expiring -> copy(refundInProgress = refunding) + else -> this + } + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState): Unit = supervisorScope { launch { RevocationListPollingWorker.schedule(application) 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 0476586b72..7a0f2729c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.qa +import network.loki.messenger.libsession_util.pro.BackendRequests import android.content.Intent import android.os.Bundle import network.loki.messenger.BuildConfig @@ -160,6 +161,57 @@ object QaLaunchConfig { */ private const val EXTRA_PRO_LOADING_STATE = "sessionProLoadingState" + /** + * Whether the mocked subscription has a refund pending: `useActual` | `notRefunding` | `refunding`. + * iOS's `mockCurrentUserSessionProRefundingStatus`. + * + * Its own key rather than a [EXTRA_PRO_BACKEND_STATUS] value, so it composes with any fixture: + * `AUTO_APPLE_REFUNDING` bundles refunding with a provider, duration and renewal date. Keeping it out + * of that key also leaves those values as the backend's own plan slugs, which are open to future + * additions that could shadow a test-only one. + * + * Only renders on an ACTIVE plan — the flag lives on `ProStatus.Active.WithPlan`. + */ + private const val EXTRA_PRO_REFUNDING_STATUS = "sessionProRefundingStatus" + + /** + * Which platform the mocked subscription was bought on: `useActual` | `iOS` | `android`. + * iOS's `mockCurrentUserSessionProOriginatingPlatform`. + * + * Maps to the payment provider slug, which is what every "bought elsewhere" decision reads + * (`PaymentProviderMetadata.isFromAnotherPlatform`) — so `iOS` reaches the non-originating screens on + * an Android device, and `android` the originating ones. + */ + private const val EXTRA_PRO_ORIGINATING_PLATFORM = "sessionProOriginatingPlatform" + + /** + * Whether the store's own quick-refund window is still open: `useActual` | `true` | `false`. Decides + * the <48h vs >48h refund screens. + * + * Applied by moving the plan's `quickRefundExpiry`, which is the single representation of the window, + * so it works for any fixture regardless of how access was granted. + */ + private const val EXTRA_PRO_QUICK_REFUND_WINDOW = "sessionProQuickRefundWindow" + + /** + * Whether the mocked plan renews itself: `useActual` | `autoRenewing` | `notAutoRenewing`. + * iOS's `mockCurrentUserSessionProAutoRenewing`. + * + * The flag the "Pro auto-renewing in {time}" line, the renewal-unsuccessful state and the Cancel Pro + * Access action all read - without it a mocked plan always runs to its end date, so none of those is + * reachable. + */ + private const val EXTRA_PRO_AUTO_RENEWING = "sessionProAutoRenewing" + + /** + * Whether the store account signed in on this device is the one that bought the subscription: + * `useActual` | `originatingAccount` | `nonOriginatingAccount`. iOS's `mockCurrentUserOriginatingAccount`. + * + * Overrides `hasValidSubscription`, which the cancel and choose-plan screens read as "same platform but + * a different account". Note the refund screen branches only on the platform, so this does not change it. + */ + private const val EXTRA_PRO_ORIGINATING_ACCOUNT = "sessionProOriginatingAccount" + /** * Read any supported extras off [intent] and persist them. Safe to call on every launch: absent * extras leave the corresponding preference untouched. @@ -196,6 +248,11 @@ object QaLaunchConfig { applyProProof(intent, prefs) applyProAccessExpiry(intent, prefs) applyProLoadingState(intent, prefs) + applyProRefundingStatus(intent, prefs) + applyProOriginatingPlatform(intent, prefs) + applyProQuickRefundWindow(intent, prefs) + applyProAutoRenewing(intent, prefs) + applyProOriginatingAccount(intent, prefs) } catch (e: RuntimeException) { Log.e(TAG, "Ignoring unreadable launch extras", e) return @@ -222,6 +279,11 @@ object QaLaunchConfig { EXTRA_PRO_PROOF, EXTRA_PRO_ACCESS_EXPIRY, EXTRA_PRO_LOADING_STATE, + EXTRA_PRO_REFUNDING_STATUS, + EXTRA_PRO_ORIGINATING_PLATFORM, + EXTRA_PRO_QUICK_REFUND_WINDOW, + EXTRA_PRO_AUTO_RENEWING, + EXTRA_PRO_ORIGINATING_ACCOUNT, ) /** @@ -503,6 +565,169 @@ object QaLaunchConfig { return true } + /** + * Applies the mocked refund-pending flag. See [EXTRA_PRO_REFUNDING_STATUS]. + * + * refunding -> force a refund in progress + * notRefunding -> force none, even if the synced config flag says otherwise + * useActual -> clear the override; the real state governs + * + * Tri-state for the same reason as [applyProProof]: the real state is a synced config flag another + * device can have set, so `notRefunding` and `useActual` differ. + */ + private fun applyProRefundingStatus(intent: Intent, prefs: TextSecurePreferences): Boolean { + if (!intent.hasExtra(EXTRA_PRO_REFUNDING_STATUS)) { + // Absent leaves the stored override alone — see [applyProProof]. + return false + } + + val raw = intent.getStringExtra(EXTRA_PRO_REFUNDING_STATUS).orEmpty().trim() + // null = clear the override. + val override: Boolean? = when (raw.lowercase()) { + "refunding" -> true + "notrefunding" -> false + USE_ACTUAL -> null + else -> { + Log.e( + TAG, + "Ignoring unknown '$EXTRA_PRO_REFUNDING_STATUS' extra: '$raw'. " + + "Use refunding | notRefunding | $USE_ACTUAL." + ) + return false + } + } + + prefs.setDebugRefundInProgressOverride(override) + Log.i( + TAG, + "Set mocked Pro refund-pending to '$raw' " + + "(override = ${override?.toString() ?: "cleared, real state governs"})" + ) + return true + } + + /** + * Applies the mocked originating platform. See [EXTRA_PRO_ORIGINATING_PLATFORM]. + * + * Accepts the platform names iOS uses rather than the provider slugs the app stores, so one + * `bothPlatformsIt` setup reads the same on both clients. + */ + private fun applyProOriginatingPlatform(intent: Intent, prefs: TextSecurePreferences): Boolean { + if (!intent.hasExtra(EXTRA_PRO_ORIGINATING_PLATFORM)) { + return false + } + + val raw = intent.getStringExtra(EXTRA_PRO_ORIGINATING_PLATFORM).orEmpty().trim() + // null = clear the override. + val slug: String? = when (raw.lowercase()) { + "ios" -> BackendRequests.PAYMENT_PROVIDER_APP_STORE + "android" -> BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY + USE_ACTUAL -> null + else -> { + Log.e( + TAG, + "Ignoring unknown '$EXTRA_PRO_ORIGINATING_PLATFORM' extra: '$raw'. " + + "Use iOS | android | $USE_ACTUAL." + ) + return false + } + } + + prefs.setDebugOriginatingProvider(slug) + Log.i( + TAG, + "Set mocked Pro originating platform to '$raw' (provider = ${slug ?: "cleared"})" + ) + return true + } + + /** Applies the mocked originating account. See [EXTRA_PRO_ORIGINATING_ACCOUNT]. */ + private fun applyProOriginatingAccount(intent: Intent, prefs: TextSecurePreferences): Boolean { + if (!intent.hasExtra(EXTRA_PRO_ORIGINATING_ACCOUNT)) { + return false + } + + val raw = intent.getStringExtra(EXTRA_PRO_ORIGINATING_ACCOUNT).orEmpty().trim() + // null = clear the override. + val override: Boolean? = when (raw.lowercase()) { + "originatingaccount" -> true + "nonoriginatingaccount" -> false + USE_ACTUAL -> null + else -> { + Log.e( + TAG, + "Ignoring unknown '$EXTRA_PRO_ORIGINATING_ACCOUNT' extra: '$raw'. " + + "Use originatingAccount | nonOriginatingAccount | $USE_ACTUAL." + ) + return false + } + } + + prefs.setDebugOriginatingAccountOverride(override) + Log.i( + TAG, + "Set mocked Pro originating account to '$raw' " + + "(override = ${override?.toString() ?: "cleared, the store decides"})" + ) + return true + } + + /** Applies the mocked auto-renewing flag. See [EXTRA_PRO_AUTO_RENEWING]. */ + private fun applyProAutoRenewing(intent: Intent, prefs: TextSecurePreferences): Boolean { + if (!intent.hasExtra(EXTRA_PRO_AUTO_RENEWING)) { + return false + } + + val raw = intent.getStringExtra(EXTRA_PRO_AUTO_RENEWING).orEmpty().trim() + // null = clear the override. + val override: Boolean? = when (raw.lowercase()) { + "autorenewing" -> true + "notautorenewing" -> false + USE_ACTUAL -> null + else -> { + Log.e( + TAG, + "Ignoring unknown '$EXTRA_PRO_AUTO_RENEWING' extra: '$raw'. " + + "Use autoRenewing | notAutoRenewing | $USE_ACTUAL." + ) + return false + } + } + + prefs.setDebugAutoRenewingOverride(override) + Log.i( + TAG, + "Set mocked Pro auto-renewing to '$raw' " + + "(override = ${override?.toString() ?: "cleared, real state governs"})" + ) + return true + } + + /** Applies the mocked quick-refund window. See [EXTRA_PRO_QUICK_REFUND_WINDOW]. */ + private fun applyProQuickRefundWindow(intent: Intent, prefs: TextSecurePreferences): Boolean { + if (!intent.hasExtra(EXTRA_PRO_QUICK_REFUND_WINDOW)) { + return false + } + + val raw = intent.getStringExtra(EXTRA_PRO_QUICK_REFUND_WINDOW).orEmpty().trim() + when (raw.lowercase()) { + "true" -> prefs.setDebugQuickRefundWindowOverride(true) + "false" -> prefs.setDebugQuickRefundWindowOverride(false) + USE_ACTUAL -> prefs.setDebugQuickRefundWindowOverride(null) + else -> { + Log.e( + TAG, + "Ignoring unknown '$EXTRA_PRO_QUICK_REFUND_WINDOW' extra: '$raw'. " + + "Use true | false | $USE_ACTUAL." + ) + return false + } + } + + Log.i(TAG, "Set mocked Pro quick-refund window to '$raw'") + 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)) { diff --git a/app/src/main/res/raw/keep.xml b/app/src/main/res/raw/keep.xml new file mode 100644 index 0000000000..268995bc84 --- /dev/null +++ b/app/src/main/res/raw/keep.xml @@ -0,0 +1,23 @@ + + diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index ea2bc1b90a..c2f9b88846 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -373,6 +373,11 @@ pro-settings-stats-header + + pro-stats-longer-messages + pro-stats-pinned-conversations + pro-stats-badges-sent + pro-stats-groups-upgraded pro-settings-manage-header pro-settings-features-header pro-settings-description + + pro-screen-choose-plan + pro-screen-choose-plan-no-billing + pro-screen-choose-plan-non-originating + pro-screen-cancel-plan + pro-screen-cancel-plan-non-originating + pro-screen-refund-plan + pro-screen-refund-plan-non-originating + pro-screen-refund-in-progress + pro-screen-plan-confirmation + + pro-screen-header + pro-screen-title + pro-screen-description + pro-screen-action + + pro-link-cell-device + pro-link-cell-device-title + pro-link-cell-device-description + pro-link-cell-website + pro-link-cell-website-title + pro-link-cell-website-description action-item-title action-item-subtitle action-item-icon