From ef7e136420eee80a3ba3891068555af6890779dd Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 20 Nov 2025 16:02:00 +1100 Subject: [PATCH 1/7] Updated Pro logic --- .../preferences/SettingsViewModel.kt | 7 ++---- .../prosettings/ProSettingsHomeScreen.kt | 12 +++------- .../prosettings/ProSettingsViewModel.kt | 24 ++++++++++++++----- .../securesms/pro/ProStatusManager.kt | 6 ++++- .../pro/subscription/SubscriptionManager.kt | 19 +++++++++++---- .../PlayStoreSubscriptionManager.kt | 2 +- gradle/libs.versions.toml | 2 +- 7 files changed, 44 insertions(+), 28 deletions(-) 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 ab9cce4dfc..33150eaecd 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 proDetailsRepository.requestRefresh() } @@ -604,10 +605,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/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 73b74cbb39..28ba226e37 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 @@ -54,13 +54,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 @@ -795,9 +789,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) } ) } 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..fc23a8f4ee 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 @@ -37,6 +37,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.session.libsession.utilities.TextSecurePreferences 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 +63,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 +144,11 @@ 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 + // retry the post purchase code + subscriptionCoordinator.getCurrentManager().onPurchaseSuccessful( + orderId = purchaseEvent.orderId, + paymentId = purchaseEvent.paymentId + ) }, onNegative = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) @@ -283,7 +288,7 @@ class ProSettingsViewModel @AssistedInject constructor( negativeText = context.getString(R.string.helpSupport), positiveStyleDanger = false, showXIcon = true, - onPositive = { refreshSubscriptionData() }, + onPositive = { refreshProDetails() }, onNegative = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) } @@ -365,6 +370,10 @@ class ProSettingsViewModel @AssistedInject constructor( //todo PRO implement } + is Commands.RefeshProDetails -> { + refreshProDetails() + } + is Commands.SelectProPlan -> { val data: ChoosePlanState = (_choosePlanState.value as? State.Success)?.value ?: return @@ -528,7 +537,7 @@ class ProSettingsViewModel @AssistedInject constructor( negativeText = context.getString(R.string.helpSupport), positiveStyleDanger = false, showXIcon = true, - onPositive = { refreshSubscriptionData() }, + onPositive = { refreshProDetails() }, onNegative = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) } @@ -567,8 +576,9 @@ class ProSettingsViewModel @AssistedInject constructor( } } - private fun refreshSubscriptionData(){ - //todo PRO implement properly + private fun refreshProDetails(){ + // refreshes the pro details data + proDetailsRepository.requestRefresh() } private fun getSelectedPlan(): ProPlan? { @@ -786,6 +796,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/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index 8596c696af..7c8f0f2c7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -231,8 +231,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 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..2c89ada9c2 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,24 @@ 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) + delay(3000) + throw PaymentServerException() _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/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" From 8797f5267caee1cfa1f9856baa4e2bd87b7ba049 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 20 Nov 2025 17:07:31 +1100 Subject: [PATCH 2/7] Wiring the pro data properly --- .../securesms/home/HomeViewModel.kt | 11 +++++--- .../prosettings/ProSettingsViewModel.kt | 10 +++++++ .../securesms/pro/ProDetailsRepository.kt | 14 +++++----- .../securesms/pro/ProStatusManager.kt | 26 ++++++++++++++----- .../pro/subscription/SubscriptionManager.kt | 4 +-- 5 files changed, 45 insertions(+), 20 deletions(-) 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/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index fc23a8f4ee..79e33f270c 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 @@ -144,6 +144,16 @@ class ProSettingsViewModel @AssistedInject constructor( positiveStyleDanger = false, showXIcon = true, onPositive = { + // 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, 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 a901b75a62..29aa2224f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.selects.onTimeout import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.pro.api.GetProDetailsRequest import org.thoughtcrime.securesms.pro.api.ProApiExecutor @@ -82,7 +83,7 @@ class ProDetailsRepository @Inject constructor( var retryingAt: Instant? = null if (last != null && last.second.plusSeconds(MIN_UPDATE_INTERVAL_SECONDS) >= Instant.now()) { - Log.d(TAG, "Pro details is fresh enough, skipping fetch") + Log.d(DebugLogGroup.PRO_DATA.label, "Pro details is fresh enough, skipping fetch") // Last update was recent enough, skip fetching emit(LoadState.Loaded(last)) } else { @@ -96,14 +97,14 @@ class ProDetailsRepository @Inject constructor( // Fetch new details try { - Log.d(TAG, "Start fetching Pro details from backend") + Log.d(DebugLogGroup.PRO_DATA.label, "Start fetching Pro details from backend") last = apiExecutor.executeRequest( request = getProDetailsRequestFactory.create(proMasterKey) ).successOrThrow() to Instant.now() db.updateProDetails(last.first, last.second) - Log.d(TAG, "Successfully fetched Pro details from backend") + Log.d(DebugLogGroup.PRO_DATA.label, "Successfully fetched Pro details from backend") emit(LoadState.Loaded(last)) numRetried = 0 } catch (e: Exception) { @@ -113,7 +114,7 @@ class ProDetailsRepository @Inject constructor( // Exponential backoff for retries, capped at 2 minutes val delaySeconds = minOf(10L * (1L shl numRetried), 120L) - Log.e(TAG, "Error fetching Pro details from backend, retrying in ${delaySeconds}s", e) + Log.e(DebugLogGroup.PRO_DATA.label, "Error fetching Pro details from backend, retrying in ${delaySeconds}s", e) retryingAt = Instant.now().plusSeconds(delaySeconds) numRetried++ @@ -124,14 +125,14 @@ class ProDetailsRepository @Inject constructor( // Wait until either a refresh is requested, or it's time to retry select { refreshRequests.onReceiveCatching { - Log.d(TAG, "Manual refresh requested") + Log.d(DebugLogGroup.PRO_DATA.label, "Manual Pro details refresh requested") } if (retryingAt != null) { val delayMillis = Duration.between(Instant.now(), retryingAt).toMillis() onTimeout(delayMillis) { - Log.d(TAG, "Retrying Pro details fetch after delay") + Log.d(DebugLogGroup.PRO_DATA.label, "Retrying Pro details fetch after delay") } } } @@ -145,7 +146,6 @@ class ProDetailsRepository @Inject constructor( } 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 7c8f0f2c7b..b825392f49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart @@ -55,10 +56,12 @@ class ProStatusManager @Inject constructor( private val loginState: LoginStateRepository, private val proDatabase: ProDatabase, private val snodeClock: SnodeClock, + private val proDetailsRepository: ProDetailsRepository, ) : 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() }, @@ -68,19 +71,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 @@ -140,7 +150,7 @@ class ProStatusManager @Inject constructor( }, refreshState = proDataRefreshState, - showProBadge = selfRecipient.shouldShowProBadge, + showProBadge = shouldShowProBadge, ) } @@ -262,6 +272,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 2c89ada9c2..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 @@ -67,9 +67,7 @@ abstract class SubscriptionManager( // we need to tie our purchase with the back end scope.launch { try { - //proStatusManager.addProPayment(orderId, paymentId) - delay(3000) - throw PaymentServerException() + proStatusManager.addProPayment(orderId, paymentId) _purchaseEvents.emit(PurchaseEvent.Success) } catch (e: Exception) { when (e) { From e334f3531117114df6b1c70b2c0261cf22e3edb7 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 21 Nov 2025 10:14:34 +1100 Subject: [PATCH 3/7] Making sure we update the state upon getting to the choose plan, cancel and refund screens regardless of how we get to the screen --- .../securesms/home/HomeDialogs.kt | 6 +- .../prosettings/CancelPlanScreen.kt | 8 + .../prosettings/ProSettingsViewModel.kt | 168 ++++++++++-------- .../prosettings/RefundPlanScreen.kt | 7 + .../chooseplan/ChoosePlanHomeScreen.kt | 7 + .../securesms/pro/ProDetailsRepository.kt | 2 +- .../securesms/pro/ProStatusManager.kt | 31 +--- 7 files changed, 119 insertions(+), 110 deletions(-) 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/preferences/prosettings/CancelPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt index 8e1349854a..e95ea116a9 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,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 @@ -14,6 +15,7 @@ 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.viewmodel.compose.viewModel import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants @@ -37,6 +39,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( 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 79e33f270c..f6aa65317d 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,6 +35,8 @@ 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 @@ -215,6 +217,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) { @@ -316,45 +405,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 -> { @@ -596,56 +654,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 { 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 29aa2224f4..733acd9f6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt @@ -104,7 +104,7 @@ class ProDetailsRepository @Inject constructor( db.updateProDetails(last.first, last.second) - Log.d(DebugLogGroup.PRO_DATA.label, "Successfully fetched Pro details from backend") + Log.d(DebugLogGroup.PRO_DATA.label, "Successfully fetched Pro details from backend: status: ${last.first.status}, auto-renewing: ${last.first.autoRenewing}") emit(LoadState.Loaded(last)) numRetried = 0 } catch (e: Exception) { 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 b825392f49..af3613d395 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -59,6 +59,10 @@ class ProStatusManager @Inject constructor( private val proDetailsRepository: ProDetailsRepository, ) : OnAppStartupComponent { + //todo PRO state does not update after a successful pro purchase once getting back to the pro home + //todo PRO implement post cancel screen handling + + val proDataState: StateFlow = combine( recipientRepository.observeSelf().map { it.shouldShowProBadge }.distinctUntilChanged(), proDetailsRepository.loadState, @@ -202,33 +206,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 */ From daff1d89223264afacae6fdc1e9d9ac7acafd199 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 21 Nov 2025 12:01:43 +1100 Subject: [PATCH 4/7] Handling post cancellation - get back to pro home and scroll back to top --- .../prosettings/BaseProSettingsScreens.kt | 18 +++++-------- .../prosettings/CancelPlanScreen.kt | 18 +++++++++++++ .../prosettings/ProSettingsHomeScreen.kt | 27 +++++++++++++++++++ .../prosettings/ProSettingsNavHost.kt | 24 +++++++++++++++-- .../prosettings/ProSettingsViewModel.kt | 11 ++++++++ .../securesms/pro/ProStatusManager.kt | 2 -- 6 files changed, 84 insertions(+), 16 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 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 e95ea116a9..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 @@ -10,11 +10,17 @@ 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 @@ -81,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, @@ -90,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 28ba226e37..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 @@ -97,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, ) @@ -114,6 +130,7 @@ fun ProSettingsHomeScreen( fun ProSettingsHome( data: ProSettingsViewModel.ProSettingsState, inSheet: Boolean, + listState: LazyListState, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { @@ -126,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 @@ -973,6 +991,7 @@ fun PreviewProSettingsPro( ), inSheet = false, sendCommand = {}, + listState = rememberLazyListState(), onBack = {}, ) } @@ -993,6 +1012,7 @@ fun PreviewProSettingsProLoading( ), ), inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1014,6 +1034,7 @@ fun PreviewProSettingsProError( ), ), inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1035,6 +1056,7 @@ fun PreviewProSettingsExpired( ) ), inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1056,6 +1078,7 @@ fun PreviewProSettingsExpiredInSheet( ) ), inSheet = true, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1077,6 +1100,7 @@ fun PreviewProSettingsExpiredLoading( ) ), inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1098,6 +1122,7 @@ fun PreviewProSettingsExpiredError( ) ), inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1119,6 +1144,7 @@ fun PreviewProSettingsNonPro( ) ), inSheet = false, + listState = rememberLazyListState(), sendCommand = {}, onBack = {}, ) @@ -1140,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 f6aa65317d..e0533cd4d6 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 @@ -442,6 +442,16 @@ class ProSettingsViewModel @AssistedInject constructor( refreshProDetails() } + is Commands.OnUserBackFromCancellation -> { + // refresh details + refreshProDetails() + + // 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 @@ -807,6 +817,7 @@ class ProSettingsViewModel @AssistedInject constructor( object OnPostPlanConfirmation: Commands object OpenCancelSubscriptionPage: Commands + object OnUserBackFromCancellation: Commands data class SetShowProBadge(val show: Boolean): Commands 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 af3613d395..a2162bdc00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -60,8 +60,6 @@ class ProStatusManager @Inject constructor( ) : OnAppStartupComponent { //todo PRO state does not update after a successful pro purchase once getting back to the pro home - //todo PRO implement post cancel screen handling - val proDataState: StateFlow = combine( recipientRepository.observeSelf().map { it.shouldShowProBadge }.distinctUntilChanged(), From 435f2aeab2b429d80c2cb6541c6ea45a60103a4b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 21 Nov 2025 14:51:08 +1100 Subject: [PATCH 5/7] Fixed the collapsibleFooter to allow clicking on the whole row and making sure the ax is setup correctly for it --- .../thoughtcrime/securesms/ui/Components.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 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 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() + } } } ) From a390701c4240fda519b1df8684f8946d29ad1dd1 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 21 Nov 2025 15:00:37 +1100 Subject: [PATCH 6/7] Adding logs that can be viewed in the debug logger --- .../securesms/preferences/SettingsViewModel.kt | 2 +- .../org/thoughtcrime/securesms/pro/ProDetailsRepository.kt | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) 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 1696e89f39..253f3027d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -155,7 +155,7 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy(avatarData = data) } } } - + // refreshes the pro details data viewModelScope.launch { proDetailsRepository.requestRefresh() 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 From 550d58d5a7c5a67a9fcd1d8a6becd23202be2a3b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 21 Nov 2025 15:41:59 +1100 Subject: [PATCH 7/7] Refresh on recover and block further attempts --- .../prosettings/ProSettingsViewModel.kt | 17 +++++++++-------- .../securesms/pro/ProStatusManager.kt | 2 -- 2 files changed, 9 insertions(+), 10 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 e0533cd4d6..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 @@ -179,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 { @@ -387,7 +385,7 @@ class ProSettingsViewModel @AssistedInject constructor( negativeText = context.getString(R.string.helpSupport), positiveStyleDanger = false, showXIcon = true, - onPositive = { refreshProDetails() }, + onPositive = { refreshProDetails(true) }, onNegative = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) } @@ -439,12 +437,12 @@ class ProSettingsViewModel @AssistedInject constructor( } is Commands.RefeshProDetails -> { - refreshProDetails() + refreshProDetails(true) } is Commands.OnUserBackFromCancellation -> { // refresh details - refreshProDetails() + refreshProDetails(true) // send action to handle post cancellation to the navigator viewModelScope.launch { @@ -615,7 +613,7 @@ class ProSettingsViewModel @AssistedInject constructor( negativeText = context.getString(R.string.helpSupport), positiveStyleDanger = false, showXIcon = true, - onPositive = { refreshProDetails() }, + onPositive = { refreshProDetails(true) }, onNegative = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) } @@ -654,9 +652,12 @@ class ProSettingsViewModel @AssistedInject constructor( } } - private fun refreshProDetails(){ + 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() + proDetailsRepository.requestRefresh(force = force) } private fun getSelectedPlan(): ProPlan? { 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 199131e7db..87121d2c06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -75,8 +75,6 @@ class ProStatusManager @Inject constructor( private val configFactory: Lazy, ) : OnAppStartupComponent { - //todo PRO state does not update after a successful pro purchase once getting back to the pro home - val proDataState: StateFlow = combine( recipientRepository.observeSelf().map { it.shouldShowProBadge }.distinctUntilChanged(), proDetailsRepository.loadState,