From ec660d10998f243cd0fc0ab43a368285655b0cb4 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 28 Oct 2025 10:45:30 +1100 Subject: [PATCH 01/15] Making sure we do not show the loader if the purchase fails right away --- .../securesms/debugmenu/DebugMenuViewModel.kt | 4 +- .../prosettings/ProSettingsViewModel.kt | 16 +- .../subscription/NoOpSubscriptionManager.kt | 4 +- .../pro/subscription/SubscriptionManager.kt | 2 +- .../PlayStoreSubscriptionManager.kt | 138 +++++++++--------- 5 files changed, 89 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index 8688a484ba..03d1e572c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -326,7 +326,9 @@ class DebugMenuViewModel @Inject constructor( } is Commands.PurchaseDebugPlan -> { - command.plan.apply { manager.purchasePlan(plan) } + viewModelScope.launch { + command.plan.apply { manager.purchasePlan(plan) } + } } } } 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 c8111020a4..e74b08c7d2 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 @@ -619,13 +619,17 @@ class ProSettingsViewModel @AssistedInject constructor( } private fun getPlanFromProvider(){ - _choosePlanState.update { - it.copy(loading = true) - } + viewModelScope.launch { + val purchaseStarted = subscriptionCoordinator.getCurrentManager().purchasePlan( + getSelectedPlan().durationType + ) - subscriptionCoordinator.getCurrentManager().purchasePlan( - getSelectedPlan().durationType - ) + if(purchaseStarted.isSuccess) { + _choosePlanState.update { + it.copy(loading = true) + } + } + } } fun getSubscriptionManager(): SubscriptionManager { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 1031ad45fb..967056de8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -21,7 +21,9 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val quickRefundUrl = null - override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) {} + override suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result { + return Result.success(Unit) + } override val availablePlans: List get() = emptyList() diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index ddcfb41f59..3a516c5012 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -33,7 +33,7 @@ interface SubscriptionManager: OnAppStartupComponent { // purchase events val purchaseEvents: SharedFlow - fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) + suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result /** * Returns true if a provider has a quick refunds and the current time since purchase is within that window diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 404f92b389..511066a1d6 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.pro.subscription import android.app.Application import android.widget.Toast +import androidx.compose.ui.res.stringResource import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams @@ -16,6 +17,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -31,6 +33,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope @@ -89,6 +92,7 @@ class PlayStoreSubscriptionManager @Inject constructor( // signal that purchase was completed try { //todo PRO send confirmation to libsession + delay(4000) } catch (e : Exception){ _purchaseEvents.emit(PurchaseEvent.Failed()) } @@ -116,87 +120,89 @@ class PlayStoreSubscriptionManager @Inject constructor( override val availablePlans: List = ProSubscriptionDuration.entries.toList() - override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) { - scope.launch { - try { - val activity = checkNotNull(currentActivityObserver.currentActivity.value) { - "No current activity available to launch the billing flow" - } + override suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result { + try { + val activity = checkNotNull(currentActivityObserver.currentActivity.value) { + "No current activity available to launch the billing flow" + } - val result = billingClient.queryProductDetails( - QueryProductDetailsParams.newBuilder() - .setProductList( - listOf( - QueryProductDetailsParams.Product.newBuilder() - .setProductId("session_pro") - .setProductType(BillingClient.ProductType.SUBS) - .build() - ) + val result = billingClient.queryProductDetails( + QueryProductDetailsParams.newBuilder() + .setProductList( + listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId("session_pro") + .setProductType(BillingClient.ProductType.SUBS) + .build() ) - .build() - ) + ) + .build() + ) - check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - "Failed to query product details. Reason: ${result.billingResult}" - } + check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + "Failed to query product details. Reason: ${result.billingResult}" + } - val productDetails = checkNotNull(result.productDetailsList?.firstOrNull()) { - "Unable to get the product: product for given id is null" - } + val productDetails = checkNotNull(result.productDetailsList?.firstOrNull()) { + "Unable to get the product: product for given id is null" + } - val planId = subscriptionDuration.planId + val planId = subscriptionDuration.planId - val offerDetails = checkNotNull(productDetails.subscriptionOfferDetails - ?.firstOrNull { it.basePlanId == planId }) { - "Unable to find a plan with id $planId" - } + val offerDetails = checkNotNull(productDetails.subscriptionOfferDetails + ?.firstOrNull { it.basePlanId == planId }) { + "Unable to find a plan with id $planId" + } - // Check for existing subscription - val existingPurchase = getExistingSubscription() + // Check for existing subscription + val existingPurchase = getExistingSubscription() - val billingFlowParamsBuilder = BillingFlowParams.newBuilder() - .setProductDetailsParamsList( - listOf( - BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(productDetails) - .setOfferToken(offerDetails.offerToken) - .build() - ) - ) - - // If user has an existing subscription, configure upgrade/downgrade - if (existingPurchase != null) { - Log.d(TAG, "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION") - - billingFlowParamsBuilder.setSubscriptionUpdateParams( - BillingFlowParams.SubscriptionUpdateParams.newBuilder() - .setOldPurchaseToken(existingPurchase.purchaseToken) - // WITHOUT_PRORATION ensures new plan only bills when existing plan expires/renews - // This applies whether the subscription is auto-renewing or canceled - .setSubscriptionReplacementMode( - BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION - ) + val billingFlowParamsBuilder = BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerDetails.offerToken) .build() ) - } + ) - val billingResult = billingClient.launchBillingFlow( - activity, - billingFlowParamsBuilder.build() + // If user has an existing subscription, configure upgrade/downgrade + if (existingPurchase != null) { + Log.d(TAG, "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION") + + billingFlowParamsBuilder.setSubscriptionUpdateParams( + BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOldPurchaseToken(existingPurchase.purchaseToken) + // WITHOUT_PRORATION ensures new plan only bills when existing plan expires/renews + // This applies whether the subscription is auto-renewing or canceled + .setSubscriptionReplacementMode( + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION + ) + .build() ) + } - check(billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - "Unable to launch the billing flow. Reason: ${billingResult.debugMessage}" - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(TAG, "Error purchase plan", e) + val billingResult = billingClient.launchBillingFlow( + activity, + billingFlowParamsBuilder.build() + ) - withContext(Dispatchers.Main) { - Toast.makeText(application, e.message, Toast.LENGTH_LONG).show() - } + check(billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + "Unable to launch the billing flow. Reason: ${billingResult.debugMessage}" + } + + return Result.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error purchase plan", e) + + withContext(Dispatchers.Main) { + Toast.makeText(application, application.getString(R.string.errorGeneric), Toast.LENGTH_LONG).show() } + + return Result.failure(e) } } From 32b457fa5a35729218e185ce504c9a552ff1a72a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 28 Oct 2025 14:18:02 +1100 Subject: [PATCH 02/15] UI Update to match designs --- .../prosettings/BaseProSettingsScreens.kt | 10 +- .../prosettings/ProSettingsActivity.kt | 2 +- .../prosettings/ProSettingsHomeScreen.kt | 237 ++++++++++++------ .../prosettings/ProSettingsNavHost.kt | 181 ++++++------- .../chooseplan/ChoosePlanScreen.kt | 8 +- .../securesms/ui/ProComponents.kt | 14 +- 6 files changed, 264 insertions(+), 188 deletions(-) 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 93fd2fcf08..8bba3a5f18 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 @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only @@ -22,7 +21,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -36,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign @@ -70,7 +72,7 @@ fun BaseProSettingsScreen( onBack: () -> Unit, onHeaderClick: (() -> Unit)? = null, extraHeaderContent: @Composable (() -> Unit)? = null, - content: @Composable () -> Unit + content: @Composable LazyItemScope.() -> Unit ){ // We need the app bar to start as transparent and slowly go opaque as we scroll val lazyListState = rememberLazyListState() @@ -109,7 +111,7 @@ fun BaseProSettingsScreen( ) { paddings -> LazyColumn( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() .consumeWindowInsets(paddings) .padding(horizontal = LocalDimensions.current.spacing), state = lazyListState, @@ -145,7 +147,7 @@ fun BaseCellButtonProSettingsScreen( dangerButton: Boolean, onButtonClick: () -> Unit, title: CharSequence? = null, - content: @Composable () -> Unit + content: @Composable LazyItemScope.() -> Unit ) { BaseProSettingsScreen( disabled = disabled, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt index 710d638c2f..1d6fdff0cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt @@ -32,7 +32,7 @@ class ProSettingsActivity: FullComposeScreenLockActivity() { ) ?: ProSettingsDestination.Home ProSettingsNavHost( - hideHomeAppBar = false, + inSheet = false, startDestination = startDestination, onBack = this::finish ) 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 f5d4751ea1..e349593e3b 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 @@ -76,7 +76,6 @@ import org.thoughtcrime.securesms.ui.components.inlineContentMap import org.thoughtcrime.securesms.ui.proBadgeColorDisabled import org.thoughtcrime.securesms.ui.proBadgeColorStandard import org.thoughtcrime.securesms.ui.qaTag -import org.thoughtcrime.securesms.ui.shimmerOverlay import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -99,14 +98,14 @@ import java.time.Instant @Composable fun ProSettingsHomeScreen( viewModel: ProSettingsViewModel, - hideHomeAppBar: Boolean, + inSheet: Boolean, onBack: () -> Unit, ) { val data by viewModel.proSettingsUIState.collectAsState() ProSettingsHome( data = data, - hideHomeAppBar = hideHomeAppBar, + inSheet = inSheet, sendCommand = viewModel::onCommand, onBack = onBack, ) @@ -116,16 +115,19 @@ fun ProSettingsHomeScreen( @Composable fun ProSettingsHome( data: ProSettingsViewModel.ProSettingsState, - hideHomeAppBar: Boolean, + inSheet: Boolean, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { val subscriptionType = data.subscriptionState.type val context = LocalContext.current + val expiredInMainScreen = subscriptionType is SubscriptionType.Expired && !inSheet + val expiredInSheet = subscriptionType is SubscriptionType.Expired && inSheet + BaseProSettingsScreen( - disabled = subscriptionType is SubscriptionType.Expired, - hideHomeAppBar = hideHomeAppBar, + disabled = expiredInMainScreen, + hideHomeAppBar = inSheet, onBack = onBack, onHeaderClick = { // add a click handling if the subscription state is loading or errored @@ -189,14 +191,18 @@ fun ProSettingsHome( } } ) { - // Header for non-pro users - if(subscriptionType is SubscriptionType.NeverSubscribed) { + // Header for non-pro users or expired users in sheet mode + if(subscriptionType is SubscriptionType.NeverSubscribed || expiredInSheet) { if(data.subscriptionState.refreshState !is State.Success){ - Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) } Text( - text = Phrase.from(context.getText(R.string.proFullestPotential)) + text = if(expiredInSheet) Phrase.from(context.getText(R.string.proAccessRenewStart)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + else Phrase.from(context.getText(R.string.proFullestPotential)) .put(APP_NAME_KEY, stringResource(R.string.app_name)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format().toString(), @@ -213,7 +219,7 @@ fun ProSettingsHome( onClick = { sendCommand(GoToChoosePlan) } ) } - + // Pro Stats if(subscriptionType is SubscriptionType.Active){ Spacer(Modifier.height(LocalDimensions.current.spacing)) @@ -235,7 +241,7 @@ fun ProSettingsHome( } // Manage Pro - Expired - if(subscriptionType is SubscriptionType.Expired){ + if(expiredInMainScreen){ Spacer(Modifier.height(LocalDimensions.current.spacing)) ProManage( data = subscriptionType, @@ -248,67 +254,17 @@ fun ProSettingsHome( Spacer(Modifier.height(LocalDimensions.current.spacing)) ProFeatures( data = subscriptionType, + disabled = expiredInMainScreen, sendCommand = sendCommand, ) - // Manage Pro - Pro - if(subscriptionType is SubscriptionType.Active){ - Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) - ProManage( - data = subscriptionType, - subscriptionRefreshState = data.subscriptionState.refreshState, - sendCommand = sendCommand, - ) - } - - // Help - Spacer(Modifier.height(LocalDimensions.current.spacing)) - CategoryCell( - title = stringResource(R.string.sessionHelp), - ) { - val iconColor = if(subscriptionType is SubscriptionType.Expired) LocalColors.current.text - else LocalColors.current.accentText - - // Cell content - Column( - modifier = Modifier.fillMaxWidth(), - ) { - IconActionRowItem( - title = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proFaq) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString() - ), - subtitle = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proFaqDescription) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - ), - icon = R.drawable.ic_square_arrow_up_right, - iconSize = LocalDimensions.current.iconMedium, - iconColor = iconColor, - qaTag = R.string.qa_pro_settings_action_faq, - onClick = { - sendCommand(ShowOpenUrlDialog("https://getsession.org/faq#pro")) - } - ) - Divider() - IconActionRowItem( - title = annotatedStringResource(R.string.helpSupport), - subtitle = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proSupportDescription) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString() - ), - icon = R.drawable.ic_square_arrow_up_right, - iconSize = LocalDimensions.current.iconMedium, - iconColor = iconColor, - qaTag = R.string.qa_pro_settings_action_support, - onClick = { - sendCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) - } - ) - } + // do not display the footer in sheet mode + if(!inSheet){ + ProSettingsFooter( + subscriptionType = subscriptionType, + subscriptionRefreshState = data.subscriptionState.refreshState, + sendCommand = sendCommand + ) } Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) @@ -611,6 +567,7 @@ fun ProSettings( fun ProFeatures( modifier: Modifier = Modifier, data: SubscriptionType, + disabled: Boolean, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ) { CategoryCell( @@ -643,7 +600,7 @@ fun ProFeatures( icon = R.drawable.ic_message_square, iconGradientStart = primaryBlue, iconGradientEnd = primaryPurple, - expired = data is SubscriptionType.Expired + expired = disabled ) // Unlimited pins @@ -653,7 +610,7 @@ fun ProFeatures( icon = R.drawable.ic_pin, iconGradientStart = primaryPurple, iconGradientEnd = primaryPink, - expired = data is SubscriptionType.Expired + expired = disabled ) // Animated pics @@ -663,7 +620,7 @@ fun ProFeatures( icon = R.drawable.ic_square_play, iconGradientStart = primaryPink, iconGradientEnd = primaryRed, - expired = data is SubscriptionType.Expired + expired = disabled ) // Pro badges @@ -677,7 +634,7 @@ fun ProFeatures( icon = R.drawable.ic_rectangle_ellipsis, iconGradientStart = primaryRed, iconGradientEnd = primaryOrange, - expired = data is SubscriptionType.Expired, + expired = disabled, showProBadge = true, ) @@ -694,7 +651,7 @@ fun ProFeatures( icon = R.drawable.ic_circle_plus, iconGradientStart = primaryOrange, iconGradientEnd = primaryYellow, - expired = data is SubscriptionType.Expired, + expired = disabled, onClick = { sendCommand(ShowOpenUrlDialog("https://getsession.org/pro-roadmap")) } @@ -899,6 +856,73 @@ fun ProManage( } } +@Composable +fun ProSettingsFooter( + subscriptionType: SubscriptionType, + subscriptionRefreshState: State, + sendCommand: (ProSettingsViewModel.Commands) -> Unit, +) { + // Manage Pro - Pro + if(subscriptionType is SubscriptionType.Active){ + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + ProManage( + data = subscriptionType, + subscriptionRefreshState = subscriptionRefreshState, + sendCommand = sendCommand, + ) + } + + // Help + Spacer(Modifier.height(LocalDimensions.current.spacing)) + CategoryCell( + title = stringResource(R.string.sessionHelp), + ) { + val iconColor = if(subscriptionType is SubscriptionType.Expired) LocalColors.current.text + else LocalColors.current.accentText + + // Cell content + Column( + modifier = Modifier.fillMaxWidth(), + ) { + IconActionRowItem( + title = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.proFaq) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + ), + subtitle = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.proFaqDescription) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + ), + icon = R.drawable.ic_square_arrow_up_right, + iconSize = LocalDimensions.current.iconMedium, + iconColor = iconColor, + qaTag = R.string.qa_pro_settings_action_faq, + onClick = { + sendCommand(ShowOpenUrlDialog("https://getsession.org/faq#pro")) + } + ) + Divider() + IconActionRowItem( + title = annotatedStringResource(R.string.helpSupport), + subtitle = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.proSupportDescription) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + ), + icon = R.drawable.ic_square_arrow_up_right, + iconSize = LocalDimensions.current.iconMedium, + iconColor = iconColor, + qaTag = R.string.qa_pro_settings_action_support, + onClick = { + sendCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) + } + ) + } + } +} + @Preview @Composable fun PreviewProSettingsPro( @@ -926,7 +950,7 @@ fun PreviewProSettingsPro( refreshState = State.Success(Unit), ), ), - hideHomeAppBar = false, + inSheet = false, sendCommand = {}, onBack = {}, ) @@ -960,7 +984,7 @@ fun PreviewProSettingsProLoading( refreshState = State.Loading, ), ), - hideHomeAppBar = false, + inSheet = false, sendCommand = {}, onBack = {}, ) @@ -994,7 +1018,7 @@ fun PreviewProSettingsProError( refreshState = State.Error(Exception()), ), ), - hideHomeAppBar = false, + inSheet = false, sendCommand = {}, onBack = {}, ) @@ -1023,7 +1047,36 @@ fun PreviewProSettingsExpired( refreshState = State.Success(Unit), ) ), - hideHomeAppBar = false, + inSheet = false, + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +fun PreviewProSettingsExpiredInSheet( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ProSettingsHome( + data = ProSettingsViewModel.ProSettingsState( + subscriptionState = SubscriptionState( + type = SubscriptionType.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), + SubscriptionDetails( + device = "iOS", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + )), + refreshState = State.Success(Unit), + ) + ), + inSheet = true, sendCommand = {}, onBack = {}, ) @@ -1052,7 +1105,7 @@ fun PreviewProSettingsExpiredLoading( refreshState = State.Loading, ) ), - hideHomeAppBar = false, + inSheet = false, sendCommand = {}, onBack = {}, ) @@ -1081,7 +1134,7 @@ fun PreviewProSettingsExpiredError( refreshState = State.Error(Exception()), ) ), - hideHomeAppBar = false, + inSheet = false, sendCommand = {}, onBack = {}, ) @@ -1101,7 +1154,27 @@ fun PreviewProSettingsNonPro( refreshState = State.Success(Unit), ) ), - hideHomeAppBar = false, + inSheet = false, + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +fun PreviewProSettingsNonProInSheet( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ProSettingsHome( + data = ProSettingsViewModel.ProSettingsState( + subscriptionState = SubscriptionState( + type = SubscriptionType.NeverSubscribed, + refreshState = State.Success(Unit), + ) + ), + inSheet = true, sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index 3123ee4292..b96c374ec4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -4,10 +4,14 @@ import android.annotation.SuppressLint import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.NavHost @@ -62,110 +66,111 @@ sealed interface ProSettingsDestination: Parcelable { @Composable fun ProSettingsNavHost( startDestination: ProSettingsDestination = Home, - hideHomeAppBar: Boolean, + inSheet: Boolean, onBack: () -> Unit ){ - SharedTransitionLayout { - val navController = rememberNavController() - val navigator: UINavigator = remember { - UINavigator() + val navController = rememberNavController() + val navigator: UINavigator = remember { + UINavigator() + } + + val handleBack: () -> Unit = { + if (navController.previousBackStackEntry != null) { + navController.navigateUp() + } else { + onBack() // Finish activity if at root } + } - val handleBack: () -> Unit = { - if (navController.previousBackStackEntry != null) { - navController.navigateUp() - } else { - onBack() // Finish activity if at root + + ObserveAsEvents(flow = navigator.navigationActions) { action -> + when (action) { + is NavigationAction.Navigate -> navController.navigate( + action.destination + ) { + action.navOptions(this) } - } + NavigationAction.NavigateUp -> handleBack() - ObserveAsEvents(flow = navigator.navigationActions) { action -> - when (action) { - is NavigationAction.Navigate -> navController.navigate( - action.destination - ) { - action.navOptions(this) - } + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) + } - NavigationAction.NavigateUp -> handleBack() + is NavigationAction.ReturnResult -> {} + } + } - is NavigationAction.NavigateToIntent -> { - navController.context.startActivity(action.intent) - } + NavHost( + navController = navController, + startDestination = ProSettingsGraph + ) { + navigation(startDestination = startDestination) { + // Home + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + ProSettingsHomeScreen( + viewModel = viewModel, + inSheet = inSheet, + onBack = onBack, + ) + } - is NavigationAction.ReturnResult -> {} + // Subscription plan selection + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + UpdatePlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) + } + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + GetOrRenewPlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) } - } - NavHost(navController = navController, startDestination = ProSettingsGraph) { - navigation(startDestination = startDestination) { - // Home - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - ProSettingsHomeScreen( - viewModel = viewModel, - hideHomeAppBar = hideHomeAppBar, - onBack = onBack, - ) - } - - // Subscription plan selection - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - UpdatePlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - GetOrRenewPlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } - - // Subscription plan confirmation - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - PlanConfirmationScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } - - // Refund - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - RefundPlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } - - // Cancellation - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - CancelPlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } + // Subscription plan confirmation + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + PlanConfirmationScreen( + viewModel = viewModel, + onBack = handleBack, + ) } - } - // Dialogs - // the composable need to wait until the graph has been rendered - val graphReady = remember(navController.currentBackStackEntryAsState().value) { - runCatching { navController.getBackStackEntry(ProSettingsGraph) }.getOrNull() - } - graphReady?.let { entry -> - val vm = navController.proGraphViewModel(entry, navigator) - val dialogsState by vm.dialogState.collectAsState() - ProSettingsDialogs(dialogsState = dialogsState, sendCommand = vm::onCommand) + // Refund + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + RefundPlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) + } + + // Cancellation + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + CancelPlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) + } } } + + // Dialogs + // the composable need to wait until the graph has been rendered + val graphReady = remember(navController.currentBackStackEntryAsState().value) { + runCatching { navController.getBackStackEntry(ProSettingsGraph) }.getOrNull() + } + graphReady?.let { entry -> + val vm = navController.proGraphViewModel(entry, navigator) + val dialogsState by vm.dialogState.collectAsState() + ProSettingsDialogs(dialogsState = dialogsState, sendCommand = vm::onCommand) + } } @Composable diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index f81430fc97..6041b01578 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt @@ -105,12 +105,6 @@ fun ChoosePlan( val context = LocalContext.current val title = when (planData.subscriptionType) { - is SubscriptionType.Expired -> - Phrase.from(context.getText(R.string.proAccessRenewStart)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format() - is SubscriptionType.Active.Expiring -> Phrase.from(context.getText(R.string.proAccessActivatedNotAuto)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(DATE_KEY, planData.subscriptionType.duration.expiryFromNow()) @@ -128,7 +122,7 @@ fun ChoosePlan( .put(DATE_KEY, planData.subscriptionType.duration.expiryFromNow()) .format() - is SubscriptionType.NeverSubscribed -> + else -> Phrase.from(context.getText(R.string.proChooseAccess)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() 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 7dfd639686..80acdb84d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -25,9 +25,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing @@ -88,7 +88,6 @@ import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsNavHost import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.components.BaseBottomSheet -import org.thoughtcrime.securesms.ui.components.FillButtonRect import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.TertiaryFillButtonRect import org.thoughtcrime.securesms.ui.theme.LocalColors @@ -362,15 +361,18 @@ fun SessionProCTA( ) { BoxWithConstraints(modifier = modifier) { val topInset = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() - val targetHeight = - (this.maxHeight - topInset) * 0.94f // sheet should take up 94% of the height, without the status bar + val maxHeight = + (this.maxHeight - topInset) * 0.8f // sheet should take up 80% of the height, without the status bar + Box( - modifier = Modifier.height(targetHeight), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxHeight), contentAlignment = Alignment.TopCenter ) { ProSettingsNavHost( startDestination = ProSettingsDestination.Home, - hideHomeAppBar = true, + inSheet = true, onBack = dismissSheet ) From e7a2c1b03c229b88f25215355908d644cc2d330d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 28 Oct 2025 16:05:32 +1100 Subject: [PATCH 03/15] More state and UI handling to match designs --- .../prosettings/ProSettingsHomeScreen.kt | 59 +++++++++++++++---- .../prosettings/ProSettingsViewModel.kt | 40 ++++++++----- .../securesms/ui/ProComponents.kt | 2 +- 3 files changed, 73 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index e349593e3b..32d837db16 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 @@ -39,6 +39,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -54,11 +56,17 @@ import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus import org.session.libsession.utilities.recipients.shouldShowProBadge -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.* +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GoToCancel +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GoToChoosePlan +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GoToRefund +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.OnHeaderClicked +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.OnProStatsClicked +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.SetShowProBadge +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionDetails -import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.SubscriptionState +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.ui.ActionRowItem import org.thoughtcrime.securesms.ui.CategoryCell @@ -132,7 +140,7 @@ fun ProSettingsHome( onHeaderClick = { // add a click handling if the subscription state is loading or errored if(data.subscriptionState.refreshState !is State.Success<*>){ - sendCommand(OnHeaderClicked) + sendCommand(OnHeaderClicked(inSheet)) } else null }, extraHeaderContent = { @@ -212,12 +220,34 @@ fun ProSettingsHome( Spacer(Modifier.height(LocalDimensions.current.spacing)) - AccentFillButtonRect( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.theContinue), - enabled = data.subscriptionState.refreshState is State.Success, - onClick = { sendCommand(GoToChoosePlan) } - ) + Box { + val enableButon = data.subscriptionState.refreshState is State.Success + AccentFillButtonRect( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.theContinue), + enabled = enableButon, + onClick = { sendCommand(GoToChoosePlan(inSheet)) } + ) + // the designs require we should still be able to click on the disabled button... + // this goes against the system the built in ux decisions. + // To avoid extending the button we will instead add a clickable area above the button, + // invisible to screen readers as this is purely a visual action in case people try to + // click in spite of the state being "loading" or "error" + if (!enableButon) { + Box( + modifier = Modifier.fillMaxWidth() + .height(LocalDimensions.current.minItemButtonHeight) + .semantics { + hideFromAccessibility() + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { sendCommand(GoToChoosePlan(inSheet)) } + ) + ) { } + } + } } // Pro Stats @@ -235,6 +265,7 @@ fun ProSettingsHome( ProSettings( data = subscriptionType, subscriptionRefreshState = data.subscriptionState.refreshState, + inSheet = inSheet, expiry = data.subscriptionExpiryLabel, sendCommand = sendCommand, ) @@ -246,6 +277,7 @@ fun ProSettingsHome( ProManage( data = subscriptionType, subscriptionRefreshState = data.subscriptionState.refreshState, + inSheet = inSheet, sendCommand = sendCommand, ) } @@ -263,6 +295,7 @@ fun ProSettingsHome( ProSettingsFooter( subscriptionType = subscriptionType, subscriptionRefreshState = data.subscriptionState.refreshState, + inSheet = inSheet, sendCommand = sendCommand ) } @@ -478,6 +511,7 @@ fun ProSettings( modifier: Modifier = Modifier, data: SubscriptionType.Active, subscriptionRefreshState: State, + inSheet: Boolean, expiry: CharSequence, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ){ @@ -540,7 +574,7 @@ fun ProSettings( } }, qaTag = R.string.qa_pro_settings_action_update_plan, - onClick = { sendCommand(GoToChoosePlan) } + onClick = { sendCommand(GoToChoosePlan(inSheet)) } ) Divider() @@ -730,6 +764,7 @@ private fun ProFeatureItem( fun ProManage( modifier: Modifier = Modifier, data: SubscriptionType, + inSheet: Boolean, subscriptionRefreshState: State, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ){ @@ -832,7 +867,7 @@ fun ProManage( } }, qaTag = R.string.qa_pro_settings_action_renew_plan, - onClick = { sendCommand(GoToChoosePlan) } + onClick = { sendCommand(GoToChoosePlan(inSheet)) } ) Divider() @@ -860,6 +895,7 @@ fun ProManage( fun ProSettingsFooter( subscriptionType: SubscriptionType, subscriptionRefreshState: State, + inSheet: Boolean, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ) { // Manage Pro - Pro @@ -867,6 +903,7 @@ fun ProSettingsFooter( Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) ProManage( data = subscriptionType, + inSheet = inSheet, subscriptionRefreshState = subscriptionRefreshState, sendCommand = sendCommand, ) 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 e74b08c7d2..26f4814934 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 @@ -293,18 +293,20 @@ class ProSettingsViewModel @AssistedInject constructor( } } - Commands.GoToChoosePlan -> { + is Commands.GoToChoosePlan -> { when(_proSettingsUIState.value.subscriptionState.refreshState){ // if we are in a loading or refresh state we should show a dialog instead is State.Loading -> { - val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ - is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proAccessLoading)) + val state = _proSettingsUIState.value.subscriptionState.type + val (title, message) = when{ + state is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proAccessLoading)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proAccessLoadingDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() - is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.checkingProStatus)) + state is SubscriptionType.NeverSubscribed + || command.inSheet -> Phrase.from(context.getText(R.string.checkingProStatus)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.checkingProStatusContinue)) @@ -331,15 +333,17 @@ class ProSettingsViewModel @AssistedInject constructor( } is State.Error -> { - val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ - is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proAccessError)) + val state = _proSettingsUIState.value.subscriptionState.type + val (title, message) = when{ + state is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proAccessError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proAccessNetworkLoadError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format() - is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.proStatusError)) + state is SubscriptionType.NeverSubscribed + || command.inSheet-> Phrase.from(context.getText(R.string.proStatusError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proStatusNetworkErrorContinue)) @@ -504,18 +508,20 @@ class ProSettingsViewModel @AssistedInject constructor( } } - Commands.OnHeaderClicked -> { + is Commands.OnHeaderClicked -> { when(_proSettingsUIState.value.subscriptionState.refreshState){ // if we are in a loading or refresh state we should show a dialog instead is State.Loading -> { - val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ - is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proStatusLoading)) + val state = _proSettingsUIState.value.subscriptionState.type + val (title, message) = when{ + state is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proStatusLoading)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proStatusLoadingDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() - is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.checkingProStatus)) + state is SubscriptionType.NeverSubscribed + || command.inSheet-> Phrase.from(context.getText(R.string.checkingProStatus)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.checkingProStatusContinue)) @@ -542,14 +548,16 @@ class ProSettingsViewModel @AssistedInject constructor( is State.Error -> { _dialogState.update { - val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ - is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proStatusError)) + val state = _proSettingsUIState.value.subscriptionState.type + val (title, message) = when{ + state is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proStatusError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() - is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.proStatusError)) + state is SubscriptionType.NeverSubscribed || + command.inSheet -> Phrase.from(context.getText(R.string.proStatusError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proStatusNetworkErrorContinue)) @@ -651,7 +659,7 @@ class ProSettingsViewModel @AssistedInject constructor( data object HideTCPolicyDialog: Commands data object HideSimpleDialog : Commands - object GoToChoosePlan: Commands + data class GoToChoosePlan(val inSheet: Boolean): Commands object GoToRefund: Commands object GoToCancel: Commands object GoToProSettings: Commands @@ -664,7 +672,7 @@ class ProSettingsViewModel @AssistedInject constructor( data object GetProPlan: Commands data object ConfirmProPlan: Commands - data object OnHeaderClicked: Commands + data class OnHeaderClicked(val inSheet: Boolean): Commands data object OnProStatsClicked: Commands } 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 80acdb84d0..fe78e52674 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -362,7 +362,7 @@ fun SessionProCTA( BoxWithConstraints(modifier = modifier) { val topInset = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() val maxHeight = - (this.maxHeight - topInset) * 0.8f // sheet should take up 80% of the height, without the status bar + (this.maxHeight - topInset) * 0.85f // sheet should take up 80% of the height, without the status bar Box( modifier = Modifier From be0d710d25b69994ebd73276297a9eaff283e77f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 29 Oct 2025 15:15:02 +1100 Subject: [PATCH 04/15] giving subscription state to CTA to display dynamic content --- .../thoughtcrime/securesms/InputBarDialogs.kt | 3 +- .../securesms/InputbarViewModel.kt | 11 ++- .../conversation/v2/MessageDetailActivity.kt | 15 +++- .../v2/MessageDetailsViewModel.kt | 18 +++-- .../settings/ConversationSettingsDialogs.kt | 5 +- .../settings/ConversationSettingsViewModel.kt | 19 +++-- .../securesms/home/HomeDialogs.kt | 1 + .../securesms/home/HomeViewModel.kt | 8 +- .../securesms/preferences/SettingsScreen.kt | 7 +- .../securesms/ui/ProComponents.kt | 79 ++++++++++++++++--- .../securesms/ui/UserProfileModal.kt | 21 ++--- .../securesms/util/UserProfileUtils.kt | 19 ++++- 12 files changed, 152 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt index bb8daa7705..91d720e821 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt @@ -63,8 +63,9 @@ fun InputBarDialogs( } // Pro CTA - if (inputBarDialogsState.sessionProCharLimitCTA) { + if (inputBarDialogsState.sessionProCharLimitCTA != null) { LongMessageProCTA( + proSubscription = inputBarDialogsState.sessionProCharLimitCTA.proSubscription, onDismissRequest = {sendCommand(InputbarViewModel.Commands.HideSessionProCTA)} ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt index 74ba63918a..6b7fe76595 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt @@ -12,6 +12,7 @@ import org.session.libsession.utilities.recipients.isPro import org.session.libsession.utilities.recipients.shouldShowProBadge import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.util.NumberUtil @@ -94,7 +95,7 @@ abstract class InputbarViewModel( fun showSessionProCTA(){ _inputBarStateDialogsState.update { - it.copy(sessionProCharLimitCTA = true) + it.copy(sessionProCharLimitCTA = CharLimitCTAData(proStatusManager.subscriptionState.value.type)) } } @@ -165,7 +166,7 @@ abstract class InputbarViewModel( is Commands.HideSessionProCTA -> { _inputBarStateDialogsState.update { - it.copy(sessionProCharLimitCTA = false) + it.copy(sessionProCharLimitCTA = null) } } } @@ -195,7 +196,11 @@ abstract class InputbarViewModel( data class InputBarDialogsState( val showSimpleDialog: SimpleDialogData? = null, - val sessionProCharLimitCTA: Boolean = false + val sessionProCharLimitCTA: CharLimitCTAData? = null + ) + + data class CharLimitCTAData( + val proSubscription: SubscriptionType ) sealed interface Commands { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index a94a63388d..07a3f7a709 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -706,13 +706,22 @@ fun MessageDetailDialogs( if(state.proBadgeCTA != null){ when(state.proBadgeCTA){ is ProBadgeCTA.Generic -> - GenericProCTA(onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)}) + GenericProCTA( + proSubscription = state.proBadgeCTA.proSubscription, + onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)} + ) is ProBadgeCTA.LongMessage -> - LongMessageProCTA(onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)}) + LongMessageProCTA( + proSubscription = state.proBadgeCTA.proSubscription, + onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)} + ) is ProBadgeCTA.AnimatedProfile -> - AnimatedProfilePicProCTA(onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)}) + AnimatedProfilePicProCTA( + proSubscription = state.proBadgeCTA.proSubscription, + onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)} + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt index 9319d0049a..1f4301bc4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -51,6 +51,7 @@ import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.ProStatusManager.MessageProFeature.AnimatedAvatar import org.thoughtcrime.securesms.pro.ProStatusManager.MessageProFeature.LongMessage +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.TitledText import org.thoughtcrime.securesms.util.AvatarUIData @@ -283,13 +284,14 @@ class MessageDetailsViewModel @AssistedInject constructor( is Commands.ShowProBadgeCTA -> { val features = state.value.proFeatures _dialogState.update { + val proSubscription = proStatusManager.subscriptionState.value.type it.copy( proBadgeCTA = when{ - features.size > 1 -> ProBadgeCTA.Generic // always show the generic cta when there are more than 1 feature + features.size > 1 -> ProBadgeCTA.Generic(proSubscription) // always show the generic cta when there are more than 1 feature - features.contains(LongMessage) -> ProBadgeCTA.LongMessage - features.contains(AnimatedAvatar) -> ProBadgeCTA.AnimatedProfile - else -> ProBadgeCTA.Generic + features.contains(LongMessage) -> ProBadgeCTA.LongMessage(proSubscription) + features.contains(AnimatedAvatar) -> ProBadgeCTA.AnimatedProfile(proSubscription) + else -> ProBadgeCTA.Generic(proSubscription) } ) } @@ -369,10 +371,10 @@ data class MessageDetailsState( val canDelete: Boolean get() = !readOnly } -sealed interface ProBadgeCTA { - data object Generic: ProBadgeCTA - data object LongMessage: ProBadgeCTA - data object AnimatedProfile: ProBadgeCTA +sealed class ProBadgeCTA(open val proSubscription: SubscriptionType) { + data class Generic(override val proSubscription: SubscriptionType): ProBadgeCTA(proSubscription) + data class LongMessage(override val proSubscription: SubscriptionType): ProBadgeCTA(proSubscription) + data class AnimatedProfile(override val proSubscription: SubscriptionType): ProBadgeCTA(proSubscription) } data class DialogsState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index 41f8a0fe57..ffc8c05635 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsV import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateGroupDescription import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateGroupName import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateNickname +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.CTAImage import org.thoughtcrime.securesms.ui.DialogButtonData @@ -232,6 +233,7 @@ fun ConversationSettingsDialogs( // pin CTA if(dialogsState.pinCTA != null){ PinProCTA( + proSubscription = dialogsState.pinCTA.proSubscription, overTheLimit = dialogsState.pinCTA.overTheLimit, onDismissRequest = { sendCommand(HidePinCTADialog) @@ -242,6 +244,7 @@ fun ConversationSettingsDialogs( when(dialogsState.proBadgeCTA){ is ConversationSettingsViewModel.ProBadgeCTA.Generic -> { GenericProCTA( + proSubscription = dialogsState.proBadgeCTA.proSubscription, onDismissRequest = { sendCommand(HideProBadgeCTA) } @@ -438,7 +441,7 @@ fun PreviewCTAGroupDialog() { PreviewTheme { ConversationSettingsDialogs( dialogsState = ConversationSettingsViewModel.DialogsState( - proBadgeCTA = ConversationSettingsViewModel.ProBadgeCTA.Group + proBadgeCTA = ConversationSettingsViewModel.ProBadgeCTA.Group(SubscriptionType.NeverSubscribed) ), sendCommand = {} ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 0d98c5c126..632877c0b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -60,6 +60,7 @@ import org.thoughtcrime.securesms.dependencies.ConfigFactory.Companion.MAX_NAME_ import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.HomeActivity import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.ui.UINavigator @@ -719,7 +720,10 @@ class ConversationSettingsViewModel @AssistedInject constructor( if(totalPins >= maxPins){ // the user has reached the pin limit, show the CTA _dialogState.update { - it.copy(pinCTA = PinProCTA(overTheLimit = totalPins > maxPins)) + it.copy(pinCTA = PinProCTA( + overTheLimit = totalPins > maxPins, + proSubscription = proStatusManager.subscriptionState.value.type + )) } } else { viewModelScope.launch { @@ -1236,8 +1240,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( is Commands.ShowProBadgeCTA -> { _dialogState.update { it.copy( - proBadgeCTA = if(recipient?.isGroupV2Recipient == true) ProBadgeCTA.Group - else ProBadgeCTA.Generic + proBadgeCTA = if(recipient?.isGroupV2Recipient == true) ProBadgeCTA.Group(proStatusManager.subscriptionState.value.type) + else ProBadgeCTA.Generic(proStatusManager.subscriptionState.value.type) ) } } @@ -1441,12 +1445,13 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) data class PinProCTA( - val overTheLimit: Boolean + val overTheLimit: Boolean, + val proSubscription: SubscriptionType ) - sealed interface ProBadgeCTA { - data object Generic: ProBadgeCTA - data object Group: ProBadgeCTA + sealed class ProBadgeCTA(open val proSubscription: SubscriptionType) { + data class Generic(override val proSubscription: SubscriptionType): ProBadgeCTA(proSubscription) + data class Group(override val proSubscription: SubscriptionType): ProBadgeCTA(proSubscription) } data class NicknameDialogData( diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt index 31e7c693e7..5dc75d008e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -45,6 +45,7 @@ fun HomeDialogs( if(dialogsState.pinCTA != null){ PinProCTA( overTheLimit = dialogsState.pinCTA.overTheLimit, + proSubscription = dialogsState.pinCTA.proSubscription, onDismissRequest = { sendCommand(HidePinCTADialog) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index c969576b01..628204e81a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -261,7 +261,10 @@ class HomeViewModel @Inject constructor( // the user has reached the pin limit, show the CTA _dialogsState.update { it.copy( - pinCTA = PinProCTA(overTheLimit = totalPins > maxPins) + pinCTA = PinProCTA( + overTheLimit = totalPins > maxPins, + proSubscription = proStatusManager.subscriptionState.value.type + ) ) } } else { @@ -341,7 +344,8 @@ class HomeViewModel @Inject constructor( ) data class PinProCTA( - val overTheLimit: Boolean + val overTheLimit: Boolean, + val proSubscription: SubscriptionType ) data class ProExpiringCTA( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt index 693d109e32..738b0e5cdf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -395,7 +395,7 @@ fun Settings( // Animated avatar CTA if(uiState.showAnimatedProCTA){ AnimatedProCTA( - isPro = uiState.isPro, + proSubscription = uiState.subscriptionState.type, sendCommand = sendCommand ) } @@ -994,10 +994,10 @@ fun AvatarDialog( @Composable fun AnimatedProCTA( - isPro: Boolean, + proSubscription: SubscriptionType, sendCommand: (SettingsViewModel.Commands) -> Unit, ){ - if(isPro) { + if(proSubscription is SubscriptionType.Active) { SessionProCTA ( title = stringResource(R.string.proActivated), badgeAtStart = true, @@ -1032,6 +1032,7 @@ fun AnimatedProCTA( ) } else { AnimatedProfilePicProCTA( + proSubscription = proSubscription, onDismissRequest = { sendCommand(HideAnimatedProCTA) }, ) } 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 fe78e52674..d5d395fcb0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -83,8 +84,11 @@ import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsNavHost +import org.thoughtcrime.securesms.pro.SubscriptionDetails +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.components.BaseBottomSheet @@ -97,6 +101,8 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.util.AvatarUIData +import java.time.Duration +import java.time.Instant @Composable @@ -550,13 +556,23 @@ fun CTAAnimatedImages( // Reusable generic Pro CTA @Composable fun GenericProCTA( + proSubscription: SubscriptionType, onDismissRequest: () -> Unit, ){ val context = LocalContext.current + val expired = proSubscription is SubscriptionType.Expired + AnimatedSessionProCTA( heroImageBg = R.drawable.cta_hero_generic_bg, heroImageAnimatedFg = R.drawable.cta_hero_generic_fg, - text = Phrase.from(context,R.string.proUserProfileModalCallToAction) + title = if(expired) stringResource(R.string.renew) + else stringResource(R.string.upgradeTo), + text = if(expired) Phrase.from(context,R.string.proRenewMaxPotential) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + else Phrase.from(context, R.string.proUserProfileModalCallToAction) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() @@ -575,11 +591,21 @@ fun GenericProCTA( // Reusable long message Pro CTA @Composable fun LongMessageProCTA( + proSubscription: SubscriptionType, onDismissRequest: () -> Unit, ){ + val expired = proSubscription is SubscriptionType.Expired + val context = LocalContext.current + SimpleSessionProCTA( heroImage = R.drawable.cta_hero_char_limit, - text = Phrase.from(LocalContext.current, R.string.proCallToActionLongerMessages) + title = if(expired) stringResource(R.string.renew) + else stringResource(R.string.upgradeTo), + text = if(expired) Phrase.from(context,R.string.proRenewLongerMessages) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + else Phrase.from(context, R.string.proCallToActionLongerMessages) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() .toString(), @@ -597,12 +623,22 @@ fun LongMessageProCTA( // Reusable animated profile pic Pro CTA @Composable fun AnimatedProfilePicProCTA( + proSubscription: SubscriptionType, onDismissRequest: () -> Unit, ){ + val expired = proSubscription is SubscriptionType.Expired + val context = LocalContext.current + AnimatedSessionProCTA( heroImageBg = R.drawable.cta_hero_animated_bg, heroImageAnimatedFg = R.drawable.cta_hero_animated_fg, - text = Phrase.from(LocalContext.current, R.string.proAnimatedDisplayPictureCallToActionDescription) + title = if(expired) stringResource(R.string.renew) + else stringResource(R.string.upgradeTo), + text =if(expired) Phrase.from(context,R.string.proRenewAnimatedDisplayPicture) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + else Phrase.from(context, R.string.proAnimatedDisplayPictureCallToActionDescription) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() .toString(), @@ -623,24 +659,41 @@ fun AnimatedProfilePicProCTA( @Composable fun PinProCTA( overTheLimit: Boolean, + proSubscription: SubscriptionType, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ){ + val expired = proSubscription is SubscriptionType.Expired val context = LocalContext.current + + val title = when{ + overTheLimit && expired -> Phrase.from(context, R.string.proRenewPinMoreConversations) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + + overTheLimit && !expired -> Phrase.from(context, R.string.proCallToActionPinnedConversations) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString() + + !overTheLimit && expired -> Phrase.from(context, R.string.proRenewPinFiveConversations) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + + else -> Phrase.from(context, R.string.proCallToActionPinnedConversationsMoreThan) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString() + } SimpleSessionProCTA( modifier = modifier, heroImage = R.drawable.cta_hero_pins, - text = if(overTheLimit) - Phrase.from(context, R.string.proCallToActionPinnedConversations) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format() - .toString() - else - Phrase.from(context, R.string.proCallToActionPinnedConversationsMoreThan) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format() - .toString(), + title = if(expired) stringResource(R.string.renew) + else stringResource(R.string.upgradeTo), + text = title, features = listOf( CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt index 811532e20a..dae5298cfb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt @@ -38,6 +38,7 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.ui.components.SlimAccentOutlineButton import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -51,6 +52,7 @@ import org.thoughtcrime.securesms.ui.theme.monospace import org.thoughtcrime.securesms.ui.theme.primaryRed import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement +import org.thoughtcrime.securesms.util.GenericCTAData import org.thoughtcrime.securesms.util.UserProfileModalCommands import org.thoughtcrime.securesms.util.UserProfileModalData @@ -220,8 +222,9 @@ fun UserProfileModal( ) // the pro CTA that comes with UPM - if(data.showProCTA){ + if(data.showProCTA != null){ GenericProCTA( + proSubscription = data.showProCTA.proSubscription, onDismissRequest = { sendCommand(UserProfileModalCommands.HideSessionProCTA) }, @@ -250,7 +253,7 @@ private fun PreviewUPM( enableMessage = true, expandedAvatar = false, showQR = false, - showProCTA = false, + showProCTA = null, avatarUIData = AvatarUIData( listOf( AvatarUIElement( @@ -270,10 +273,10 @@ private fun PreviewUPM( sendCommand = { command -> when(command){ UserProfileModalCommands.ShowProCTA -> { - data = data.copy(showProCTA = true) + data = data.copy(showProCTA = GenericCTAData(SubscriptionType.NeverSubscribed)) } UserProfileModalCommands.HideSessionProCTA -> { - data = data.copy(showProCTA = false) + data = data.copy(showProCTA = null) } UserProfileModalCommands.ToggleQR -> { data = data.copy(showQR = !data.showQR) @@ -310,7 +313,7 @@ private fun PreviewUPMResolved( enableMessage = true, expandedAvatar = false, showQR = true, - showProCTA = false, + showProCTA = null, avatarUIData = AvatarUIData( listOf( AvatarUIElement( @@ -330,10 +333,10 @@ private fun PreviewUPMResolved( sendCommand = { command -> when(command){ UserProfileModalCommands.ShowProCTA -> { - data = data.copy(showProCTA = true) + data = data.copy(showProCTA = GenericCTAData(SubscriptionType.NeverSubscribed)) } UserProfileModalCommands.HideSessionProCTA -> { - data = data.copy(showProCTA = false) + data = data.copy(showProCTA = null) } UserProfileModalCommands.ToggleQR -> { data = data.copy(showQR = !data.showQR) @@ -371,7 +374,7 @@ private fun PreviewUPMQR( enableMessage = false, expandedAvatar = false, showQR = false, - showProCTA = false, + showProCTA = null, avatarUIData = AvatarUIData( listOf( AvatarUIElement( @@ -413,7 +416,7 @@ private fun PreviewUPMCTA( enableMessage = false, expandedAvatar = true, showQR = false, - showProCTA = true, + showProCTA = GenericCTAData(SubscriptionType.NeverSubscribed), avatarUIData = AvatarUIData( listOf( AvatarUIElement( diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt index 8b09a6a485..9acc453a4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt @@ -29,6 +29,8 @@ import org.session.libsession.utilities.toBlinded import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.SubscriptionType /** * Helper class to get the information required for the user profile modal @@ -41,6 +43,7 @@ class UserProfileUtils @AssistedInject constructor( private val avatarUtils: AvatarUtils, private val blindedIdMappingRepository: BlindMappingRepository, private val recipientRepository: RecipientRepository, + private val proStatusManager: ProStatusManager ) { private val _userProfileModalData: MutableStateFlow = MutableStateFlow(null) val userProfileModalData: StateFlow @@ -123,7 +126,7 @@ class UserProfileUtils @AssistedInject constructor( enableMessage = !recipient.address.isBlinded || recipient.acceptsBlindedCommunityMessageRequests, expandedAvatar = false, showQR = false, - showProCTA = false, + showProCTA = null, messageAddress = messageAddress, ) @@ -140,11 +143,15 @@ class UserProfileUtils @AssistedInject constructor( fun onCommand(command: UserProfileModalCommands){ when(command){ UserProfileModalCommands.ShowProCTA -> { - _userProfileModalData.update { _userProfileModalData.value?.copy(showProCTA = true) } + _userProfileModalData.update { + _userProfileModalData.value?.copy( + showProCTA = GenericCTAData(proStatusManager.subscriptionState.value.type) + ) + } } UserProfileModalCommands.HideSessionProCTA -> { - _userProfileModalData.update { _userProfileModalData.value?.copy(showProCTA = false) } + _userProfileModalData.update { _userProfileModalData.value?.copy(showProCTA = null) } } UserProfileModalCommands.ToggleQR -> { @@ -196,7 +203,11 @@ data class UserProfileModalData( val expandedAvatar: Boolean, val showQR: Boolean, val avatarUIData: AvatarUIData, - val showProCTA: Boolean + val showProCTA: GenericCTAData? +) + +data class GenericCTAData( + val proSubscription: SubscriptionType ) sealed interface UserProfileModalCommands { From 054e01ea9659aca79616c7fc4da2968d4f47cc10 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 30 Oct 2025 12:53:53 +1100 Subject: [PATCH 05/15] Rely on subscription state instead of simple boolean for Pro status --- .../org/thoughtcrime/securesms/preferences/SettingsScreen.kt | 5 ++--- .../thoughtcrime/securesms/preferences/SettingsViewModel.kt | 3 --- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt index 738b0e5cdf..17089d4741 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -267,7 +267,7 @@ fun Settings( }, text = uiState.username, iconSize = 53.sp to 24.sp, - content = if(uiState.isPro){{ + content = if(uiState.subscriptionState.type !is SubscriptionType.NeverSubscribed){{ // if we are pro or expired ProBadge( modifier = Modifier.padding(start = 4.dp) .qaTag(stringResource(R.string.qa_pro_badge_icon)), @@ -385,7 +385,7 @@ fun Settings( if(uiState.showAvatarDialog) { AvatarDialog( state = uiState.avatarDialogState, - isPro = uiState.isPro, + isPro = uiState.subscriptionState.type is SubscriptionType.Active, isPostPro = uiState.isPostPro, sendCommand = sendCommand, startAvatarSelection = startAvatarSelection @@ -1062,7 +1062,6 @@ private fun SettingsScreenPreview() { ) ) ), - isPro = true, isPostPro = true, subscriptionState = SubscriptionState( type = SubscriptionType.Active.AutoRenewing( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index 87b0e6c8c7..ec25ec3788 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -96,7 +96,6 @@ class SettingsViewModel @Inject constructor( hasPath = true, version = getVersionNumber(), recoveryHidden = prefs.getHidePassword(), - isPro = selfRecipient.value.proStatus.isPro(), isPostPro = proStatusManager.isPostPro(), subscriptionState = getDefaultSubscriptionStateData(), )) @@ -111,7 +110,6 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy( username = recipient.displayName(attachesBlindedId = false), - isPro = recipient.proStatus.isPro(), ) } } @@ -633,7 +631,6 @@ class SettingsViewModel @Inject constructor( val showAnimatedProCTA: Boolean = false, val usernameDialog: UsernameDialogData? = null, val showSimpleDialog: SimpleDialogData? = null, - val isPro: Boolean, val isPostPro: Boolean, val subscriptionState: SubscriptionState, ) From fa92657873eb3d093c6be2fa5b3d5f4b5a02d035 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 30 Oct 2025 16:06:15 +1100 Subject: [PATCH 06/15] Renaming subscription methods in preparation for price calculation --- .../prosettings/ProSettingsViewModel.kt | 16 +++----- .../chooseplan/ChoosePlanScreen.kt | 5 +-- .../subscription/NoOpSubscriptionManager.kt | 2 +- .../subscription/ProSubscriptionDuration.kt | 11 +++-- .../pro/subscription/SubscriptionManager.kt | 9 ++++- .../PlayStoreSubscriptionManager.kt | 40 +++++++++---------- 6 files changed, 42 insertions(+), 41 deletions(-) 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 26f4814934..633f85f137 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 @@ -36,7 +36,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_SINGULAR_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY -import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionState @@ -90,7 +89,7 @@ class ProSettingsViewModel @AssistedInject constructor( // observe purchase events viewModelScope.launch { subscriptionCoordinator.getCurrentManager().purchaseEvents.collect { purchaseEvent -> - _choosePlanState.update { it.copy(loading = false) } + _choosePlanState.update { it.copy(purchaseInProgress = false) } when(purchaseEvent){ is SubscriptionManager.PurchaseEvent.Success -> { @@ -125,7 +124,7 @@ class ProSettingsViewModel @AssistedInject constructor( ChoosePlanState( subscriptionType = subType, - hasValidSubscription = proState.hasValidSubscription, + hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription(), hasBillingCapacity = supportsBilling, enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state plans = listOf( @@ -243,7 +242,7 @@ class ProSettingsViewModel @AssistedInject constructor( } } - private suspend fun generateState(subscriptionState: SubscriptionState){ + private fun generateState(subscriptionState: SubscriptionState){ //todo PRO need to properly calculate this val subType = subscriptionState.type @@ -251,8 +250,6 @@ class ProSettingsViewModel @AssistedInject constructor( _proSettingsUIState.update { ProSettingsState( subscriptionState = subscriptionState, - //todo PRO need to get the product id from libsession - also this might be a long running operation - hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription(""), subscriptionExpiryLabel = when(subType){ is SubscriptionType.Active.AutoRenewing -> Phrase.from(context, R.string.proAutoRenewTime) @@ -634,7 +631,7 @@ class ProSettingsViewModel @AssistedInject constructor( if(purchaseStarted.isSuccess) { _choosePlanState.update { - it.copy(loading = true) + it.copy(purchaseInProgress = true) } } } @@ -679,16 +676,15 @@ class ProSettingsViewModel @AssistedInject constructor( data class ProSettingsState( val subscriptionState: SubscriptionState = getDefaultSubscriptionStateData(), val proStats: State = State.Loading, - val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession val subscriptionExpiryLabel: CharSequence = "", // eg: "Pro auto renewing in 3 days" val subscriptionExpiryDate: CharSequence = "" // eg: "May 21st, 2025" ) data class ChoosePlanState( val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, - val hasBillingCapacity: Boolean = false, + val hasBillingCapacity: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession val hasValidSubscription: Boolean = false, - val loading: Boolean = false, + val purchaseInProgress: Boolean = false, val plans: List = emptyList(), val enableButton: Boolean = false, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index 6041b01578..7cc98f1380 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -157,7 +156,7 @@ fun ChoosePlan( proPlan = data, badgePadding = badgeHeight / 2, onBadgeLaidOut = { height -> badgeHeight = max(badgeHeight, height) }, - enabled = !planData.loading, + enabled = !planData.purchaseInProgress, onClick = { sendCommand(SelectProPlan(data)) } @@ -185,7 +184,7 @@ fun ChoosePlan( sendCommand(GetProPlan) } ){ - LoadingArcOr(loading = planData.loading) { + LoadingArcOr(loading = planData.purchaseInProgress) { Text(text = buttonLabel) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 967056de8a..9d1e64a615 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -29,7 +29,7 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val purchaseEvents: SharedFlow = MutableSharedFlow() - override suspend fun hasValidSubscription(productId: String): Boolean { + override suspend fun hasValidSubscription(): Boolean { return false } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt index 5f1b7b1ace..e07be952ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt @@ -5,12 +5,15 @@ import java.time.Duration import java.time.Period import java.time.ZonedDateTime -enum class ProSubscriptionDuration(val duration: Period) { - ONE_MONTH(Period.ofMonths(1)), - THREE_MONTHS(Period.ofMonths(3)), - TWELVE_MONTHS(Period.ofMonths(12)) +enum class ProSubscriptionDuration(val duration: Period, val id: String) { + ONE_MONTH(Period.ofMonths(1), "session-pro-1-month"), + THREE_MONTHS(Period.ofMonths(3), "session-pro-3-months"), + TWELVE_MONTHS(Period.ofMonths(12), "session-pro-12-months") } +fun ProSubscriptionDuration.getById(id: String): ProSubscriptionDuration? = + ProSubscriptionDuration.entries.find { it.id == id } + private val proSettingsDateFormat = "MMMM d, yyyy" fun ProSubscriptionDuration.expiryFromNow(): String { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index 3a516c5012..e44d6691d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -41,8 +41,13 @@ interface SubscriptionManager: OnAppStartupComponent { suspend fun isWithinQuickRefundWindow(): Boolean /** - * Checks whether there is a valid subscription for the given product id for the current user within this subscriber's billing API + * Checks whether there is a valid subscription for the current user within this subscriber's billing API */ - suspend fun hasValidSubscription(productId: String): Boolean + suspend fun hasValidSubscription(): Boolean + + data class SubscriptionPricing( + val subscriptionDuration: ProSubscriptionDuration, + val price: String, + ) } diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 511066a1d6..2aa8b8d689 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -8,6 +8,7 @@ import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingResult import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.ProductDetailsResult import com.android.billingclient.api.Purchase import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryPurchasesParams @@ -43,6 +44,7 @@ import java.time.Instant import java.time.temporal.ChronoUnit import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.measureTime /** * The Google Play Store implementation of our subscription manager @@ -92,7 +94,6 @@ class PlayStoreSubscriptionManager @Inject constructor( // signal that purchase was completed try { //todo PRO send confirmation to libsession - delay(4000) } catch (e : Exception){ _purchaseEvents.emit(PurchaseEvent.Failed()) } @@ -126,18 +127,7 @@ class PlayStoreSubscriptionManager @Inject constructor( "No current activity available to launch the billing flow" } - val result = billingClient.queryProductDetails( - QueryProductDetailsParams.newBuilder() - .setProductList( - listOf( - QueryProductDetailsParams.Product.newBuilder() - .setProductId("session_pro") - .setProductType(BillingClient.ProductType.SUBS) - .build() - ) - ) - .build() - ) + val result = getProductDetails() check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { "Failed to query product details. Reason: ${result.billingResult}" @@ -147,7 +137,7 @@ class PlayStoreSubscriptionManager @Inject constructor( "Unable to get the product: product for given id is null" } - val planId = subscriptionDuration.planId + val planId = subscriptionDuration.id val offerDetails = checkNotNull(productDetails.subscriptionOfferDetails ?.firstOrNull { it.basePlanId == planId }) { @@ -206,12 +196,20 @@ class PlayStoreSubscriptionManager @Inject constructor( } } - private val ProSubscriptionDuration.planId: String - get() = when (this) { - ProSubscriptionDuration.ONE_MONTH -> "session-pro-1-month" - ProSubscriptionDuration.THREE_MONTHS -> "session-pro-3-months" - ProSubscriptionDuration.TWELVE_MONTHS -> "session-pro-12-months" - } + private suspend fun getProductDetails(): ProductDetailsResult { + return billingClient.queryProductDetails( + QueryProductDetailsParams.newBuilder() + .setProductList( + listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId("session_pro") + .setProductType(BillingClient.ProductType.SUBS) + .build() + ) + ) + .build() + ) + } override fun onPostAppStarted() { super.onPostAppStarted() @@ -253,7 +251,7 @@ class PlayStoreSubscriptionManager @Inject constructor( } } - override suspend fun hasValidSubscription(productId: String): Boolean { + override suspend fun hasValidSubscription(): Boolean { // if in debug mode, always return true return if(prefs.forceCurrentUserAsPro()) true else getExistingSubscription() != null From 150ca717a5ee952cfeeab5117103cf2103b2fa4d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 10:13:16 +1100 Subject: [PATCH 07/15] Added State management to the choose plan and cancel data --- .../securesms/home/HomeDialogs.kt | 13 +- .../prosettings/CancelPlanScreen.kt | 98 +++--- .../prosettings/ProSettingsNavHost.kt | 26 +- .../prosettings/ProSettingsViewModel.kt | 327 ++++++++++-------- .../{ChoosePlanScreen.kt => ChoosePlan.kt} | 0 .../chooseplan/ChoosePlanHomeScreen.kt | 90 +++++ .../chooseplan/GetOrRenewPlanScreen.kt | 38 -- .../chooseplan/UpdatePlanScreen.kt | 50 --- 8 files changed, 331 insertions(+), 311 deletions(-) rename app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/{ChoosePlanScreen.kt => ChoosePlan.kt} (100%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt index 5dc75d008e..036d3822b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -1,38 +1,29 @@ package org.thoughtcrime.securesms.home -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.sp import com.squareup.phrase.Phrase import kotlinx.coroutines.delay import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants -import org.session.libsession.utilities.NonTranslatableStringConstants.PRO -import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HandleUserProfileCommand import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HidePinCTADialog import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HideUserProfileModal -import org.thoughtcrime.securesms.home.startconversation.StartConversationDestination import org.thoughtcrime.securesms.home.startconversation.StartConversationSheet import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination import org.thoughtcrime.securesms.ui.AnimatedSessionProCTA import org.thoughtcrime.securesms.ui.CTAFeature import org.thoughtcrime.securesms.ui.PinProCTA -import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.UserProfileModal -import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme @Composable @@ -106,7 +97,7 @@ fun HomeDialogs( negativeButtonText = stringResource(R.string.close), onUpgrade = { sendCommand(HomeViewModel.Commands.HideExpiringCTADialog) - sendCommand(HomeViewModel.Commands.GotoProSettings(ProSettingsDestination.UpdatePlan)) + sendCommand(HomeViewModel.Commands.GotoProSettings(ProSettingsDestination.ChoosePlan)) }, onCancel = { sendCommand(HomeViewModel.Commands.HideExpiringCTADialog) @@ -147,7 +138,7 @@ fun HomeDialogs( negativeButtonText = stringResource(R.string.cancel), onUpgrade = { sendCommand(HomeViewModel.Commands.HideExpiredCTADialog) - sendCommand(HomeViewModel.Commands.GotoProSettings(ProSettingsDestination.GetOrRenewPlan)) + sendCommand(HomeViewModel.Commands.GotoProSettings(ProSettingsDestination.ChoosePlan)) }, onCancel = { sendCommand(HomeViewModel.Commands.HideExpiredCTADialog) 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 b16d5abd08..b826dfd584 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 @@ -1,14 +1,18 @@ package org.thoughtcrime.securesms.preferences.prosettings +import android.widget.Toast import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -19,13 +23,9 @@ import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY -import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.OpenSubscriptionPage -import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionType -import org.thoughtcrime.securesms.pro.subscription.NoOpSubscriptionManager -import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration -import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -34,8 +34,7 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.bold -import java.time.Duration -import java.time.Instant +import org.thoughtcrime.securesms.util.State @OptIn(ExperimentalSharedTransitionApi::class) @@ -44,42 +43,57 @@ fun CancelPlanScreen( viewModel: ProSettingsViewModel, onBack: () -> Unit, ) { - val planData by viewModel.choosePlanState.collectAsState() - val activePlan = planData.subscriptionType as? SubscriptionType.Active - if (activePlan == null) { - onBack() - return - } + val state by viewModel.cancelPlanState.collectAsState() - val subManager = viewModel.getSubscriptionManager() + when(state) { + is State.Error -> { + // show a toast and go back to pro settings home screen + Toast.makeText(LocalContext.current, R.string.errorGeneric, Toast.LENGTH_LONG).show() + onBack() + } - // there are different UI depending on the state - when { - // there is an active subscription but from a different platform or from the - // same platform but a different account - activePlan.subscriptionDetails.isFromAnotherPlatform() - || !planData.hasValidSubscription -> - CancelPlanNonOriginating( - subscriptionDetails = activePlan.subscriptionDetails, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) + is State.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } - // default cancel screen - else -> CancelPlan( - data = activePlan, - subscriptionManager = subManager, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) + is State.Success -> { + val planData = (state as State.Success).value + val activePlan = planData.subscriptionType as? SubscriptionType.Active + if (activePlan == null) { + onBack() + return + } + + // there are different UI depending on the state + when { + // there is an active subscription but from a different platform or from the + // same platform but a different account + activePlan.subscriptionDetails.isFromAnotherPlatform() + || !planData.hasValidSubscription -> + CancelPlanNonOriginating( + subscriptionDetails = activePlan.subscriptionDetails, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // default cancel screen + else -> CancelPlan( + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun CancelPlan( - data: SubscriptionType.Active, - subscriptionManager: SubscriptionManager, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { @@ -131,22 +145,6 @@ private fun PreviewCancelPlan( ) { PreviewTheme(colors) { CancelPlan( - data = SubscriptionType.Active.AutoRenewing( - proStatus = ProStatus.Pro( - visible = true, - validUntil = Instant.now() + Duration.ofDays(14), - ), - duration = ProSubscriptionDuration.THREE_MONTHS, - subscriptionDetails = SubscriptionDetails( - device = "Android", - store = "Google Play Store", - platform = "Google", - platformAccount = "Google account", - subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", - refundUrl = "https://getsession.org/android-refund", - ) - ), - subscriptionManager = NoOpSubscriptionManager(), sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index b96c374ec4..82e904ad48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -3,15 +3,10 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.annotation.SuppressLint import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.NavHost @@ -21,13 +16,10 @@ import androidx.navigation.compose.rememberNavController import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.CancelSubscription -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.GetOrRenewPlan import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.Home import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.PlanConfirmation import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.RefundSubscription -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.UpdatePlan -import org.thoughtcrime.securesms.preferences.prosettings.chooseplan.GetOrRenewPlanScreen -import org.thoughtcrime.securesms.preferences.prosettings.chooseplan.UpdatePlanScreen +import org.thoughtcrime.securesms.preferences.prosettings.chooseplan.ChoosePlanHomeScreen import org.thoughtcrime.securesms.ui.NavigationAction import org.thoughtcrime.securesms.ui.ObserveAsEvents import org.thoughtcrime.securesms.ui.UINavigator @@ -41,10 +33,7 @@ sealed interface ProSettingsDestination: Parcelable { @Serializable @Parcelize - data object UpdatePlan: ProSettingsDestination - @Serializable - @Parcelize - data object GetOrRenewPlan: ProSettingsDestination + data object ChoosePlan: ProSettingsDestination @Serializable @Parcelize @@ -117,16 +106,9 @@ fun ProSettingsNavHost( } // Subscription plan selection - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - UpdatePlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } - horizontalSlideComposable { entry -> + horizontalSlideComposable { entry -> val viewModel = navController.proGraphViewModel(entry, navigator) - GetOrRenewPlanScreen( + ChoosePlanHomeScreen( viewModel = viewModel, onBack = handleBack, ) 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 633f85f137..a46bf0c7c7 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 @@ -16,9 +16,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update @@ -72,12 +72,15 @@ class ProSettingsViewModel @AssistedInject constructor( private val _dialogState: MutableStateFlow = MutableStateFlow(DialogsState()) val dialogState: StateFlow = _dialogState - private val _choosePlanState: MutableStateFlow = MutableStateFlow(ChoosePlanState()) - val choosePlanState: StateFlow = _choosePlanState + private val _choosePlanState: MutableStateFlow> = MutableStateFlow(State.Loading) + val choosePlanState: StateFlow> = _choosePlanState private val _refundPlanState: MutableStateFlow = MutableStateFlow(RefundPlanState()) val refundPlanState: StateFlow = _refundPlanState + private val _cancelPlanState: MutableStateFlow> = MutableStateFlow(State.Loading) + val cancelPlanState: StateFlow> = _cancelPlanState + init { // observe subscription status viewModelScope.launch { @@ -89,7 +92,16 @@ class ProSettingsViewModel @AssistedInject constructor( // observe purchase events viewModelScope.launch { subscriptionCoordinator.getCurrentManager().purchaseEvents.collect { purchaseEvent -> - _choosePlanState.update { it.copy(purchaseInProgress = false) } + val data = choosePlanState.value + + // stop loader + if(data is State.Success) { + _choosePlanState.update { + State.Success( + data.value.copy(purchaseInProgress = false) + ) + } + } when(purchaseEvent){ is SubscriptionManager.PurchaseEvent.Success -> { @@ -111,120 +123,6 @@ class ProSettingsViewModel @AssistedInject constructor( } } - // Update choosePlanState whenever proSettingsUIState or the billing support change - viewModelScope.launch { - combine(_proSettingsUIState, - subscriptionCoordinator.getCurrentManager().supportsBilling - ) { proState, supportsBilling -> - val subType = proState.subscriptionState.type - val isActive = subType is SubscriptionType.Active - val currentPlan12Months = isActive && subType.duration == ProSubscriptionDuration.TWELVE_MONTHS - val currentPlan3Months = isActive && subType.duration == ProSubscriptionDuration.THREE_MONTHS - val currentPlan1Month = isActive && subType.duration == ProSubscriptionDuration.ONE_MONTH - - ChoosePlanState( - subscriptionType = subType, - hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription(), - hasBillingCapacity = supportsBilling, - enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state - plans = listOf( - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceTwelveMonths)) - .put(MONTHLY_PRICE_KEY, "$3.99") //todo PRO calculate properly - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) - .put(PRICE_KEY, "$47.99") //todo PRO calculate properly - .format().toString(), - selected = currentPlan12Months || subType !is SubscriptionType.Active, // selected if our active sub is 12 month, or as a default for non pro or renew - currentPlan = currentPlan12Months, - durationType = ProSubscriptionDuration.TWELVE_MONTHS, - badges = buildList { - if(currentPlan12Months){ - add( - ProPlanBadge(context.getString(R.string.currentBilling)) - ) - } - - add( - ProPlanBadge( - "33% Off", //todo PRO calculate properly - if(currentPlan12Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(PERCENT_KEY, "33") //todo PRO calculate properly - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - else null - ) - ) - }, - ), - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceThreeMonths)) - .put(MONTHLY_PRICE_KEY, "$4.99") //todo PRO calculate properly - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly)) - .put(PRICE_KEY, "$14.99") //todo PRO calculate properly - .format().toString(), - selected = currentPlan3Months, - currentPlan = currentPlan3Months, - durationType = ProSubscriptionDuration.THREE_MONTHS, - badges = buildList { - if(currentPlan3Months){ - add( - ProPlanBadge(context.getString(R.string.currentBilling)) - ) - } - - add( - ProPlanBadge( - "16% Off", //todo PRO calculate properly - if(currentPlan3Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(PERCENT_KEY, "16") //todo PRO calculate properly - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - else null - ) - ) - }, - ), - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceOneMonth)) - .put(MONTHLY_PRICE_KEY, "$5.99") //todo PRO calculate properly - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledMonthly)) - .put(PRICE_KEY, "$5") //todo PRO calculate properly - .format().toString(), - selected = currentPlan1Month, - currentPlan = currentPlan1Month, - durationType = ProSubscriptionDuration.ONE_MONTH, - badges = if(currentPlan1Month) listOf( - ProPlanBadge(context.getString(R.string.currentBilling)) - ) else emptyList(), - ), - ) - ) - } - .distinctUntilChanged() - .collect { newState -> - _choosePlanState.update { currentState -> - // Preserve the current selection if plans exist - if (currentState.plans.isNotEmpty()) { - val currentlySelectedPlan = currentState.plans.firstOrNull { it.selected } - newState.copy( - plans = newState.plans.map { plan -> - plan.copy( - selected = currentlySelectedPlan?.durationType == plan.durationType - ) - } - ) - } else { - newState - } - } - } - } - // Update refund plan state viewModelScope.launch { _proSettingsUIState.map { proUIState -> @@ -373,12 +271,8 @@ class ProSettingsViewModel @AssistedInject constructor( } } - // otherwise navigate to update or get/renew plan screen - else -> navigateTo( - if(_proSettingsUIState.value.subscriptionState.type is SubscriptionType.Active ) - ProSettingsDestination.UpdatePlan - else ProSettingsDestination.GetOrRenewPlan - ) + // go to the "choose plan" screen + else -> goToChoosePlan() } } @@ -387,7 +281,22 @@ class ProSettingsViewModel @AssistedInject constructor( } Commands.GoToCancel -> { + // calculate state + _cancelPlanState.update { State.Loading } navigateTo(ProSettingsDestination.CancelSubscription) + + viewModelScope.launch { + val hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription() + + _cancelPlanState.update { + State.Success( + CancelPlanState( + subscriptionType = _proSettingsUIState.value.subscriptionState.type, + hasValidSubscription = hasValidSubscription + ) + ) + } + } } Commands.GoToProSettings -> { @@ -419,13 +328,17 @@ class ProSettingsViewModel @AssistedInject constructor( } is Commands.SelectProPlan -> { - _choosePlanState.update { data -> - data.copy( - plans = data.plans.map { - it.copy(selected = it == command.plan) - }, - enableButton = data.subscriptionType !is SubscriptionType.Active.AutoRenewing - || !command.plan.currentPlan + val data: ChoosePlanState = (_choosePlanState.value as? State.Success)?.value ?: return + + _choosePlanState.update { + State.Success( + data.copy( + plans = data.plans.map { + it.copy(selected = it == command.plan) + }, + enableButton = data.subscriptionType !is SubscriptionType.Active.AutoRenewing + || !command.plan.currentPlan + ) ) } } @@ -444,9 +357,10 @@ class ProSettingsViewModel @AssistedInject constructor( Commands.GetProPlan -> { val currentSubscription = _proSettingsUIState.value.subscriptionState.type + val selectedPlan = getSelectedPlan() ?: return if(currentSubscription is SubscriptionType.Active){ - val newSubscriptionExpiryString = getSelectedPlan().durationType.expiryFromNow() + val newSubscriptionExpiryString = selectedPlan.durationType.expiryFromNow() val currentSubscriptionDuration = DateUtils.getLocalisedTimeDuration( context = context, @@ -456,7 +370,7 @@ class ProSettingsViewModel @AssistedInject constructor( val selectedSubscriptionDuration = DateUtils.getLocalisedTimeDuration( context = context, - amount = getSelectedPlan().durationType.duration.months, + amount = selectedPlan.durationType.duration.months, unit = MeasureUnit.MONTH ) @@ -619,19 +533,147 @@ class ProSettingsViewModel @AssistedInject constructor( //todo PRO implement properly } - private fun getSelectedPlan(): ProPlan { - return _choosePlanState.value.plans.first { it.selected } + private fun getSelectedPlan(): ProPlan? { + return (_choosePlanState.value as? State.Success)?.value?.plans?.first { it.selected } + } + + private fun goToChoosePlan(){ + // Get the choose plan state ready in loading mode + _choosePlanState.update { State.Loading } + + // Navigate to choose plan screen + navigateTo(ProSettingsDestination.ChoosePlan) + + // while the user is on the page we need to calculate the "choose plan" data + viewModelScope.launch { + val subType = _proSettingsUIState.value.subscriptionState.type + + // first check if the user has a valid subscription + val hasValidSub = subscriptionCoordinator.getCurrentManager().hasValidSubscription() + + // next get the plans, including their pricing + // there is no point in calculating it if the user is pro but without a valid sub + // (meaning they got pro from a different google account than the one they are on now + val plans = if(subType is SubscriptionType.Active && !hasValidSub) emptyList() + else getSubscriptionPlans(subType) + + _choosePlanState.update { + State.Success( + ChoosePlanState( + subscriptionType = subType, + hasValidSubscription = hasValidSub, + hasBillingCapacity = subscriptionCoordinator.getCurrentManager().supportsBilling.value, + enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state + plans = plans + ) + ) + } + + /** + SHOW LOADER AT THE START OF THIS, CATER TO LOAD AND ERROR IN THE CHOOSE_HOME_SCREEN, CREATE THE STATE PROPERLY + HERE AND CALCULATE THE PLANS TO SEND AS SUCCESS + **/ + } + } + + private suspend fun getSubscriptionPlans(subType: SubscriptionType): List { + val isActive = subType is SubscriptionType.Active + val currentPlan12Months = isActive && subType.duration == ProSubscriptionDuration.TWELVE_MONTHS + val currentPlan3Months = isActive && subType.duration == ProSubscriptionDuration.THREE_MONTHS + val currentPlan1Month = isActive && subType.duration == ProSubscriptionDuration.ONE_MONTH + + return listOf( + ProPlan( + title = Phrase.from(context.getText(R.string.proPriceTwelveMonths)) + .put(MONTHLY_PRICE_KEY, "$3.99") //todo PRO calculate properly + .format().toString(), + subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) + .put(PRICE_KEY, "$47.99") //todo PRO calculate properly + .format().toString(), + selected = currentPlan12Months || subType !is SubscriptionType.Active, // selected if our active sub is 12 month, or as a default for non pro or renew + currentPlan = currentPlan12Months, + durationType = ProSubscriptionDuration.TWELVE_MONTHS, + badges = buildList { + if(currentPlan12Months){ + add( + ProPlanBadge(context.getString(R.string.currentBilling)) + ) + } + + add( + ProPlanBadge( + "33% Off", //todo PRO calculate properly + if(currentPlan12Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PERCENT_KEY, "33") //todo PRO calculate properly + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + else null + ) + ) + }, + ), + ProPlan( + title = Phrase.from(context.getText(R.string.proPriceThreeMonths)) + .put(MONTHLY_PRICE_KEY, "$4.99") //todo PRO calculate properly + .format().toString(), + subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly)) + .put(PRICE_KEY, "$14.99") //todo PRO calculate properly + .format().toString(), + selected = currentPlan3Months, + currentPlan = currentPlan3Months, + durationType = ProSubscriptionDuration.THREE_MONTHS, + badges = buildList { + if(currentPlan3Months){ + add( + ProPlanBadge(context.getString(R.string.currentBilling)) + ) + } + + add( + ProPlanBadge( + "16% Off", //todo PRO calculate properly + if(currentPlan3Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PERCENT_KEY, "16") //todo PRO calculate properly + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + else null + ) + ) + }, + ), + ProPlan( + title = Phrase.from(context.getText(R.string.proPriceOneMonth)) + .put(MONTHLY_PRICE_KEY, "$5.99") //todo PRO calculate properly + .format().toString(), + subtitle = Phrase.from(context.getText(R.string.proBilledMonthly)) + .put(PRICE_KEY, "$5") //todo PRO calculate properly + .format().toString(), + selected = currentPlan1Month, + currentPlan = currentPlan1Month, + durationType = ProSubscriptionDuration.ONE_MONTH, + badges = if(currentPlan1Month) listOf( + ProPlanBadge(context.getString(R.string.currentBilling)) + ) else emptyList(), + ), + ) } private fun getPlanFromProvider(){ viewModelScope.launch { + val selectedPlan = getSelectedPlan() ?: return@launch + val purchaseStarted = subscriptionCoordinator.getCurrentManager().purchasePlan( - getSelectedPlan().durationType + selectedPlan.durationType ) - if(purchaseStarted.isSuccess) { + val data = choosePlanState.value + if(purchaseStarted.isSuccess && data is State.Success) { _choosePlanState.update { - it.copy(purchaseInProgress = true) + State.Success( + data.value.copy(purchaseInProgress = true) + ) } } } @@ -682,13 +724,18 @@ class ProSettingsViewModel @AssistedInject constructor( data class ChoosePlanState( val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, - val hasBillingCapacity: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession - val hasValidSubscription: Boolean = false, + val hasBillingCapacity: Boolean = false, + val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession val purchaseInProgress: Boolean = false, val plans: List = emptyList(), val enableButton: Boolean = false, ) + data class CancelPlanState( + val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, + val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession + ) + data class RefundPlanState( val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, val isQuickRefund: Boolean = false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt similarity index 100% rename from app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt rename to app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt new file mode 100644 index 0000000000..5085c6003d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.preferences.prosettings.chooseplan + +import android.widget.Toast +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import network.loki.messenger.R +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel +import org.thoughtcrime.securesms.pro.SubscriptionType +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.util.State + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun ChoosePlanHomeScreen( + viewModel: ProSettingsViewModel, + onBack: () -> Unit, +) { + val state by viewModel.choosePlanState.collectAsState() + + when(state){ + is State.Error -> { + // show a toast and go back to pro settings home screen + Toast.makeText(LocalContext.current, R.string.errorGeneric, Toast.LENGTH_LONG).show() + onBack() + } + + is State.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is State.Success -> { + val planData = (state as State.Success).value + + // Option 1. ACTIVE Pro subscription + if(planData.subscriptionType is SubscriptionType.Active) { + val subscription = planData.subscriptionType + + when { + // there is an active subscription but from a different platform or from the + // same platform but a different account + // or we have no billing APIs + subscription.subscriptionDetails.isFromAnotherPlatform() + || !planData.hasValidSubscription + || !planData.hasBillingCapacity -> + ChoosePlanNonOriginating( + subscription = planData.subscriptionType, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // default plan chooser + else -> ChoosePlan( + planData = planData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + } else { // Option 2. Get brand new or Renew plan + when { + // there are no billing options on this device + !planData.hasBillingCapacity -> + ChoosePlanNoBilling( + subscription = planData.subscriptionType, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // default plan chooser + else -> ChoosePlan( + planData = planData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt deleted file mode 100644 index 0e662d612d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.thoughtcrime.securesms.preferences.prosettings.chooseplan - -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel -import org.thoughtcrime.securesms.pro.SubscriptionType - - -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun GetOrRenewPlanScreen( - viewModel: ProSettingsViewModel, - onBack: () -> Unit, -) { - // Renew plan - val planData by viewModel.choosePlanState.collectAsState() - - when { - // there are no billing options on this device - !planData.hasBillingCapacity -> - ChoosePlanNoBilling( - subscription = planData.subscriptionType, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - - // default plan chooser - else -> ChoosePlan( - planData = planData, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - } -} - - diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt deleted file mode 100644 index 6ccfd0edee..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.thoughtcrime.securesms.preferences.prosettings.chooseplan - -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel -import org.thoughtcrime.securesms.pro.SubscriptionType - - -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun UpdatePlanScreen( - viewModel: ProSettingsViewModel, - onBack: () -> Unit, -) { - // Update plan - val planData by viewModel.choosePlanState.collectAsState() - val subscription = planData.subscriptionType as? SubscriptionType.Active - // can't update a plan if the subscription isn't currently active - if(subscription == null){ - onBack() - return - } - - val subscriptionManager = viewModel.getSubscriptionManager() - - when { - // there is an active subscription but from a different platform or from the - // same platform but a different account - // or we have no billing APIs - subscription.subscriptionDetails.isFromAnotherPlatform() - || !planData.hasValidSubscription - || !subscriptionManager.supportsBilling.value -> - ChoosePlanNonOriginating( - subscription = planData.subscriptionType as SubscriptionType.Active, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - - // default plan chooser - else -> ChoosePlan( - planData = planData, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - } -} - - From 760a69364112f9f6ba4d46d9258680b8ea0a9695 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 10:59:02 +1100 Subject: [PATCH 08/15] New debug toggle for quick refunds --- .../utilities/TextSecurePreferences.kt | 12 +++++++ .../securesms/debugmenu/DebugMenu.kt | 10 ++++++ .../securesms/debugmenu/DebugMenuViewModel.kt | 10 ++++++ .../prosettings/ProSettingsViewModel.kt | 32 +++++++++---------- .../PlayStoreSubscriptionManager.kt | 2 ++ 5 files changed, 49 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index d0e556103f..b54c76334c 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -222,6 +222,8 @@ interface TextSecurePreferences { fun setDebugProPlanStatus(status: DebugMenuViewModel.DebugProPlanStatus?) fun getDebugForceNoBilling(): Boolean fun setDebugForceNoBilling(hasBilling: Boolean) + fun getDebugIsWithinQuickRefund(): Boolean + fun setDebugIsWithinQuickRefund(isWithin: Boolean) fun setSubscriptionProvider(provider: String) fun getSubscriptionProvider(): String? @@ -388,6 +390,7 @@ interface TextSecurePreferences { const val DEBUG_SUBSCRIPTION_STATUS = "debug_subscription_status" 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 SUBSCRIPTION_PROVIDER = "session_subscription_provider" const val DEBUG_AVATAR_REUPLOAD = "debug_avatar_reupload" @@ -1799,6 +1802,15 @@ 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 setDebugIsWithinQuickRefund(isWithin: Boolean) { + setBooleanPreference(TextSecurePreferences.DEBUG_WITHIN_QUICK_REFUND, isWithin) + _events.tryEmit(TextSecurePreferences.DEBUG_FORCE_NO_BILLING) + } + override fun getSubscriptionProvider(): String? { return getStringPreference(TextSecurePreferences.SUBSCRIPTION_PROVIDER, null) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index 2d4ff746d8..4db3947687 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -278,6 +278,15 @@ fun DebugMenu( ) } ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( + text = "Is Within Quick Refund Window", + checked = uiState.withinQuickRefund, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.WithinQuickRefund(it)) + } + ) } } @@ -831,6 +840,7 @@ fun PreviewDebugMenu() { selectedDebugProPlanStatus = DebugMenuViewModel.DebugProPlanStatus.NORMAL, debugProPlans = emptyList(), forceNoBilling = false, + withinQuickRefund = true, forceDeterministicEncryption = false, debugAvatarReupload = true, ), 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 bd7b13db69..fb2b6786cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -110,6 +110,7 @@ class DebugMenuViewModel @Inject constructor( .flatMap { it.availablePlans.asSequence().map { plan -> DebugProPlan(it, plan) } } .toList(), forceNoBilling = textSecurePreferences.getDebugForceNoBilling(), + withinQuickRefund = textSecurePreferences.getDebugIsWithinQuickRefund(), availableAltFileServers = TEST_FILE_SERVERS, alternativeFileServer = textSecurePreferences.alternativeFileServer, ) @@ -285,6 +286,13 @@ class DebugMenuViewModel @Inject constructor( } } + is Commands.WithinQuickRefund -> { + textSecurePreferences.setDebugIsWithinQuickRefund(command.set) + _uiState.update { + it.copy(withinQuickRefund = command.set) + } + } + is Commands.ForcePostPro -> { textSecurePreferences.setForcePostPro(command.set) _uiState.update { @@ -471,6 +479,7 @@ class DebugMenuViewModel @Inject constructor( val selectedDebugProPlanStatus: DebugProPlanStatus, val debugProPlans: List, val forceNoBilling: Boolean, + val withinQuickRefund: Boolean, val alternativeFileServer: FileServer? = null, val availableAltFileServers: List = emptyList(), ) @@ -511,6 +520,7 @@ class DebugMenuViewModel @Inject constructor( data class ForceOtherUsersAsPro(val set: Boolean) : Commands() data class ForceIncomingMessagesAsPro(val set: Boolean) : Commands() data class ForceNoBilling(val set: Boolean) : Commands() + data class WithinQuickRefund(val set: Boolean) : Commands() data class ForcePostPro(val set: Boolean) : Commands() data class ForceShortTTl(val set: Boolean) : Commands() data class SetMessageProFeature(val feature: ProStatusManager.MessageProFeature, val set: Boolean) : Commands() 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 a46bf0c7c7..956e69ca6b 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 @@ -36,6 +36,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_SINGULAR_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionState @@ -82,6 +83,7 @@ class ProSettingsViewModel @AssistedInject constructor( val cancelPlanState: StateFlow> = _cancelPlanState init { + Log.w("", "*** VM INIT") // observe subscription status viewModelScope.launch { proStatusManager.subscriptionState.collect { @@ -122,22 +124,6 @@ class ProSettingsViewModel @AssistedInject constructor( } } } - - // Update refund plan state - viewModelScope.launch { - _proSettingsUIState.map { proUIState -> - val subManager = subscriptionCoordinator.getCurrentManager() - RefundPlanState( - subscriptionType = proUIState.subscriptionState.type, - isQuickRefund = subManager.isWithinQuickRefundWindow(), - quickRefundUrl = subManager.quickRefundUrl - ) - } - .distinctUntilChanged() - .collect { - _refundPlanState.update { it } - } - } } private fun generateState(subscriptionState: SubscriptionState){ @@ -277,7 +263,19 @@ class ProSettingsViewModel @AssistedInject constructor( } Commands.GoToRefund -> { - navigateTo(ProSettingsDestination.RefundSubscription) + viewModelScope.launch { + val subManager = subscriptionCoordinator.getCurrentManager() + _refundPlanState.update { + RefundPlanState( + subscriptionType = _proSettingsUIState.value.subscriptionState.type, + isQuickRefund = subManager.isWithinQuickRefundWindow(), + quickRefundUrl = subManager.quickRefundUrl + ) + } + + //todo PRO might need a State here as well... + navigateTo(ProSettingsDestination.RefundSubscription) + } } Commands.GoToCancel -> { diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 2aa8b8d689..4056eb883b 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -258,6 +258,8 @@ class PlayStoreSubscriptionManager @Inject constructor( } override suspend fun isWithinQuickRefundWindow(): Boolean { + if(prefs.getDebugIsWithinQuickRefund()) return true // debug mode + val purchaseTimeMillis = getExistingSubscription()?.purchaseTime ?: return false val now = Instant.now() From 63c3d0c4a068c6f3099f977f6bc092875cf9af0c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 11:14:16 +1100 Subject: [PATCH 09/15] Making sure refund also handle its data within a State --- .../prosettings/BaseStateProScreen.kt | 47 +++++++++ .../prosettings/CancelPlanScreen.kt | 57 ++++------- .../prosettings/ProSettingsViewModel.kt | 41 ++++---- .../prosettings/RefundPlanScreen.kt | 44 +++++---- .../chooseplan/ChoosePlanHomeScreen.kt | 98 +++++++------------ 5 files changed, 149 insertions(+), 138 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt new file mode 100644 index 0000000000..f5a8229612 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.util.State + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseStateProScreen( + state: State, + onBack: () -> Unit, + successContent: @Composable (T) -> Unit +) { + when (state) { + is State.Error -> { + // show a toast and go back to pro settings home screen + Toast.makeText(LocalContext.current, R.string.errorGeneric, Toast.LENGTH_LONG).show() + onBack() + } + + is State.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + ) { + BackAppBar(title = "", onBack = onBack) + + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } + + is State.Success -> successContent(state.value) + } +} \ No newline at end of file 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 b826dfd584..fa3c9ba6a1 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 @@ -45,48 +45,29 @@ fun CancelPlanScreen( ) { val state by viewModel.cancelPlanState.collectAsState() - when(state) { - is State.Error -> { - // show a toast and go back to pro settings home screen - Toast.makeText(LocalContext.current, R.string.errorGeneric, Toast.LENGTH_LONG).show() - onBack() - } - - is State.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - - is State.Success -> { - val planData = (state as State.Success).value - val activePlan = planData.subscriptionType as? SubscriptionType.Active - if (activePlan == null) { - onBack() - return - } + BaseStateProScreen( + state = state, + onBack = onBack + ){ planData -> + val activePlan = planData.subscriptionType - // there are different UI depending on the state - when { - // there is an active subscription but from a different platform or from the - // same platform but a different account - activePlan.subscriptionDetails.isFromAnotherPlatform() - || !planData.hasValidSubscription -> - CancelPlanNonOriginating( - subscriptionDetails = activePlan.subscriptionDetails, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - - // default cancel screen - else -> CancelPlan( + // there are different UI depending on the state + when { + // there is an active subscription but from a different platform or from the + // same platform but a different account + activePlan.subscriptionDetails.isFromAnotherPlatform() + || !planData.hasValidSubscription -> + CancelPlanNonOriginating( + subscriptionDetails = activePlan.subscriptionDetails, sendCommand = viewModel::onCommand, onBack = onBack, ) - } + + // default cancel screen + else -> CancelPlan( + sendCommand = viewModel::onCommand, + onBack = onBack, + ) } } } 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 956e69ca6b..484f944a2a 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 @@ -16,11 +16,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R @@ -76,8 +73,8 @@ class ProSettingsViewModel @AssistedInject constructor( private val _choosePlanState: MutableStateFlow> = MutableStateFlow(State.Loading) val choosePlanState: StateFlow> = _choosePlanState - private val _refundPlanState: MutableStateFlow = MutableStateFlow(RefundPlanState()) - val refundPlanState: StateFlow = _refundPlanState + private val _refundPlanState: MutableStateFlow> = MutableStateFlow(State.Loading) + val refundPlanState: StateFlow> = _refundPlanState private val _cancelPlanState: MutableStateFlow> = MutableStateFlow(State.Loading) val cancelPlanState: StateFlow> = _cancelPlanState @@ -263,22 +260,30 @@ class ProSettingsViewModel @AssistedInject constructor( } Commands.GoToRefund -> { + val sub = _proSettingsUIState.value.subscriptionState.type + if(sub !is SubscriptionType.Active) return + + _refundPlanState.update { State.Loading } + navigateTo(ProSettingsDestination.RefundSubscription) + viewModelScope.launch { val subManager = subscriptionCoordinator.getCurrentManager() _refundPlanState.update { - RefundPlanState( - subscriptionType = _proSettingsUIState.value.subscriptionState.type, - isQuickRefund = subManager.isWithinQuickRefundWindow(), - quickRefundUrl = subManager.quickRefundUrl + State.Success( + RefundPlanState( + subscriptionType = sub, + isQuickRefund = subManager.isWithinQuickRefundWindow(), + quickRefundUrl = subManager.quickRefundUrl + ) ) } - - //todo PRO might need a State here as well... - navigateTo(ProSettingsDestination.RefundSubscription) } } Commands.GoToCancel -> { + val sub = _proSettingsUIState.value.subscriptionState.type + if(sub !is SubscriptionType.Active) return + // calculate state _cancelPlanState.update { State.Loading } navigateTo(ProSettingsDestination.CancelSubscription) @@ -289,7 +294,7 @@ class ProSettingsViewModel @AssistedInject constructor( _cancelPlanState.update { State.Success( CancelPlanState( - subscriptionType = _proSettingsUIState.value.subscriptionState.type, + subscriptionType = sub, hasValidSubscription = hasValidSubscription ) ) @@ -730,14 +735,14 @@ class ProSettingsViewModel @AssistedInject constructor( ) data class CancelPlanState( - val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, - val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession + val subscriptionType: SubscriptionType.Active, + val hasValidSubscription: Boolean, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession ) data class RefundPlanState( - val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, - val isQuickRefund: Boolean = false, - val quickRefundUrl: String? = null + val subscriptionType: SubscriptionType.Active, + val isQuickRefund: Boolean, + val quickRefundUrl: String? ) data class ProStats( 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 e964f36a65..376c41d3ec 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 @@ -44,31 +44,33 @@ fun RefundPlanScreen( viewModel: ProSettingsViewModel, onBack: () -> Unit, ) { - val refundData by viewModel.refundPlanState.collectAsState() - val activePlan = refundData.subscriptionType as? SubscriptionType.Active - if (activePlan == null) { - onBack() - return - } + val state by viewModel.refundPlanState.collectAsState() + + BaseStateProScreen( + state = state, + onBack = onBack + ) { refundData -> + val activePlan = refundData.subscriptionType + + // there are different UI depending on the state + when { + // there is an active subscription but from a different platform + activePlan.subscriptionDetails.isFromAnotherPlatform() -> + RefundPlanNonOriginating( + subscription = activePlan, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) - // there are different UI depending on the state - when { - // there is an active subscription but from a different platform - activePlan.subscriptionDetails.isFromAnotherPlatform() -> - RefundPlanNonOriginating( - subscription = activePlan, + // default refund screen + else -> RefundPlan( + data = activePlan, + isQuickRefund = refundData.isQuickRefund, + quickRefundUrl = refundData.quickRefundUrl, sendCommand = viewModel::onCommand, onBack = onBack, ) - - // default refund screen - else -> RefundPlan( - data = activePlan, - isQuickRefund = refundData.isQuickRefund, - quickRefundUrl = refundData.quickRefundUrl, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt index 5085c6003d..2e5a289666 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt @@ -1,20 +1,12 @@ package org.thoughtcrime.securesms.preferences.prosettings.chooseplan -import android.widget.Toast import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import network.loki.messenger.R +import org.thoughtcrime.securesms.preferences.prosettings.BaseStateProScreen import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel import org.thoughtcrime.securesms.pro.SubscriptionType -import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator -import org.thoughtcrime.securesms.util.State @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -24,66 +16,50 @@ fun ChoosePlanHomeScreen( ) { val state by viewModel.choosePlanState.collectAsState() - when(state){ - is State.Error -> { - // show a toast and go back to pro settings home screen - Toast.makeText(LocalContext.current, R.string.errorGeneric, Toast.LENGTH_LONG).show() - onBack() - } - - is State.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - - is State.Success -> { - val planData = (state as State.Success).value + BaseStateProScreen( + state = state, + onBack = onBack + ) { planData -> + // Option 1. ACTIVE Pro subscription + if(planData.subscriptionType is SubscriptionType.Active) { + val subscription = planData.subscriptionType - // Option 1. ACTIVE Pro subscription - if(planData.subscriptionType is SubscriptionType.Active) { - val subscription = planData.subscriptionType - - when { - // there is an active subscription but from a different platform or from the - // same platform but a different account - // or we have no billing APIs - subscription.subscriptionDetails.isFromAnotherPlatform() - || !planData.hasValidSubscription - || !planData.hasBillingCapacity -> - ChoosePlanNonOriginating( - subscription = planData.subscriptionType, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - - // default plan chooser - else -> ChoosePlan( - planData = planData, + when { + // there is an active subscription but from a different platform or from the + // same platform but a different account + // or we have no billing APIs + subscription.subscriptionDetails.isFromAnotherPlatform() + || !planData.hasValidSubscription + || !planData.hasBillingCapacity -> + ChoosePlanNonOriginating( + subscription = planData.subscriptionType, sendCommand = viewModel::onCommand, onBack = onBack, ) - } - } else { // Option 2. Get brand new or Renew plan - when { - // there are no billing options on this device - !planData.hasBillingCapacity -> - ChoosePlanNoBilling( - subscription = planData.subscriptionType, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - // default plan chooser - else -> ChoosePlan( - planData = planData, + // default plan chooser + else -> ChoosePlan( + planData = planData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + } else { // Option 2. Get brand new or Renew plan + when { + // there are no billing options on this device + !planData.hasBillingCapacity -> + ChoosePlanNoBilling( + subscription = planData.subscriptionType, sendCommand = viewModel::onCommand, onBack = onBack, ) - } + + // default plan chooser + else -> ChoosePlan( + planData = planData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) } } } From e6134d72303e6c3ecce3a317238f521cf186f879 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 13:50:34 +1100 Subject: [PATCH 10/15] Adding price calculation and formatting --- .../prosettings/ProSettingsViewModel.kt | 122 +++++++++++------- .../subscription/NoOpSubscriptionManager.kt | 4 + .../pro/subscription/SubscriptionManager.kt | 12 +- .../securesms/util/CurrencyFormatter.kt | 54 ++++++++ .../PlayStoreSubscriptionManager.kt | 44 ++++++- 5 files changed, 184 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt 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 484f944a2a..ccff4d397f 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 @@ -45,8 +45,10 @@ import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.pro.subscription.expiryFromNow import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.util.CurrencyFormatter import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State +import java.math.BigDecimal @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @@ -558,7 +560,16 @@ class ProSettingsViewModel @AssistedInject constructor( // there is no point in calculating it if the user is pro but without a valid sub // (meaning they got pro from a different google account than the one they are on now val plans = if(subType is SubscriptionType.Active && !hasValidSub) emptyList() - else getSubscriptionPlans(subType) + else { + // attempt to get the prices from the subscription provider + // return early in case of error + try { + getSubscriptionPlans(subType) + } catch (e: Exception){ + _choosePlanState.update { State.Error(e) } + return@launch + } + } _choosePlanState.update { State.Success( @@ -585,81 +596,96 @@ class ProSettingsViewModel @AssistedInject constructor( val currentPlan3Months = isActive && subType.duration == ProSubscriptionDuration.THREE_MONTHS val currentPlan1Month = isActive && subType.duration == ProSubscriptionDuration.ONE_MONTH - return listOf( + // get prices from the subscription provider + val prices = subscriptionCoordinator.getCurrentManager().getSubscriptionPrices() + + val data1Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.ONE_MONTH }) + val data3Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.THREE_MONTHS }) + val data12Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.TWELVE_MONTHS }) + + val baseline = data1Month?.perMonthUnits ?: BigDecimal.ZERO + + val plan12Months = data12Month?.let { ProPlan( title = Phrase.from(context.getText(R.string.proPriceTwelveMonths)) - .put(MONTHLY_PRICE_KEY, "$3.99") //todo PRO calculate properly + .put(MONTHLY_PRICE_KEY, it.perMonthText) .format().toString(), subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) - .put(PRICE_KEY, "$47.99") //todo PRO calculate properly + .put(PRICE_KEY, it.totalText) .format().toString(), selected = currentPlan12Months || subType !is SubscriptionType.Active, // selected if our active sub is 12 month, or as a default for non pro or renew currentPlan = currentPlan12Months, durationType = ProSubscriptionDuration.TWELVE_MONTHS, badges = buildList { - if(currentPlan12Months){ - add( - ProPlanBadge(context.getString(R.string.currentBilling)) - ) - } + if (currentPlan12Months) add(ProPlanBadge(context.getString(R.string.currentBilling))) + discountBadge(baseline = baseline, it.perMonthUnits, showTooltip = currentPlan12Months)?.let(this::add) + } + ) + } - add( - ProPlanBadge( - "33% Off", //todo PRO calculate properly - if(currentPlan12Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(PERCENT_KEY, "33") //todo PRO calculate properly - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - else null - ) - ) - }, - ), + val plan3Months = data3Month?.let { ProPlan( title = Phrase.from(context.getText(R.string.proPriceThreeMonths)) - .put(MONTHLY_PRICE_KEY, "$4.99") //todo PRO calculate properly + .put(MONTHLY_PRICE_KEY, it.perMonthText) .format().toString(), subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly)) - .put(PRICE_KEY, "$14.99") //todo PRO calculate properly + .put(PRICE_KEY, it.totalText) .format().toString(), selected = currentPlan3Months, currentPlan = currentPlan3Months, durationType = ProSubscriptionDuration.THREE_MONTHS, badges = buildList { - if(currentPlan3Months){ - add( - ProPlanBadge(context.getString(R.string.currentBilling)) - ) - } + if (currentPlan3Months) add(ProPlanBadge(context.getString(R.string.currentBilling))) + discountBadge(baseline = baseline, it.perMonthUnits, showTooltip = currentPlan3Months)?.let(this::add) + } + ) + } - add( - ProPlanBadge( - "16% Off", //todo PRO calculate properly - if(currentPlan3Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(PERCENT_KEY, "16") //todo PRO calculate properly - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - else null - ) - ) - }, - ), + val plan1Month = data1Month?.let { ProPlan( title = Phrase.from(context.getText(R.string.proPriceOneMonth)) - .put(MONTHLY_PRICE_KEY, "$5.99") //todo PRO calculate properly + .put(MONTHLY_PRICE_KEY, it.perMonthText) .format().toString(), subtitle = Phrase.from(context.getText(R.string.proBilledMonthly)) - .put(PRICE_KEY, "$5") //todo PRO calculate properly + .put(PRICE_KEY, it.totalText) .format().toString(), selected = currentPlan1Month, currentPlan = currentPlan1Month, durationType = ProSubscriptionDuration.ONE_MONTH, - badges = if(currentPlan1Month) listOf( - ProPlanBadge(context.getString(R.string.currentBilling)) - ) else emptyList(), - ), + badges = if (currentPlan1Month) listOf(ProPlanBadge(context.getString(R.string.currentBilling))) else emptyList() + // no discount on the baseline 1 month... + ) + } + + return listOfNotNull(plan12Months, plan3Months, plan1Month) + } + + private data class PriceDisplayData(val perMonthUnits: BigDecimal, val perMonthText: String, val totalText: String) + + private fun calculatePricesFor(pricing: SubscriptionManager.SubscriptionPricing?): PriceDisplayData? { + if(pricing == null) return null + + val months = CurrencyFormatter.monthsFromIso(pricing.billingPeriodIso) + val perMonthUnits = CurrencyFormatter.perMonthUnitsFloor(pricing.priceAmountMicros, months, pricing.priceCurrencyCode) + val perMonthText = CurrencyFormatter.formatUnits(perMonthUnits, pricing.priceCurrencyCode) + return PriceDisplayData(perMonthUnits, perMonthText, pricing.formattedTotal) + } + + private fun discountBadge(baseline: BigDecimal ,perMonthUnits: BigDecimal, showTooltip: Boolean): ProPlanBadge? { + val pct = CurrencyFormatter.percentOffFloor(baseline, perMonthUnits) + if (pct <= 0) return null + val tooltip = if (showTooltip) + Phrase.from(context.getText(R.string.proDiscountTooltip)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PERCENT_KEY, pct.toString()) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + else null + return ProPlanBadge( + title = Phrase.from(context.getText(R.string.proPercentOff)) + .put(PERCENT_KEY, pct.toString()) + .format().toString(), + tooltip = tooltip ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 9d1e64a615..05375437ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -36,4 +36,8 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override suspend fun isWithinQuickRefundWindow(): Boolean { return false } + + override suspend fun getSubscriptionPrices(): List { + return emptyList() + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index e44d6691d5..4606856cfc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -45,9 +45,19 @@ interface SubscriptionManager: OnAppStartupComponent { */ suspend fun hasValidSubscription(): Boolean + /** + * Gets a list of pricing for the subscriptions + * @throws Exception in case of errors fetching prices + */ + @Throws(Exception::class) + suspend fun getSubscriptionPrices(): List + data class SubscriptionPricing( val subscriptionDuration: ProSubscriptionDuration, - val price: String, + val priceAmountMicros: Long, + val priceCurrencyCode: String, + val billingPeriodIso: String, + val formattedTotal: String, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt new file mode 100644 index 0000000000..1373221711 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.util + +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale + +/** + * Utility for converting and formatting prices + * to correctly localized strings. + * + * - Only supports months/years for ISO 8601 billing periods (PXM, PXY, P1Y6M) - We can add more if needed in the future + */ +object CurrencyFormatter { + + /** Parse only Years/Months: P1M, P3M, P1Y, P1Y6M. (Weeks/Days intentionally ignored.) */ + fun monthsFromIso(iso: String): Int { + val y = Regex("""(\d+)Y""").find(iso)?.groupValues?.get(1)?.toInt() ?: 0 + val m = Regex("""(\d+)M""").find(iso)?.groupValues?.get(1)?.toInt() ?: 0 + return (y * 12 + m).coerceAtLeast(1) + } + + /** Currency fraction digits with sane default. */ + private fun fractionDigits(code: String): Int = + Currency.getInstance(code).defaultFractionDigits.let { if (it >= 0) it else 2 } + + /** PRD rule: (total/months) then **ROUND DOWN** to the currency’s smallest unit. */ + fun perMonthUnitsFloor(totalMicros: Long, months: Int, currencyCode: String): BigDecimal { + val units = BigDecimal(totalMicros).divide(BigDecimal(1_000_000)) // raw units + val perMonth = units.divide(BigDecimal(months), 10, RoundingMode.DOWN) + return perMonth.setScale(fractionDigits(currencyCode), RoundingMode.DOWN) + } + + /** Locale-correct currency formatting (no extra rounding — use the scale already on amount). */ + fun formatUnits(amountUnits: BigDecimal, currencyCode: String, locale: Locale = Locale.getDefault()): String { + val nf = NumberFormat.getCurrencyInstance(locale) + nf.currency = Currency.getInstance(currencyCode) + return nf.format(amountUnits) + } + + /** + * Used to calculate discounts: + * floor(((baseline - plan)/baseline) * 100). Assumes both inputs already floored to fraction. + **/ + fun percentOffFloor(baselinePerMonthUnits: BigDecimal, planPerMonthUnits: BigDecimal): Int { + if (baselinePerMonthUnits <= BigDecimal.ZERO || planPerMonthUnits >= baselinePerMonthUnits) return 0 + val pct = baselinePerMonthUnits.subtract(planPerMonthUnits) + .divide(baselinePerMonthUnits, 6, RoundingMode.DOWN) + .multiply(BigDecimal(100)) + .setScale(0, RoundingMode.DOWN) + return pct.toInt() + } +} \ No newline at end of file diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 4056eb883b..cc2ca99fea 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.pro.subscription import android.app.Application import android.widget.Toast -import androidx.compose.ui.res.stringResource import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams @@ -18,7 +17,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -44,7 +42,6 @@ import java.time.Instant import java.time.temporal.ChronoUnit import javax.inject.Inject import javax.inject.Singleton -import kotlin.time.measureTime /** * The Google Play Store implementation of our subscription manager @@ -272,6 +269,47 @@ class PlayStoreSubscriptionManager @Inject constructor( return now.isBefore(refundDeadline) } + @Throws(Exception::class) + override suspend fun getSubscriptionPrices(): List { + val result = getProductDetails() + check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + "Failed to query product details. Reason: ${result.billingResult}" + } + + val productDetails = result.productDetailsList?.firstOrNull() + ?: run { + Log.w(TAG, "No ProductDetails returned for product id session_pro") + return emptyList() + } + + val offersByBasePlan = productDetails.subscriptionOfferDetails + ?.associateBy { it.basePlanId } + .orEmpty() + + // For each duration we support, find the matching offer by basePlanId + return availablePlans.mapNotNull { duration -> + val offer = offersByBasePlan[duration.id] + if (offer == null) { + Log.w(TAG, "No offer found for basePlanId=${duration.id}") + return@mapNotNull null + } + + val phases = offer.pricingPhases.pricingPhaseList + + val pricing = phases.firstOrNull { + it.recurrenceMode == com.android.billingclient.api.ProductDetails.RecurrenceMode.INFINITE_RECURRING + } ?:return@mapNotNull null // skip if not found + + SubscriptionManager.SubscriptionPricing( + subscriptionDuration = duration, + priceAmountMicros = pricing.priceAmountMicros, + priceCurrencyCode = pricing.priceCurrencyCode, + billingPeriodIso = pricing.billingPeriod, // e.g., P1M, P3M, P1Y + formattedTotal = pricing.formattedPrice // Play-formatted localized total + ) + } + } + companion object { private const val TAG = "PlayStoreSubscriptionManager" } From 556cbd18324fdd0f2d5bd2de1408cd83e82c6e28 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 15:33:23 +1100 Subject: [PATCH 11/15] Formatting total to match --- .../prosettings/ProSettingsViewModel.kt | 9 +++++- .../securesms/util/CurrencyFormatter.kt | 29 +++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) 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 ccff4d397f..7f35456a45 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 @@ -668,7 +668,14 @@ class ProSettingsViewModel @AssistedInject constructor( val months = CurrencyFormatter.monthsFromIso(pricing.billingPeriodIso) val perMonthUnits = CurrencyFormatter.perMonthUnitsFloor(pricing.priceAmountMicros, months, pricing.priceCurrencyCode) val perMonthText = CurrencyFormatter.formatUnits(perMonthUnits, pricing.priceCurrencyCode) - return PriceDisplayData(perMonthUnits, perMonthText, pricing.formattedTotal) + + val totalUnits = CurrencyFormatter.microToBigDecimal(pricing.priceAmountMicros) + val totalText = CurrencyFormatter.formatUnits( + amountUnits = totalUnits, + currencyCode = pricing.priceCurrencyCode + ) + + return PriceDisplayData(perMonthUnits, perMonthText, totalText) } private fun discountBadge(baseline: BigDecimal ,perMonthUnits: BigDecimal, showTooltip: Boolean): ProPlanBadge? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt index 1373221711..6aeacb40e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt @@ -14,25 +14,44 @@ import java.util.Locale */ object CurrencyFormatter { - /** Parse only Years/Months: P1M, P3M, P1Y, P1Y6M. (Weeks/Days intentionally ignored.) */ + /** + * Parse only Years/Months: P1M, P3M, P1Y, P1Y6M. (Weeks/Days intentionally ignored.) + **/ fun monthsFromIso(iso: String): Int { val y = Regex("""(\d+)Y""").find(iso)?.groupValues?.get(1)?.toInt() ?: 0 val m = Regex("""(\d+)M""").find(iso)?.groupValues?.get(1)?.toInt() ?: 0 return (y * 12 + m).coerceAtLeast(1) } - /** Currency fraction digits with sane default. */ + /** + * Currency fraction digits (e.g., USD=2, JPY=0). Default to 2 if unknown. + **/ private fun fractionDigits(code: String): Int = Currency.getInstance(code).defaultFractionDigits.let { if (it >= 0) it else 2 } - /** PRD rule: (total/months) then **ROUND DOWN** to the currency’s smallest unit. */ + /** + * PRD rule: (total/months) then **ROUND DOWN** to the currency’s smallest unit. + **/ fun perMonthUnitsFloor(totalMicros: Long, months: Int, currencyCode: String): BigDecimal { - val units = BigDecimal(totalMicros).divide(BigDecimal(1_000_000)) // raw units + // 1) Convert Play’s micros → currency *units* as a BigDecimal + val units = BigDecimal(totalMicros).divide(BigDecimal(1_000_000)) // e.g., 47_99_0000 → 47.99 + + // 2) Compute the raw monthly price: total / months. + // We keep extra precision (scale=10) and ROUND DOWN to avoid accidental rounding up mid-way. val perMonth = units.divide(BigDecimal(months), 10, RoundingMode.DOWN) + + // 3) Floor to the currency’s smallest unit (fraction digits): + // USD/EUR/AUD → 2 decimals, JPY/KRW → 0 decimals, KWD → 3 decimals, etc. return perMonth.setScale(fractionDigits(currencyCode), RoundingMode.DOWN) } - /** Locale-correct currency formatting (no extra rounding — use the scale already on amount). */ + fun microToBigDecimal(micro: Long): BigDecimal { + return BigDecimal(micro).divide(BigDecimal(1_000_000)) + } + + /** + * Locale-correct currency formatting + **/ fun formatUnits(amountUnits: BigDecimal, currencyCode: String, locale: Locale = Locale.getDefault()): String { val nf = NumberFormat.getCurrencyInstance(locale) nf.currency = Currency.getInstance(currencyCode) From 8d9e34f1600c83cb9a54b983717c9006d7ef192b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 16:09:21 +1100 Subject: [PATCH 12/15] Do not apply debug setting if we are not forcing the user as pro --- .../securesms/pro/subscription/PlayStoreSubscriptionManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index cc2ca99fea..106a65a4f9 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -255,7 +255,7 @@ class PlayStoreSubscriptionManager @Inject constructor( } override suspend fun isWithinQuickRefundWindow(): Boolean { - if(prefs.getDebugIsWithinQuickRefund()) return true // debug mode + if(prefs.getDebugIsWithinQuickRefund() && prefs.forceCurrentUserAsPro()) return true // debug mode val purchaseTimeMillis = getExistingSubscription()?.purchaseTime ?: return false From 85ada7d2d65b6c85add9c7d091a56c5369c35fd2 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 16:14:37 +1100 Subject: [PATCH 13/15] Fixing old component to use crossfade for better transition --- .../org/thoughtcrime/securesms/ui/Components.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 2ad98b9ace..9183d20c93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutLinearInEasing @@ -639,11 +640,15 @@ fun LaunchedEffectAsync(block: suspend CoroutineScope.() -> Unit) { @Composable fun LoadingArcOr(loading: Boolean, content: @Composable () -> Unit) { - AnimatedVisibility(loading) { - SmallCircularProgressIndicator(color = LocalContentColor.current) - } - AnimatedVisibility(!loading) { - content() + Crossfade(loading) { isLoading -> + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (isLoading) { + SmallCircularProgressIndicator(color = LocalContentColor.current) + } else { + content() + } + } + } } From 0e3a987a8708d8f431259435d0d644ca3e3c96d2 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 3 Nov 2025 11:07:20 +1100 Subject: [PATCH 14/15] Removing suffix for QA + PR feedback --- app/build.gradle.kts | 3 +-- .../preferences/prosettings/BaseStateProScreen.kt | 9 +++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 874d9d152f..7ef76a3438 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,13 +181,12 @@ android { matchingFallbacks += "release" signingConfig = signingConfigs.getByName("debug") - applicationIdSuffix = ".$name" devNetDefaultOn(false) enablePermissiveNetworkSecurityConfig(true) setAlternativeAppName("Session QA") - setAuthorityPostfix(".qa") + setAuthorityPostfix("") } create("automaticQa") { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt index f5a8229612..4e6d5ff690 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -26,8 +27,12 @@ fun BaseStateProScreen( when (state) { is State.Error -> { // show a toast and go back to pro settings home screen - Toast.makeText(LocalContext.current, R.string.errorGeneric, Toast.LENGTH_LONG).show() - onBack() + val context = LocalContext.current + + LaunchedEffect(Unit) { + Toast.makeText(context, R.string.errorGeneric, Toast.LENGTH_LONG).show() + onBack() + } } is State.Loading -> { From 2e77128aecafbfe25bed98add87a53809355b80d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 3 Nov 2025 11:18:32 +1100 Subject: [PATCH 15/15] PR feedback --- .../prosettings/BaseStateProScreen.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt index 4e6d5ff690..1aba04d45c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt @@ -24,17 +24,17 @@ fun BaseStateProScreen( onBack: () -> Unit, successContent: @Composable (T) -> Unit ) { - when (state) { - is State.Error -> { + // in the case of an error + val context = LocalContext.current + LaunchedEffect(state) { + if (state is State.Error) { // show a toast and go back to pro settings home screen - val context = LocalContext.current - - LaunchedEffect(Unit) { - Toast.makeText(context, R.string.errorGeneric, Toast.LENGTH_LONG).show() - onBack() - } + Toast.makeText(context, R.string.errorGeneric, Toast.LENGTH_LONG).show() + onBack() } + } + when (state) { is State.Loading -> { Box( modifier = Modifier.fillMaxSize(), @@ -48,5 +48,7 @@ fun BaseStateProScreen( } is State.Success -> successContent(state.value) + + else -> {} } } \ No newline at end of file