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 844ba9a8ce..ad321cd6a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -136,11 +136,11 @@ fun HomeDialogs( positiveButtonText = stringResource(R.string.renew), negativeButtonText = stringResource(R.string.cancel), onUpgrade = { - sendCommand(HomeViewModel.Commands.HideExpiredCTADialog) - sendCommand(HomeViewModel.Commands.GotoProSettings(ProSettingsDestination.ChoosePlan)) + sendCommand(HideExpiredCTADialog) + sendCommand(GotoProSettings(ProSettingsDestination.ChoosePlan)) }, onCancel = { - sendCommand(HomeViewModel.Commands.HideExpiredCTADialog) + sendCommand(HideExpiredCTADialog) } ) } 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 a09f43ace0..c444dbad40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -37,6 +37,7 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination import org.thoughtcrime.securesms.pro.ProStatus @@ -172,8 +173,9 @@ class HomeViewModel @Inject constructor( && !prefs.hasSeenProExpiring() ){ val validUntil = subscription.type.validUntil - - if (validUntil.isBefore(now.plus(7, ChronoUnit.DAYS))) { + val show = validUntil.isBefore(now.plus(7, ChronoUnit.DAYS)) + Log.d(DebugLogGroup.PRO_DATA.label, "Home: Pro active but not auto renewing (expiring). Valid until: $validUntil - Should show Expiring CTA? $show") + if (show) { _dialogsState.update { state -> state.copy( proExpiringCTA = ProExpiringCTA( @@ -186,9 +188,12 @@ class HomeViewModel @Inject constructor( else if(subscription.type is ProStatus.Expired && !prefs.hasSeenProExpired()) { val validUntil = subscription.type.expiredAt + val show = now.isBefore(validUntil.plus(30, ChronoUnit.DAYS)) + + Log.d(DebugLogGroup.PRO_DATA.label, "Home: Pro expired. Expired at: $validUntil - Should show Expired CTA? $show") // Check if now is within 30 days after expiry - if (now.isBefore(validUntil.plus(30, ChronoUnit.DAYS))) { + if (show) { _dialogsState.update { state -> state.copy(proExpiredCTA = true) 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 346526d59b..253f3027d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -83,7 +83,7 @@ class SettingsViewModel @Inject constructor( private val inAppReviewManager: InAppReviewManager, private val avatarUploadManager: AvatarUploadManager, private val attachmentProcessor: AttachmentProcessor, - val proDetailsRepository: ProDetailsRepository, + private val proDetailsRepository: ProDetailsRepository, ) : ViewModel() { private val TAG = "SettingsViewModel" @@ -156,6 +156,7 @@ class SettingsViewModel @Inject constructor( } } + // refreshes the pro details data viewModelScope.launch { proDetailsRepository.requestRefresh() } @@ -606,10 +607,6 @@ class SettingsViewModel @Inject constructor( } } - private fun refreshSubscriptionData(){ - //todo PRO implement properly - } - sealed class AvatarDialogState() { object NoAvatar : AvatarDialogState() data class UserAvatar(val data: AvatarUIData) : AvatarDialogState() 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 4785623c69..2e50075736 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 @@ -12,20 +12,16 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing 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.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -39,7 +35,6 @@ 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 @@ -71,13 +66,12 @@ import org.thoughtcrime.securesms.ui.theme.bold fun BaseProSettingsScreen( disabled: Boolean, hideHomeAppBar: Boolean = false, + listState: LazyListState = rememberLazyListState(), onBack: () -> Unit, onHeaderClick: (() -> Unit)? = null, extraHeaderContent: @Composable (() -> Unit)? = null, content: @Composable LazyItemScope.() -> Unit ){ - // We need the app bar to start as transparent and slowly go opaque as we scroll - val lazyListState = rememberLazyListState() // Calculate scroll fraction val density = LocalDensity.current val thresholdPx = remember(density) { with(density) { 28.dp.toPx() } } // amount before the appbar gets fully opaque @@ -86,9 +80,9 @@ fun BaseProSettingsScreen( val rawFraction by remember { derivedStateOf { when { - lazyListState.layoutInfo.totalItemsCount == 0 -> 0f - lazyListState.firstVisibleItemIndex > 0 -> 1f - else -> (lazyListState.firstVisibleItemScrollOffset / thresholdPx).coerceIn(0f, 1f) + listState.layoutInfo.totalItemsCount == 0 -> 0f + listState.firstVisibleItemIndex > 0 -> 1f + else -> (listState.firstVisibleItemScrollOffset / thresholdPx).coerceIn(0f, 1f) } } } @@ -115,7 +109,7 @@ fun BaseProSettingsScreen( modifier = Modifier .fillMaxWidth() .consumeWindowInsets(paddings), - state = lazyListState, + state = listState, contentPadding = PaddingValues( start = LocalDimensions.current.spacing, end = LocalDimensions.current.spacing, 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 8e1349854a..924c9059e9 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 @@ -7,13 +7,21 @@ 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.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +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.tooling.preview.PreviewParameter +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants @@ -37,6 +45,12 @@ fun CancelPlanScreen( viewModel: ProSettingsViewModel, onBack: () -> Unit, ) { + LaunchedEffect(Unit) { + // ensuring we get the latest data here + // since we can deep link to this screen without going through the pro home screen + viewModel.ensureCancelState() + } + val state by viewModel.cancelPlanState.collectAsState() BaseStateProScreen( @@ -73,6 +87,17 @@ fun CancelPlan( onBack: () -> Unit, ) { val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + // to track if the user came back from the cancel screen in the subscriber's page + var waitingForReturn by rememberSaveable { mutableStateOf(false) } + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + if (waitingForReturn) { + waitingForReturn = false + sendCommand(ProSettingsViewModel.Commands.OnUserBackFromCancellation) + } + } BaseCellButtonProSettingsScreen( disabled = true, @@ -82,6 +107,7 @@ fun CancelPlan( .format().toString(), dangerButton = true, onButtonClick = { + waitingForReturn = true sendCommand(OpenCancelSubscriptionPage) }, title = Phrase.from(context.getText(R.string.proCancelSorry)) 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 73b74cbb39..0a5e6dbf62 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 @@ -19,12 +19,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -54,13 +57,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY -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.preferences.prosettings.ProSettingsViewModel.Commands.* import org.thoughtcrime.securesms.pro.ProDataState import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager @@ -103,13 +100,26 @@ import org.thoughtcrime.securesms.util.State fun ProSettingsHomeScreen( viewModel: ProSettingsViewModel, inSheet: Boolean, + shouldScrollToTop: Boolean = false, + onScrollToTopConsumed: () -> Unit = {}, onBack: () -> Unit, ) { val data by viewModel.proSettingsUIState.collectAsState() + val listState = rememberLazyListState() + + // check if we requested to scroll to the top + LaunchedEffect(shouldScrollToTop) { + if (shouldScrollToTop) { + listState.scrollToItem(0) + onScrollToTopConsumed() + } + } + ProSettingsHome( data = data, inSheet = inSheet, + listState = listState, sendCommand = viewModel::onCommand, onBack = onBack, ) @@ -120,6 +130,7 @@ fun ProSettingsHomeScreen( fun ProSettingsHome( data: ProSettingsViewModel.ProSettingsState, inSheet: Boolean, + listState: LazyListState, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { @@ -132,6 +143,7 @@ fun ProSettingsHome( BaseProSettingsScreen( disabled = expiredInMainScreen, hideHomeAppBar = inSheet, + listState = listState, onBack = onBack, onHeaderClick = { // add a click handling if the subscription state is loading or errored @@ -795,9 +807,9 @@ fun ProManage( .format().toString() ), icon = R.drawable.ic_refresh_cw, - qaTag = R.string.qa_pro_settings_action_request_refund, + qaTag = R.string.qa_pro_settings_action_recover_plan, onClick = { - //todo PRO implement + sendCommand(RefeshProDetails) } ) } @@ -979,6 +991,7 @@ fun PreviewProSettingsPro( ), inSheet = false, sendCommand = {}, + listState = rememberLazyListState(), onBack = {}, ) } @@ -999,6 +1012,7 @@ fun PreviewProSettingsProLoading( ), ), inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1020,6 +1034,7 @@ fun PreviewProSettingsProError( ), ), inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1041,6 +1056,7 @@ fun PreviewProSettingsExpired( ) ), inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1062,6 +1078,7 @@ fun PreviewProSettingsExpiredInSheet( ) ), inSheet = true, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1083,6 +1100,7 @@ fun PreviewProSettingsExpiredLoading( ) ), inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1104,6 +1122,7 @@ fun PreviewProSettingsExpiredError( ) ), inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1125,6 +1144,7 @@ fun PreviewProSettingsNonPro( ) ), inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1146,6 +1166,7 @@ fun PreviewProSettingsNonProInSheet( ) ), inSheet = true, + listState = rememberLazyListState(), 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 42dcdce86f..9252199ff7 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 @@ -49,9 +49,11 @@ sealed interface ProSettingsDestination: Parcelable { } enum class ProNavHostCustomActions { - ON_POST_PLAN_CONFIRMATION + ON_POST_PLAN_CONFIRMATION, ON_POST_CANCELLATION } +private const val KEY_SCROLL_TOP = "scrollToTop" + @Serializable object ProSettingsGraph @SuppressLint("RestrictedApi") @@ -93,13 +95,20 @@ fun ProSettingsNavHost( is NavigationAction.PerformCustomAction -> { when(action.data as? ProNavHostCustomActions){ // handle the custom case of dealing with the post "choose plan confirmation"screen - ProNavHostCustomActions.ON_POST_PLAN_CONFIRMATION -> { + ProNavHostCustomActions.ON_POST_PLAN_CONFIRMATION, + ProNavHostCustomActions.ON_POST_CANCELLATION -> { // we get here where we either hit back or hit the "ok" button on the plan confirmation screen // if we are in a sheet we need to close it if (inSheet) { onBack() } // otherwise we should clear the stack and head back to the pro settings home screen else { + // set a flag to make sure the home screen scroll back to the top + runCatching { + navController.getBackStackEntry(Home) + .savedStateHandle[KEY_SCROLL_TOP] = true + } + // try to navigate "back" home is possible val wentBack = navController.popBackStack(route = Home, inclusive = false) @@ -128,9 +137,20 @@ fun ProSettingsNavHost( // Home horizontalSlideComposable { entry -> val viewModel = navController.proGraphViewModel(entry, navigator) + + // check if we have the scroll flag set + val scrollToTop by entry.savedStateHandle + .getStateFlow(KEY_SCROLL_TOP, false) + .collectAsState() + ProSettingsHomeScreen( viewModel = viewModel, inSheet = inSheet, + shouldScrollToTop = scrollToTop, + onScrollToTopConsumed = { + // Reset the flag so it doesn't trigger again on rotation + entry.savedStateHandle["scrollToTop"] = false + }, 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 2a254640cd..a709a1cb6d 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 @@ -35,8 +35,11 @@ import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLA import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_SINGULAR_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProDataState +import org.thoughtcrime.securesms.pro.ProDetailsRepository import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData @@ -62,6 +65,7 @@ class ProSettingsViewModel @AssistedInject constructor( private val subscriptionCoordinator: SubscriptionCoordinator, private val dateUtils: DateUtils, private val prefs: TextSecurePreferences, + private val proDetailsRepository: ProDetailsRepository, ) : ViewModel() { @AssistedFactory @@ -142,8 +146,21 @@ class ProSettingsViewModel @AssistedInject constructor( positiveStyleDanger = false, showXIcon = true, onPositive = { - //todo PRO I shouldn't get the plan from the provider again. I should really only try to redo the backend call - //getPlanFromProvider() // retry getting the plan from provider + // show the loader again + val data = choosePlanState.value + if(data is State.Success) { + _choosePlanState.update { + State.Success( + data.value.copy(purchaseInProgress = true) + ) + } + } + + // retry the post purchase code + subscriptionCoordinator.getCurrentManager().onPurchaseSuccessful( + orderId = purchaseEvent.orderId, + paymentId = purchaseEvent.paymentId + ) }, onNegative = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) @@ -162,8 +179,6 @@ class ProSettingsViewModel @AssistedInject constructor( } private fun generateState(proDataState: ProDataState){ - //todo PRO need to properly calculate this - val subType = proDataState.type _proSettingsUIState.update { @@ -200,6 +215,93 @@ class ProSettingsViewModel @AssistedInject constructor( } } + fun ensureChoosePlanState(){ + // Get the choose plan state ready in loading mode + _choosePlanState.update { State.Loading } + + // while the user is on the page we need to calculate the "choose plan" data + viewModelScope.launch { + val subType = _proSettingsUIState.value.proDataState.type + + // first check if the user has a valid subscription and billing + val hasBillingCapacity = subscriptionCoordinator.getCurrentManager().supportsBilling.value + val hasValidSub = subscriptionCoordinator.getCurrentManager().hasValidSubscription() + + // next get the plans, including their pricing, unless there is no billing + // or the user is pro without a valid subscription + // or the user is pro but non originating + val noPriceNeeded = !hasBillingCapacity + || (subType is ProStatus.Active && !hasValidSub) + || (subType is ProStatus.Active && subType.providerData.isFromAnotherPlatform()) + + val plans = if(noPriceNeeded) emptyList() + else { + // attempt to get the prices from the subscription provider + // return early in case of error + try { + getSubscriptionPlans(subType) + } catch (e: Exception){ + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Error while trying to get subscription plans", e) + _choosePlanState.update { State.Error(e) } + return@launch + } + } + + _choosePlanState.update { + State.Success( + ChoosePlanState( + proStatus = subType, + hasValidSubscription = hasValidSub, + hasBillingCapacity = hasBillingCapacity, + enableButton = subType !is ProStatus.Active.AutoRenewing, // only the auto-renew can have a disabled state + plans = plans + ) + ) + } + } + } + + fun ensureCancelState(){ + val sub = _proSettingsUIState.value.proDataState.type + if(sub !is ProStatus.Active) return + + _cancelPlanState.update { State.Loading } + viewModelScope.launch { + _cancelPlanState.update { State.Loading } + val hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription() + + _cancelPlanState.update { + State.Success( + CancelPlanState( + proStatus = sub, + hasValidSubscription = hasValidSubscription + ) + ) + } + } + } + + fun ensureRefundState(){ + val sub = _proSettingsUIState.value.proDataState.type + if(sub !is ProStatus.Active) return + + _refundPlanState.update { State.Loading } + + viewModelScope.launch { + _refundPlanState.update { + val isQuickRefund = if(prefs.getDebugIsWithinQuickRefund() && prefs.forceCurrentUserAsPro()) true // debug mode + else sub.isWithinQuickRefundWindow() + + State.Success( + RefundPlanState( + proStatus = sub, + isQuickRefund = isQuickRefund, + quickRefundUrl = sub.providerData.refundUrl + ) + ) + } + } + } fun onCommand(command: Commands) { when (command) { @@ -283,7 +385,7 @@ class ProSettingsViewModel @AssistedInject constructor( negativeText = context.getString(R.string.helpSupport), positiveStyleDanger = false, showXIcon = true, - onPositive = { refreshSubscriptionData() }, + onPositive = { refreshProDetails(true) }, onNegative = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) } @@ -301,45 +403,14 @@ class ProSettingsViewModel @AssistedInject constructor( val sub = _proSettingsUIState.value.proDataState.type if(sub !is ProStatus.Active) return - _refundPlanState.update { State.Loading } navigateTo(ProSettingsDestination.RefundSubscription) - - viewModelScope.launch { - _refundPlanState.update { - val isQuickRefund = if(prefs.getDebugIsWithinQuickRefund() && prefs.forceCurrentUserAsPro()) true // debug mode - else sub.isWithinQuickRefundWindow() - - State.Success( - RefundPlanState( - proStatus = sub, - isQuickRefund = isQuickRefund, - quickRefundUrl = sub.providerData.refundUrl - ) - ) - } - } } Commands.GoToCancel -> { val sub = _proSettingsUIState.value.proDataState.type if(sub !is ProStatus.Active) return - // calculate state - _cancelPlanState.update { State.Loading } navigateTo(ProSettingsDestination.CancelSubscription) - - viewModelScope.launch { - val hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription() - - _cancelPlanState.update { - State.Success( - CancelPlanState( - proStatus = sub, - hasValidSubscription = hasValidSubscription - ) - ) - } - } } Commands.OnPostPlanConfirmation -> { @@ -365,6 +436,20 @@ class ProSettingsViewModel @AssistedInject constructor( //todo PRO implement } + is Commands.RefeshProDetails -> { + refreshProDetails(true) + } + + is Commands.OnUserBackFromCancellation -> { + // refresh details + refreshProDetails(true) + + // send action to handle post cancellation to the navigator + viewModelScope.launch { + navigator.sendCustomAction(ProNavHostCustomActions.ON_POST_CANCELLATION) + } + } + is Commands.SelectProPlan -> { val data: ChoosePlanState = (_choosePlanState.value as? State.Success)?.value ?: return @@ -528,7 +613,7 @@ class ProSettingsViewModel @AssistedInject constructor( negativeText = context.getString(R.string.helpSupport), positiveStyleDanger = false, showXIcon = true, - onPositive = { refreshSubscriptionData() }, + onPositive = { refreshProDetails(true) }, onNegative = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) } @@ -567,8 +652,12 @@ class ProSettingsViewModel @AssistedInject constructor( } } - private fun refreshSubscriptionData(){ - //todo PRO implement properly + private fun refreshProDetails(force: Boolean){ + // stop early if we are already refreshing + if(_proSettingsUIState.value.proDataState.refreshState is State.Loading) return + + // refreshes the pro details data + proDetailsRepository.requestRefresh(force = force) } private fun getSelectedPlan(): ProPlan? { @@ -576,56 +665,8 @@ class ProSettingsViewModel @AssistedInject constructor( } 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.proDataState.type - - // first check if the user has a valid subscription and billing - val hasBillingCapacity = subscriptionCoordinator.getCurrentManager().supportsBilling.value - val hasValidSub = subscriptionCoordinator.getCurrentManager().hasValidSubscription() - - // next get the plans, including their pricing, unless there is no billing - // or the user is pro without a valid subscription - // or the user is pro but non originating - val noPriceNeeded = !hasBillingCapacity - || (subType is ProStatus.Active && !hasValidSub) - || (subType is ProStatus.Active && subType.providerData.isFromAnotherPlatform()) - - val plans = if(noPriceNeeded) emptyList() - 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( - ChoosePlanState( - proStatus = subType, - hasValidSubscription = hasValidSub, - hasBillingCapacity = hasBillingCapacity, - enableButton = subType !is ProStatus.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: ProStatus): List { @@ -777,6 +818,7 @@ class ProSettingsViewModel @AssistedInject constructor( object OnPostPlanConfirmation: Commands object OpenCancelSubscriptionPage: Commands + object OnUserBackFromCancellation: Commands data class SetShowProBadge(val show: Boolean): Commands @@ -786,6 +828,8 @@ class ProSettingsViewModel @AssistedInject constructor( data class OnHeaderClicked(val inSheet: Boolean): Commands data object OnProStatsClicked: Commands + + data object RefeshProDetails: Commands } data class ProSettingsState( 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 3738b73461..4e8bc5f36a 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 @@ -7,6 +7,7 @@ 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.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -40,6 +41,12 @@ fun RefundPlanScreen( viewModel: ProSettingsViewModel, onBack: () -> Unit, ) { + LaunchedEffect(Unit) { + // ensuring we get the latest data here + // since we can deep link to this screen without going through the pro home screen + viewModel.ensureRefundState() + } + val state by viewModel.refundPlanState.collectAsState() BaseStateProScreen( 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 4e8d1d3922..135bad2487 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 @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.preferences.prosettings.chooseplan import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import org.thoughtcrime.securesms.preferences.prosettings.BaseStateProScreen @@ -15,6 +16,12 @@ fun ChoosePlanHomeScreen( viewModel: ProSettingsViewModel, onBack: () -> Unit, ) { + LaunchedEffect(Unit) { + // ensuring we get the latest data here + // since we can deep link to this screen without going through the pro home screen + viewModel.ensureChoosePlanState() + } + val state by viewModel.choosePlanState.collectAsState() BaseStateProScreen( diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt index 9b019ed715..7b7d654990 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import org.session.libsession.snode.SnodeClock import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.pro.api.ProDetails import org.thoughtcrime.securesms.pro.db.ProDatabase @@ -59,6 +60,7 @@ class ProDetailsRepository @Inject constructor( WorkInfo.State.RUNNING -> LoadState.Loading(last, waitingForNetwork = false) WorkInfo.State.SUCCEEDED -> { if (last != null) { + Log.d(DebugLogGroup.PRO_DATA.label, "Successfully fetched Pro details from backend") LoadState.Loaded(last) } else { // This should never happen, but just in case... @@ -81,17 +83,16 @@ class ProDetailsRepository @Inject constructor( if (!force && (currentState is LoadState.Loading || currentState is LoadState.Loaded) && currentState.lastUpdated?.second?.plusSeconds(MIN_UPDATE_INTERVAL_SECONDS) ?.isBefore(snodeClock.currentTime()) == true) { - Log.d(TAG, "Pro details are fresh enough, skipping refresh") + Log.d(DebugLogGroup.PRO_DATA.label, "Pro details are fresh enough, skipping refresh") return } - Log.d(TAG, "Scheduling fetch of Pro details from server") + Log.d(DebugLogGroup.PRO_DATA.label, "Scheduling fetch of Pro details from server") FetchProDetailsWorker.schedule(application, ExistingWorkPolicy.KEEP) } companion object { - private const val TAG = "ProDetailsRepository" private const val MIN_UPDATE_INTERVAL_SECONDS = 120L } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index 1a84c4b42b..87121d2c06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -76,7 +76,8 @@ class ProStatusManager @Inject constructor( ) : OnAppStartupComponent { val proDataState: StateFlow = combine( - recipientRepository.observeSelf(), + recipientRepository.observeSelf().map { it.shouldShowProBadge }.distinctUntilChanged(), + proDetailsRepository.loadState, (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS } as Flow<*>) .onStart { emit(Unit) } .map { prefs.getDebugSubscriptionType() }, @@ -86,19 +87,26 @@ class ProStatusManager @Inject constructor( (TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_CURRENT_USER_PRO } as Flow<*>) .onStart { emit(Unit) } .map { prefs.forceCurrentUserAsPro() }, - ){ selfRecipient, debugSubscription, debugProPlanStatus, forceCurrentUserAsPro -> + ){ shouldShowProBadge, proDetailsState, debugSubscription, debugProPlanStatus, forceCurrentUserAsPro -> val proDataRefreshState = when(debugProPlanStatus){ DebugMenuViewModel.DebugProPlanStatus.LOADING -> State.Loading DebugMenuViewModel.DebugProPlanStatus.ERROR -> State.Error(Exception()) - else -> State.Success(Unit) + else -> { + // calculate the real refresh state here + when(proDetailsState){ + is ProDetailsRepository.LoadState.Loading -> State.Loading + is ProDetailsRepository.LoadState.Error -> State.Error(Exception()) + else -> State.Success(Unit) + } + } } if(!forceCurrentUserAsPro){ Log.d(DebugLogGroup.PRO_DATA.label, "ProStatusManager: Getting REAL Pro data state") - //todo PRO this is where we should get the real state + ProDataState( - type = ProStatus.NeverSubscribed, - showProBadge = selfRecipient.shouldShowProBadge, + type = proDetailsState.lastUpdated?.first?.toProStatus() ?: ProStatus.NeverSubscribed, + showProBadge = shouldShowProBadge, refreshState = proDataRefreshState ) }// debug data @@ -158,7 +166,7 @@ class ProStatusManager @Inject constructor( }, refreshState = proDataRefreshState, - showProBadge = selfRecipient.shouldShowProBadge, + showProBadge = shouldShowProBadge, ) } @@ -324,33 +332,6 @@ class ProStatusManager @Inject constructor( return if (isPro) Int.MAX_VALUE else MAX_PIN_REGULAR } - /** - * This will calculate the pro features of an outgoing message - */ - fun calculateMessageProFeatures(isPro: Boolean, shouldShowProBadge: Boolean, message: String) { -// if (!isPro){ -// return emptyList() -// } -// -// val features = mutableListOf() -// -// // check for pro badge display -// if (shouldShowProBadge){ -// features.add(MessageProFeature.ProBadge) -// } -// -// // check for "long message" feature -// if(message.length > MAX_CHARACTER_REGULAR){ -// features.add(MessageProFeature.LongMessage) -// } - - // check is the user has an animated avatar - //todo PRO check for animated avatar here and add appropriate feature - - -// return features - } - /** * This will get the list of Pro features from an incoming message */ @@ -363,8 +344,12 @@ class ProStatusManager @Inject constructor( return message.proFeatures } + /** + * To be called once a subscription has successfully gone through a provider. + * This will link that payment to our back end. + */ @OptIn(ExperimentalCoroutinesApi::class) - suspend fun appProPaymentToBackend(orderId: String, paymentId: String) { + suspend fun addProPayment(orderId: String, paymentId: String) { // max 3 attempts as per PRD val maxAttempts = 3 @@ -390,6 +375,8 @@ class ProStatusManager @Inject constructor( Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' successful") // Payment was successfully claimed - save it to the database proDatabase.updateCurrentProProof(paymentResponse.data) + // refresh the pro details + proDetailsRepository.requestRefresh() } is ProApiResponse.Failure -> { 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 ea0d54469d..1e26ff0f88 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 @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.pro.subscription import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -10,7 +11,6 @@ import kotlinx.coroutines.launch import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.pro.ProStatusManager -import java.time.Instant /** * Represents the implementation details of a given subscription provider @@ -33,7 +33,7 @@ abstract class SubscriptionManager( data object Cancelled : PurchaseEvent sealed interface Failed : PurchaseEvent { data class GenericError(val errorMessage: String? = null): Failed - data object ServerError : Failed + data class ServerError(val orderId: String, val paymentId: String) : Failed } } @@ -63,15 +63,22 @@ abstract class SubscriptionManager( /** * Function called when a purchased has been made successfully from the subscription api */ - protected fun onPurchaseSuccessful(orderId: String, paymentId: String){ + fun onPurchaseSuccessful(orderId: String, paymentId: String){ // we need to tie our purchase with the back end scope.launch { try { - proStatusManager.appProPaymentToBackend(orderId, paymentId) + proStatusManager.addProPayment(orderId, paymentId) _purchaseEvents.emit(PurchaseEvent.Success) } catch (e: Exception) { when (e) { - is PaymentServerException -> _purchaseEvents.emit(PurchaseEvent.Failed.ServerError) + is PaymentServerException -> { + _purchaseEvents.emit( + PurchaseEvent.Failed.ServerError( + orderId = orderId, + paymentId = paymentId + ) + ) + } else -> _purchaseEvents.emit(PurchaseEvent.Failed.GenericError()) } } 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 26239d97db..ebab217eef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -105,6 +105,8 @@ import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle @@ -939,9 +941,12 @@ private fun CollapsibleFooterActions( val annotatedTitle = remember(titleText) { AnnotatedString(titleText) } ActionRowItem( - modifier = Modifier.background(LocalColors.current.backgroundTertiary), + modifier = Modifier.background(LocalColors.current.backgroundTertiary) + .semantics(mergeDescendants = true){}, title = annotatedTitle, - onClick = {}, + onClick = { + item.onClick() + }, qaTag = R.string.qa_collapsing_footer_action, endContent = { Box( @@ -952,11 +957,16 @@ private fun CollapsibleFooterActions( else Modifier.width(equalWidthDp) ) ) { + val buttonModifier = if (single) Modifier else Modifier.fillMaxWidth() SlimFillButtonRect( - modifier = if (single) Modifier else Modifier.fillMaxWidth(), + modifier = buttonModifier + .qaTag(stringResource(R.string.qa_collapsing_footer_action)+"_"+item.buttonLabel.string().lowercase()) + .clearAndSetSemantics{}, text = item.buttonLabel.string(), color = item.buttonColor - ) { item.onClick() } + ) { + item.onClick() + } } } ) 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 89f202b9f2..e1d0e447d6 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 @@ -251,7 +251,7 @@ class PlayStoreSubscriptionManager @Inject constructor( // Return the first active subscription result.purchasesList.firstOrNull { - it.purchaseState == Purchase.PurchaseState.PURCHASED //todo PRO Should we also OR PENDING here? + it.purchaseState == Purchase.PurchaseState.PURCHASED } } catch (e: Exception) { Log.e(DebugLogGroup.PRO_SUBSCRIPTION.label, "Error querying existing subscription", e) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6cb0f65f91..83f8de1035 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ assertjCoreVersion = "3.27.6" biometricVersion = "1.1.0" cameraCamera2Version = "1.5.1" cardviewVersion = "1.0.0" -composeBomVersion = "2025.11.00" +composeBomVersion = "2025.11.01" conscryptAndroidVersion = "2.5.3" conscryptJavaVersion = "2.5.2" constraintlayoutVersion = "2.2.1"