From f733aefc18615dcc973888ac1472eaaad009f322 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 23 Oct 2025 16:11:53 +1100 Subject: [PATCH 01/14] Making the pro setting app bar react to scroll by making its bg opaque --- .../prosettings/BaseProSettingsScreens.kt | 79 ++++++++++++------- 1 file changed, 52 insertions(+), 27 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 ec7c688348..9414539742 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -1,12 +1,14 @@ package org.thoughtcrime.securesms.preferences.prosettings import androidx.annotation.DrawableRes +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -20,31 +22,35 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +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.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue 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 import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import network.loki.messenger.R import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.DialogBg import org.thoughtcrime.securesms.ui.SessionProSettingsHeader import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.DangerFillButtonRect import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.components.inlineContentMap import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -52,9 +58,6 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.bold -import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.DialogBg -import org.thoughtcrime.securesms.ui.components.inlineContentMap /** * Base structure used in most Pro Settings screen @@ -69,42 +72,64 @@ fun BaseProSettingsScreen( extraHeaderContent: @Composable (() -> Unit)? = null, content: @Composable () -> 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 { with(density) { 28.dp.toPx() } } // amount before the appbar gets fully opaque + + // raw fraction 0..1 derived from scrolling + val rawFraction by remember { + derivedStateOf { + when { + lazyListState.layoutInfo.totalItemsCount == 0 -> 0f + lazyListState.firstVisibleItemIndex > 0 -> 1f + else -> (lazyListState.firstVisibleItemScrollOffset / thresholdPx).coerceIn(0f, 1f) + } + } + } + + // easing + smoothing of fraction + val easedFraction = remember(rawFraction) { + FastOutSlowInEasing.transform(rawFraction) + } + + // setting the appbar's bg alpha based on scroll + val backgroundColor = LocalColors.current.background.copy(alpha = easedFraction) + Scaffold( topBar = if(!hideHomeAppBar){{ BackAppBar( title = "", - backgroundColor = Color.Transparent, + backgroundColor = backgroundColor, onBack = onBack, ) }} else {{}}, contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { paddings -> - Column( + LazyColumn( modifier = Modifier .fillMaxSize() - .padding(top = - (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) - .coerceAtLeast(0.dp)) .consumeWindowInsets(paddings) - .padding( - horizontal = LocalDimensions.current.spacing, - ) - .verticalScroll(rememberScrollState()), + .padding(horizontal = LocalDimensions.current.spacing), + state = lazyListState, + contentPadding = PaddingValues( + top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) + .coerceAtLeast(0.dp) + 46.dp, + bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing + ), horizontalAlignment = CenterHorizontally ) { - Spacer(Modifier.height(46.dp)) - - SessionProSettingsHeader( - disabled = disabled, - onClick = onHeaderClick, - extraContent = extraHeaderContent - ) - - content() + item { + SessionProSettingsHeader( + disabled = disabled, + onClick = onHeaderClick, + extraContent = extraHeaderContent + ) + } - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + item { content() } } } } From b93c3129381e79475ce5bda14fed68adaec72bf9 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 23 Oct 2025 17:07:58 +1100 Subject: [PATCH 02/14] Fixing up the activity observer --- .../securesms/util/CurrentActivityObserver.kt | 8 ++++---- .../securesms/reviews/PlayStoreReviewManager.kt | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt index 3408057bd7..b4006d9fda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt @@ -25,18 +25,18 @@ class CurrentActivityObserver @Inject constructor( init { application.registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} - override fun onActivityStarted(activity: Activity) { + override fun onActivityStarted(activity: Activity) {} + override fun onActivityResumed(activity: Activity) { _currentActivity.value = activity Log.d("CurrentActivityObserver", "Current activity set to: ${activity.javaClass.simpleName}") } - override fun onActivityResumed(activity: Activity) {} - override fun onActivityPaused(activity: Activity) {} - override fun onActivityStopped(activity: Activity) { + override fun onActivityPaused(activity: Activity) { if (_currentActivity.value === activity) { _currentActivity.value = null Log.d("CurrentActivityObserver", "Current activity set to null") } } + override fun onActivityStopped(activity: Activity) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} override fun onActivityDestroyed(activity: Activity) {} }) diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/reviews/PlayStoreReviewManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/reviews/PlayStoreReviewManager.kt index a28a203158..41d5f79e06 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/reviews/PlayStoreReviewManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/reviews/PlayStoreReviewManager.kt @@ -38,7 +38,7 @@ class PlayStoreReviewManager @Inject constructor( manager.launchReview(activity, info) val hasLaunchedSomething = withTimeoutOrNull(500.milliseconds) { - currentActivityObserver.currentActivity.first { it != requestedOnActivity } + currentActivityObserver.currentActivity.first { it != null && it != requestedOnActivity } } != null require(hasLaunchedSomething) { From ab2d7b2ec5aa5c631287e3f98f6c33fabc07a232 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 24 Oct 2025 09:29:44 +1100 Subject: [PATCH 03/14] Only listen to the state within the lifecycle --- .../main/java/org/thoughtcrime/securesms/home/HomeActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 33f91e5a8f..2bd77b727a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -22,6 +22,7 @@ import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager @@ -290,7 +291,7 @@ class HomeActivity : ScreenLockActionBarActivity(), binding.dialogs.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setThemedContent { - val dialogsState by homeViewModel.dialogsState.collectAsState() + val dialogsState by homeViewModel.dialogsState.collectAsStateWithLifecycle() HomeDialogs( dialogsState = dialogsState, sendCommand = homeViewModel::onCommand From 2d2862b7645e089aabd5af8d953e66db9d0ae93e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 24 Oct 2025 09:32:03 +1100 Subject: [PATCH 04/14] Mark CTA as seen on dismiss --- .../java/org/thoughtcrime/securesms/home/HomeViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 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 6906b3c7ed..c969576b01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -170,7 +170,6 @@ class HomeViewModel @Inject constructor( val validUntil = subscription.type.proStatus.validUntil ?: return@collect if (validUntil.isBefore(now.plus(7, ChronoUnit.DAYS))) { - prefs.setHasSeenProExpiring() _dialogsState.update { state -> state.copy( proExpiringCTA = ProExpiringCTA( @@ -188,7 +187,7 @@ class HomeViewModel @Inject constructor( // Check if now is within 30 days after expiry if (now.isBefore(validUntil.plus(30, ChronoUnit.DAYS))) { - prefs.setHasSeenProExpired() + _dialogsState.update { state -> state.copy(proExpiredCTA = true) } @@ -299,10 +298,12 @@ class HomeViewModel @Inject constructor( } is Commands.HideExpiringCTADialog -> { + prefs.setHasSeenProExpiring() _dialogsState.update { it.copy(proExpiringCTA = null) } } is Commands.HideExpiredCTADialog -> { + prefs.setHasSeenProExpired() _dialogsState.update { it.copy(proExpiredCTA = false) } } From 08dcc04637e26facf2d260170ed88a251f2b3713 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 11:56:48 +1100 Subject: [PATCH 05/14] Loading state in choose plan + store data handling --- app/build.gradle.kts | 3 +- .../prosettings/BaseProSettingsScreens.kt | 87 ++++++++++++--- .../prosettings/ProSettingsViewModel.kt | 30 +++++- .../chooseplan/ChoosePlanScreen.kt | 1 + .../subscription/NoOpSubscriptionManager.kt | 9 +- .../pro/subscription/SubscriptionManager.kt | 14 ++- .../components/CircularProgressIndicator.kt | 12 ++- .../PlayStoreSubscriptionManager.kt | 101 +++++++++++++++--- 8 files changed, 218 insertions(+), 39 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d769563e80..da96b2db71 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -203,11 +203,10 @@ android { enableUnitTestCoverage = false signingConfig = signingConfigs.getByName("debug") - applicationIdSuffix = ".${name}" enablePermissiveNetworkSecurityConfig(true) devNetDefaultOn(false) setAlternativeAppName("Session Debug") - setAuthorityPostfix(".debug") + setAuthorityPostfix("") } } 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 9414539742..661dc14976 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 @@ -33,21 +33,33 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.consumeAllChanges +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.AnimateFade import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogBg import org.thoughtcrime.securesms.ui.SessionProSettingsHeader import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator import org.thoughtcrime.securesms.ui.components.DangerFillButtonRect import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.components.inlineContentMap @@ -57,6 +69,7 @@ import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.blackAlpha40 import org.thoughtcrime.securesms.ui.theme.bold /** @@ -66,6 +79,7 @@ import org.thoughtcrime.securesms.ui.theme.bold @Composable fun BaseProSettingsScreen( disabled: Boolean, + loading: Boolean = false, hideHomeAppBar: Boolean = false, onBack: () -> Unit, onHeaderClick: (() -> Unit)? = null, @@ -108,28 +122,63 @@ fun BaseProSettingsScreen( contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { paddings -> - LazyColumn( + Box( modifier = Modifier .fillMaxSize() - .consumeWindowInsets(paddings) - .padding(horizontal = LocalDimensions.current.spacing), - state = lazyListState, - contentPadding = PaddingValues( - top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) - .coerceAtLeast(0.dp) + 46.dp, - bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing - ), - horizontalAlignment = CenterHorizontally ) { - item { - SessionProSettingsHeader( - disabled = disabled, - onClick = onHeaderClick, - extraContent = extraHeaderContent - ) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(paddings) + .padding(horizontal = LocalDimensions.current.spacing), + state = lazyListState, + contentPadding = PaddingValues( + top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) + .coerceAtLeast(0.dp) + 46.dp, + bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing + ), + horizontalAlignment = CenterHorizontally + ) { + item { + SessionProSettingsHeader( + disabled = disabled, + onClick = onHeaderClick, + extraContent = extraHeaderContent + ) + } + + item { content() } } + } - item { content() } + AnimateFade(loading) { + val loadingLabel = stringResource(R.string.loading) + Box( + modifier = Modifier + .fillMaxSize() + // dim the background so it's visually obvious it's blocked (optional) + .background(blackAlpha40) + // Intercept click events so that when the loading is on we can't click beneath + .pointerInput(loading) { + if (loading) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + event.changes.forEach { it.consume() } + } + } + } + } + // Provide proper a11y semantics: announce an indeterminate progress. + .semantics(mergeDescendants = true) { + progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate + contentDescription = loadingLabel + liveRegion = LiveRegionMode.Polite + }, + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } } } } @@ -146,10 +195,12 @@ fun BaseCellButtonProSettingsScreen( dangerButton: Boolean, onButtonClick: () -> Unit, title: CharSequence? = null, + loading: Boolean = false, content: @Composable () -> Unit ) { BaseProSettingsScreen( disabled = disabled, + loading = loading, onBack = onBack, ) { Spacer(Modifier.height(LocalDimensions.current.spacing)) @@ -235,12 +286,14 @@ fun BaseNonOriginatingProSettingsScreen( headerTitle: CharSequence?, contentTitle: String?, contentDescription: CharSequence?, + loading: Boolean = false, contentClick: (() -> Unit)? = null, linkCellsInfo: String?, linkCells: List = emptyList(), ) { BaseCellButtonProSettingsScreen( disabled = disabled, + loading = loading, onBack = onBack, buttonText = buttonText, dangerButton = dangerButton, 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 982a7afb3b..af27719d47 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 @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.content.Context import android.content.Intent import android.icu.util.MeasureUnit +import android.widget.Toast import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -83,6 +84,27 @@ class ProSettingsViewModel @AssistedInject constructor( } } + // observe purchase events + viewModelScope.launch { + subscriptionCoordinator.getCurrentManager().purchaseEvents.collect { purchaseEvent -> + _choosePlanState.update { it.copy(loading = false) } + + when(purchaseEvent){ + is SubscriptionManager.PurchaseEvent.Success -> { + navigator.navigate(destination = ProSettingsDestination.PlanConfirmation) + } + + is SubscriptionManager.PurchaseEvent.Failed -> { + Toast.makeText( + context, + purchaseEvent.errorMessage ?: context.getString(R.string.errorGeneric), + Toast.LENGTH_SHORT + ).show() + } + } + } + } + // Update choosePlanState whenever proSettingsUIState changes viewModelScope.launch { _proSettingsUIState @@ -197,7 +219,7 @@ class ProSettingsViewModel @AssistedInject constructor( } } - private fun generateState(subscriptionState: SubscriptionState){ + private suspend fun generateState(subscriptionState: SubscriptionState){ //todo PRO need to properly calculate this val subType = subscriptionState.type @@ -389,7 +411,6 @@ class ProSettingsViewModel @AssistedInject constructor( Commands.GetProPlan -> { val currentSubscription = _proSettingsUIState.value.subscriptionState.type - if(currentSubscription is SubscriptionType.Active){ val newSubscriptionExpiryString = getSelectedPlan().durationType.expiryFromNow() @@ -555,6 +576,10 @@ class ProSettingsViewModel @AssistedInject constructor( } private fun getPlanFromProvider(){ + _choosePlanState.update { + it.copy(loading = true) + } + subscriptionCoordinator.getCurrentManager().purchasePlan( getSelectedPlan().durationType ) @@ -609,6 +634,7 @@ class ProSettingsViewModel @AssistedInject constructor( val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, val hasBillingCapacity: Boolean = false, val hasValidSubscription: Boolean = false, + val loading: Boolean = false, val plans: List = emptyList(), val enableButton: Boolean = false, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index 7681c84c08..8e3140e21a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt @@ -92,6 +92,7 @@ fun ChoosePlan( ) { BaseProSettingsScreen( disabled = false, + loading = planData.loading, onBack = onBack, ) { // Keeps track of the badge height dynamically so we can adjust the padding accordingly diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 91a7c8877f..91305b858e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -1,5 +1,10 @@ package org.thoughtcrime.securesms.pro.subscription +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import javax.inject.Inject /** @@ -20,7 +25,9 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val availablePlans: List get() = emptyList() - override fun hasValidSubscription(productId: String): Boolean { + override val purchaseEvents: SharedFlow = MutableSharedFlow() + + override suspend fun hasValidSubscription(productId: String): Boolean { return false } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index ab32095926..49293eda1e 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 @@ -1,5 +1,9 @@ package org.thoughtcrime.securesms.pro.subscription +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import java.time.Instant @@ -20,6 +24,14 @@ interface SubscriptionManager: OnAppStartupComponent { val availablePlans: List + sealed interface PurchaseEvent { + data object Success : PurchaseEvent + data class Failed(val errorMessage: String? = null) : PurchaseEvent + } + + // purchase events + val purchaseEvents: SharedFlow + fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) /** @@ -32,6 +44,6 @@ interface SubscriptionManager: OnAppStartupComponent { /** * Checks whether there is a valid subscription for the given product id for the current user within this subscriber's billing API */ - fun hasValidSubscription(productId: String): Boolean + suspend fun hasValidSubscription(productId: String): Boolean } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt index 5fa28c633a..8e9ea6b2c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt @@ -5,7 +5,10 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.contentDescription @Composable fun CircularProgressIndicator( @@ -13,7 +16,8 @@ fun CircularProgressIndicator( color: Color = LocalContentColor.current ) { androidx.compose.material3.CircularProgressIndicator( - modifier = modifier.size(40.dp), + modifier = modifier.size(40.dp) + .contentDescription(stringResource(R.string.loading)), color = color ) } @@ -24,7 +28,8 @@ fun SmallCircularProgressIndicator( color: Color = LocalContentColor.current ) { androidx.compose.material3.CircularProgressIndicator( - modifier = modifier.size(20.dp), + modifier = modifier.size(20.dp) + .contentDescription(stringResource(R.string.loading)), color = color, strokeWidth = 2.dp ) @@ -36,7 +41,8 @@ fun ExtraSmallCircularProgressIndicator( color: Color = LocalContentColor.current ) { androidx.compose.material3.CircularProgressIndicator( - modifier = modifier.size(16.dp), + modifier = modifier.size(16.dp) + .contentDescription(stringResource(R.string.loading)), color = color, strokeWidth = 2.dp ) 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 54e7bd96c1..777628e13d 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 @@ -7,16 +7,25 @@ import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingResult import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.Purchase import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.queryProductDetails +import com.android.billingclient.api.queryPurchasesAsync import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import org.thoughtcrime.securesms.util.CurrentActivityObserver import java.time.Instant import javax.inject.Inject @@ -41,10 +50,32 @@ class PlayStoreSubscriptionManager @Inject constructor( override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" + private val _purchaseEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val purchaseEvents: SharedFlow = _purchaseEvents.asSharedFlow() + private val billingClient by lazy { BillingClient.newBuilder(application) .setListener { result, purchases -> Log.d(TAG, "onPurchasesUpdated: $result, $purchases") + if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + for (purchase in purchases) { + scope.launch { + // signal that purchase was completed + + //todo PRO send confirmation to libsession + _purchaseEvents.emit(PurchaseEvent.Success) + } + } + } else if (result.responseCode != BillingClient.BillingResponseCode.USER_CANCELED) { + Log.w(TAG, "Purchase failed or cancelled: $result") + scope.launch { + _purchaseEvents.emit(PurchaseEvent.Failed()) + } + } } .enableAutoServiceReconnection() .enablePendingPurchases( @@ -94,23 +125,43 @@ class PlayStoreSubscriptionManager @Inject constructor( "Unable to find a plan with id $planId" } - val billingResult = billingClient.launchBillingFlow( - activity, BillingFlowParams.newBuilder() - .setProductDetailsParamsList( - listOf( - BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(productDetails) - .setOfferToken(offerDetails.offerToken) - .build() - ) + // Check for existing subscription + val existingPurchase = getExistingSubscription() + + val billingFlowParamsBuilder = BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerDetails.offerToken) + .build() ) - .build() + ) + + // If user has an existing subscription, configure upgrade/downgrade + if (existingPurchase != null) { + Log.d(TAG, "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION") + + billingFlowParamsBuilder.setSubscriptionUpdateParams( + BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOldPurchaseToken(existingPurchase.purchaseToken) + // WITHOUT_PRORATION ensures new plan only bills when existing plan expires/renews + // This applies whether the subscription is auto-renewing or canceled + .setSubscriptionReplacementMode( + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION + ) + .build() + ) + } + + val billingResult = billingClient.launchBillingFlow( + activity, + billingFlowParamsBuilder.build() ) check(billingResult.responseCode == BillingClient.BillingResponseCode.OK) { "Unable to launch the billing flow. Reason: ${billingResult.debugMessage}" } - } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -144,8 +195,32 @@ class PlayStoreSubscriptionManager @Inject constructor( }) } - override fun hasValidSubscription(productId: String): Boolean { - return true //todo PRO implement properly - we should check if the api has a valid subscription matching this productId for the current google user on this phone + /** + * Gets the user's existing active subscription if one exists. + * Returns null if no active subscription is found. + */ + private suspend fun getExistingSubscription(): Purchase? { + return try { + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + + val result = billingClient.queryPurchasesAsync(params) + + // Return the first active subscription + result.purchasesList.firstOrNull { + it.purchaseState == Purchase.PurchaseState.PURCHASED //todo PRO Should we also OR PENDING here? + } + } catch (e: Exception) { + Log.e(TAG, "Error querying existing subscription", e) + null + } + } + + override suspend fun hasValidSubscription(productId: String): Boolean { + // if in debug mode, always return true + return if(prefs.forceCurrentUserAsPro()) true + else getExistingSubscription() != null } companion object { From fd22a95385b6c30379e6eb8e77a4f8cf894e68f7 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 14:42:28 +1100 Subject: [PATCH 06/14] Adding a canceled state which is neither an error nor a success in terms of handling billing responses Cleaned up billing access logic as well --- .../utilities/TextSecurePreferences.kt | 1 + .../prosettings/ProSettingsViewModel.kt | 18 ++++--- .../chooseplan/UpdatePlanScreen.kt | 2 +- .../subscription/NoOpSubscriptionManager.kt | 7 +-- .../pro/subscription/SubscriptionManager.kt | 4 +- .../thoughtcrime/securesms/ui/AlertDialog.kt | 1 + .../PlayStoreSubscriptionManager.kt | 47 ++++++++++++++++--- 7 files changed, 62 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index da8d6afb36..f840572d8e 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -1789,6 +1789,7 @@ class AppTextSecurePreferences @Inject constructor( override fun setDebugForceNoBilling(hasBilling: Boolean) { setBooleanPreference(TextSecurePreferences.DEBUG_FORCE_NO_BILLING, hasBilling) + _events.tryEmit(TextSecurePreferences.DEBUG_FORCE_NO_BILLING) } override fun getSubscriptionProvider(): String? { 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 af27719d47..8eeca29256 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 @@ -18,6 +18,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update @@ -101,14 +102,19 @@ class ProSettingsViewModel @AssistedInject constructor( Toast.LENGTH_SHORT ).show() } + + is SubscriptionManager.PurchaseEvent.Cancelled -> { + // nothing to do in this case + } } } } - // Update choosePlanState whenever proSettingsUIState changes + // Update choosePlanState whenever proSettingsUIState or the billing support change viewModelScope.launch { - _proSettingsUIState - .map { proState -> + combine(_proSettingsUIState, + subscriptionCoordinator.getCurrentManager().supportsBilling + ) { proState, supportsBilling -> val subType = proState.subscriptionState.type val isActive = subType is SubscriptionType.Active val currentPlan12Months = isActive && subType.duration == ProSubscriptionDuration.TWELVE_MONTHS @@ -118,7 +124,7 @@ class ProSettingsViewModel @AssistedInject constructor( ChoosePlanState( subscriptionType = subType, hasValidSubscription = proState.hasValidSubscription, - hasBillingCapacity = proState.hasBillingCapacity, + hasBillingCapacity = supportsBilling, enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state plans = listOf( ProPlan( @@ -225,11 +231,12 @@ class ProSettingsViewModel @AssistedInject constructor( val subType = subscriptionState.type _proSettingsUIState.update { + Log.w("", " *** SETTING VM TO: ${subscriptionCoordinator.getCurrentManager().supportsBilling.value}") + ProSettingsState( subscriptionState = subscriptionState, //todo PRO need to get the product id from libsession - also this might be a long running operation hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription(""), - hasBillingCapacity = subscriptionCoordinator.getCurrentManager().supportsBilling, subscriptionExpiryLabel = when(subType){ is SubscriptionType.Active.AutoRenewing -> Phrase.from(context, R.string.proAutoRenewTime) @@ -624,7 +631,6 @@ class ProSettingsViewModel @AssistedInject constructor( data class ProSettingsState( val subscriptionState: SubscriptionState = getDefaultSubscriptionStateData(), val proStats: State = State.Loading, - val hasBillingCapacity: Boolean = false, // true is the current build flavour supports billing val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession val subscriptionExpiryLabel: CharSequence = "", // eg: "Pro auto renewing in 3 days" val subscriptionExpiryDate: CharSequence = "" // eg: "May 21st, 2025" diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt index 38adba3087..6ccfd0edee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt @@ -31,7 +31,7 @@ fun UpdatePlanScreen( // or we have no billing APIs subscription.subscriptionDetails.isFromAnotherPlatform() || !planData.hasValidSubscription - || !subscriptionManager.supportsBilling -> + || !subscriptionManager.supportsBilling.value -> ChoosePlanNonOriginating( subscription = planData.subscriptionType as SubscriptionType.Active, sendCommand = viewModel::onCommand, diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 91305b858e..d94896898a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -1,22 +1,23 @@ package org.thoughtcrime.securesms.pro.subscription import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.emptyFlow import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import javax.inject.Inject +import javax.inject.Singleton /** * An implementation representing a lack of support for subscription */ +@Singleton class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val id = "noop" override val name = "" override val description = "" override val iconRes = null - override val supportsBilling: Boolean = false + override val supportsBilling = MutableStateFlow(false) override val quickRefundExpiry = null override val quickRefundUrl = null 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 49293eda1e..bd86cc4069 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 @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.pro.subscription import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import java.time.Instant @@ -16,7 +17,7 @@ interface SubscriptionManager: OnAppStartupComponent { val description: String val iconRes: Int? - val supportsBilling: Boolean + val supportsBilling: StateFlow // Optional. Some store can have a platform specific refund window and url val quickRefundExpiry: Instant? @@ -26,6 +27,7 @@ interface SubscriptionManager: OnAppStartupComponent { sealed interface PurchaseEvent { data object Success : PurchaseEvent + data object Cancelled : PurchaseEvent data class Failed(val errorMessage: String? = null) : PurchaseEvent } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index ea1077065e..4a10bee2e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -450,6 +450,7 @@ fun TCPolicyDialog( onDismissRequest = onDismissRequest, title = stringResource(R.string.urlOpen), text = stringResource(R.string.urlOpenBrowser), + showCloseButton = true, content = { Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) Cell( 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 777628e13d..f593479208 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 @@ -17,9 +17,19 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.session.libsession.utilities.TextSecurePreferences @@ -29,10 +39,12 @@ import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseE import org.thoughtcrime.securesms.util.CurrentActivityObserver import java.time.Instant import javax.inject.Inject +import javax.inject.Singleton /** * The Google Play Store implementation of our subscription manager */ +@Singleton class PlayStoreSubscriptionManager @Inject constructor( private val application: Application, @param:ManagerScope private val scope: CoroutineScope, @@ -44,8 +56,19 @@ class PlayStoreSubscriptionManager @Inject constructor( override val description = "" override val iconRes = null - override val supportsBilling: Boolean - get() = !prefs.getDebugForceNoBilling() + // specifically test the google play billing + private val _playBillingAvailable = MutableStateFlow(false) + + // generic billing support method. Uses the property above and also checks the debug pref + override val supportsBilling: StateFlow = combine( + _playBillingAvailable, + (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_FORCE_NO_BILLING } as Flow<*>) + .onStart { emit(Unit) } + .map { prefs.getDebugForceNoBilling() }, + ){ available, forceNoBilling -> + !forceNoBilling && available + } + .stateIn(scope, SharingStarted.Eagerly, false) override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" @@ -62,18 +85,22 @@ class PlayStoreSubscriptionManager @Inject constructor( .setListener { result, purchases -> Log.d(TAG, "onPurchasesUpdated: $result, $purchases") if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { - for (purchase in purchases) { + purchases.firstOrNull()?.let{ scope.launch { // signal that purchase was completed + try { + //todo PRO send confirmation to libsession + } catch (e : Exception){ + _purchaseEvents.emit(PurchaseEvent.Failed()) + } - //todo PRO send confirmation to libsession _purchaseEvents.emit(PurchaseEvent.Success) } } - } else if (result.responseCode != BillingClient.BillingResponseCode.USER_CANCELED) { + } else { Log.w(TAG, "Purchase failed or cancelled: $result") scope.launch { - _purchaseEvents.emit(PurchaseEvent.Failed()) + _purchaseEvents.emit(PurchaseEvent.Cancelled) } } } @@ -186,11 +213,17 @@ class PlayStoreSubscriptionManager @Inject constructor( billingClient.startConnection(object : BillingClientStateListener { override fun onBillingServiceDisconnected() { - Log.w(TAG, "onBillingServiceDisconnected") + + Log.w(TAG, " *** SETTING TO FALSE") + _playBillingAvailable.update { false } } override fun onBillingSetupFinished(result: BillingResult) { Log.d(TAG, "onBillingSetupFinished with $result") + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + Log.w(TAG, " *** SETTING TO TRUE") + _playBillingAvailable.update { true } + } } }) } From 894cefa1c77705539dfa6cf3bf0ee27221316081 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 15:11:12 +1100 Subject: [PATCH 07/14] Fixing loading and error diaogs for never-subscribed --- .../prosettings/ProSettingsHomeScreen.kt | 1 - .../prosettings/ProSettingsViewModel.kt | 28 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index d22f35c78c..06419db99e 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 @@ -131,7 +131,6 @@ fun ProSettingsHome( // add a click handling if the subscription state is loading or errored if(data.subscriptionState.refreshState !is State.Success<*>){ sendCommand(OnHeaderClicked) - //todo PRO double check if KEE is ok to not have two different dialogs for the header vs the action button. If yes then I need to simplify the logic, if not I need to fix the never-subscribed case } else null }, extraHeaderContent = { 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 8eeca29256..24f0c040ae 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 @@ -288,13 +288,18 @@ class ProSettingsViewModel @AssistedInject constructor( Phrase.from(context.getText(R.string.proAccessLoadingDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() + is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.checkingProStatus)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.checkingProStatusContinue)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() else -> Phrase.from(context.getText(R.string.checkingProStatus)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.checkingProStatusRenew)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() - //todo PRO will need to handle never subscribed here } _dialogState.update { @@ -318,6 +323,12 @@ class ProSettingsViewModel @AssistedInject constructor( .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format() + is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.proStatusError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusNetworkErrorContinue)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() else -> Phrase.from(context.getText(R.string.proStatusError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to @@ -325,7 +336,6 @@ class ProSettingsViewModel @AssistedInject constructor( .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format() - //todo PRO will need to handle never subscribed here } _dialogState.update { @@ -489,13 +499,18 @@ class ProSettingsViewModel @AssistedInject constructor( Phrase.from(context.getText(R.string.proStatusLoadingDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() + is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.checkingProStatus)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.checkingProStatusContinue)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() else -> Phrase.from(context.getText(R.string.checkingProStatus)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.checkingProStatusDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() - //todo PRO will need to handle never subscribed here } _dialogState.update { it.copy( @@ -518,13 +533,18 @@ class ProSettingsViewModel @AssistedInject constructor( Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() + is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.proStatusError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusNetworkErrorContinue)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() else -> Phrase.from(context.getText(R.string.proStatusError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() - //todo PRO will need to handle never subscribed here } it.copy( From fa2848201026d302ef312d9015c59e6ecdce777c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 15:25:06 +1100 Subject: [PATCH 08/14] Adding missing states in the confirmation screen --- .../prosettings/PlanConfirmationScreen.kt | 99 ++++++++++++++++--- 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt index 5e8c6c0b13..13c34c79ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt @@ -31,8 +31,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.NonTranslatableStringConstants.NETWORK_NAME import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NETWORK_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GoToProSettings @@ -110,17 +112,34 @@ fun PlanConfirmation( Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + val description = when (proData.subscriptionState.type) { + is SubscriptionType.Active -> { + Phrase.from(context.getText(R.string.proAllSetDescription)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(DATE_KEY, proData.subscriptionExpiryDate) + .format() + } + + is SubscriptionType.NeverSubscribed -> { + Phrase.from(context.getText(R.string.proUpgraded)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(NETWORK_NAME_KEY, NETWORK_NAME) + .format() + } + + is SubscriptionType.Expired -> { + Phrase.from(context.getText(R.string.proPlanRenewSupport)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(NETWORK_NAME_KEY, NETWORK_NAME) + .format() + } + } + Text( modifier = Modifier.align(CenterHorizontally) .safeContentWidth(), - //todo PRO the text below can change if the user was renewing vs expiring and/or/auto-renew - text = annotatedStringResource( - Phrase.from(context.getText(R.string.proAllSetDescription)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(DATE_KEY, proData.subscriptionExpiryDate) - .format() - ), + text = annotatedStringResource(description), textAlign = TextAlign.Center, style = LocalType.current.base, color = LocalColors.current.text, @@ -128,11 +147,21 @@ fun PlanConfirmation( Spacer(Modifier.height(LocalDimensions.current.spacing)) - //todo PRO the button text can change if the user was renewing vs expiring and/or/auto-renew + val buttonLabel = when (proData.subscriptionState.type) { + is SubscriptionType.Active -> stringResource(R.string.theReturn) + + else -> { + Phrase.from(context.getText(R.string.proStartUsing)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + } + } + AccentFillButtonRect( modifier = Modifier.fillMaxWidth() .widthIn(max = LocalDimensions.current.maxContentWidth), - text = stringResource(R.string.theReturn), + text = buttonLabel, onClick = { sendCommand(GoToProSettings) } @@ -146,12 +175,13 @@ fun PlanConfirmation( @Preview @Composable -private fun PreviewPlanConfirmation( +private fun PreviewPlanConfirmationActive( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { PreviewTheme(colors) { PlanConfirmation( proData = ProSettingsViewModel.ProSettingsState( + subscriptionExpiryDate = "20th June 2026", subscriptionState = SubscriptionState( type = SubscriptionType.Active.AutoRenewing( proStatus = ProStatus.Pro( @@ -176,4 +206,51 @@ private fun PreviewPlanConfirmation( } } +@Preview +@Composable +private fun PreviewPlanConfirmationExpired( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + PlanConfirmation( + proData = ProSettingsViewModel.ProSettingsState( + subscriptionState = SubscriptionState( + type = SubscriptionType.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), + SubscriptionDetails( + device = "iOS", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + )), + refreshState = State.Success(Unit),), + ), + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewPlanConfirmationNeverSub( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + PlanConfirmation( + proData = ProSettingsViewModel.ProSettingsState( + subscriptionState = SubscriptionState( + type = SubscriptionType.NeverSubscribed, + refreshState = State.Success(Unit),), + ), + sendCommand = {}, + onBack = {}, + ) + } +} + + + From 57bf456d87df517314b264984ad7fec5fe78d505 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 15:30:15 +1100 Subject: [PATCH 09/14] Clean up --- .../securesms/preferences/prosettings/ProSettingsHomeScreen.kt | 1 - .../java/org/thoughtcrime/securesms/pro/ProStatusManager.kt | 3 --- 2 files changed, 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 06419db99e..f5d4751ea1 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 @@ -168,7 +168,6 @@ fun ProSettingsHome( when(subscriptionType){ is SubscriptionType.Active -> R.string.proErrorRefreshingStatus else -> R.string.errorCheckingProStatus - //todo PRO will need to handle never subscribed here })) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), 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 a71d352678..943ef828d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -201,9 +201,6 @@ class ProStatusManager @Inject constructor( } } - //todo PRO add "about to expire" CTA logic on app launch - //todo PRO add "expired" CTA logic on app launch - /** * Logic to determine if we should animate the avatar for a user or freeze it on the first frame */ From 39ed27cf620017825426b6cd1382d088b817677c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 16:20:41 +1100 Subject: [PATCH 10/14] Proper calculation of the quick refund window --- .../prosettings/ProSettingsViewModel.kt | 26 ++++++++++++++++- .../prosettings/RefundPlanScreen.kt | 29 ++++++++++--------- .../subscription/NoOpSubscriptionManager.kt | 5 +++- .../pro/subscription/SubscriptionManager.kt | 7 ++--- .../PlayStoreSubscriptionManager.kt | 15 +++++++++- 5 files changed, 60 insertions(+), 22 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 24f0c040ae..d72b85f67b 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 @@ -50,7 +50,6 @@ import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State -import javax.inject.Inject @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @@ -77,6 +76,9 @@ class ProSettingsViewModel @AssistedInject constructor( private val _choosePlanState: MutableStateFlow = MutableStateFlow(ChoosePlanState()) val choosePlanState: StateFlow = _choosePlanState + private val _refundPlanState: MutableStateFlow = MutableStateFlow(RefundPlanState()) + val refundPlanState: StateFlow = _refundPlanState + init { // observe subscription status viewModelScope.launch { @@ -223,6 +225,22 @@ class ProSettingsViewModel @AssistedInject constructor( } } } + + // Update refund plan state + viewModelScope.launch { + _proSettingsUIState.map { proUIState -> + val subManager = subscriptionCoordinator.getCurrentManager() + RefundPlanState( + subscriptionType = proUIState.subscriptionState.type, + isQuickRefund = subManager.isWithinQuickRefundWindow(), + quickRefundUrl = subManager.quickRefundUrl + ) + } + .distinctUntilChanged() + .collect { + _refundPlanState.update { it } + } + } } private suspend fun generateState(subscriptionState: SubscriptionState){ @@ -665,6 +683,12 @@ class ProSettingsViewModel @AssistedInject constructor( val enableButton: Boolean = false, ) + data class RefundPlanState( + val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, + val isQuickRefund: Boolean = false, + val quickRefundUrl: String? = null + ) + data class ProStats( val groupsUpdated: Int = 0, val pinnedConversations: Int = 0, 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 fadc4cda64..e964f36a65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt @@ -44,21 +44,19 @@ fun RefundPlanScreen( viewModel: ProSettingsViewModel, onBack: () -> Unit, ) { - val planData by viewModel.choosePlanState.collectAsState() - val activePlan = planData.subscriptionType as? SubscriptionType.Active + val refundData by viewModel.refundPlanState.collectAsState() + val activePlan = refundData.subscriptionType as? SubscriptionType.Active if (activePlan == null) { onBack() return } - val subManager = viewModel.getSubscriptionManager() - // there are different UI depending on the state when { // there is an active subscription but from a different platform activePlan.subscriptionDetails.isFromAnotherPlatform() -> RefundPlanNonOriginating( - subscription = planData.subscriptionType as SubscriptionType.Active, + subscription = activePlan, sendCommand = viewModel::onCommand, onBack = onBack, ) @@ -66,7 +64,8 @@ fun RefundPlanScreen( // default refund screen else -> RefundPlan( data = activePlan, - subscriptionManager = subManager, + isQuickRefund = refundData.isQuickRefund, + quickRefundUrl = refundData.quickRefundUrl, sendCommand = viewModel::onCommand, onBack = onBack, ) @@ -77,24 +76,24 @@ fun RefundPlanScreen( @Composable fun RefundPlan( data: SubscriptionType.Active, - subscriptionManager: SubscriptionManager, + isQuickRefund: Boolean, + quickRefundUrl: String?, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { val context = LocalContext.current - val isWithinQuickRefundWindow = subscriptionManager.isWithinQuickRefundWindow() BaseCellButtonProSettingsScreen( disabled = true, onBack = onBack, - buttonText = if(isWithinQuickRefundWindow) Phrase.from(context.getText(R.string.openPlatformWebsite)) + buttonText = if(isQuickRefund) Phrase.from(context.getText(R.string.openPlatformWebsite)) .put(PLATFORM_KEY, data.subscriptionDetails.platform) .format().toString() else stringResource(R.string.requestRefund), dangerButton = true, onButtonClick = { - if(isWithinQuickRefundWindow && !subscriptionManager.quickRefundUrl.isNullOrEmpty()){ - sendCommand(ShowOpenUrlDialog(subscriptionManager.quickRefundUrl)) + if(isQuickRefund && !quickRefundUrl.isNullOrEmpty()){ + sendCommand(ShowOpenUrlDialog(quickRefundUrl)) } else { sendCommand(ShowOpenUrlDialog(data.subscriptionDetails.refundUrl)) } @@ -114,7 +113,7 @@ fun RefundPlan( Text( text = annotatedStringResource( - if(isWithinQuickRefundWindow) + if(isQuickRefund) Phrase.from(context.getText(R.string.proRefundRequestStorePolicies)) .put(PLATFORM_KEY, data.subscriptionDetails.platform) .put(APP_NAME_KEY, context.getString(R.string.app_name)) @@ -174,7 +173,8 @@ private fun PreviewRefundPlan( refundUrl = "https://getsession.org/android-refund", ) ), - subscriptionManager = NoOpSubscriptionManager(), + isQuickRefund = false, + quickRefundUrl = "", sendCommand = {}, onBack = {}, ) @@ -203,7 +203,8 @@ private fun PreviewQuickRefundPlan( refundUrl = "https://getsession.org/android-refund", ) ), - subscriptionManager = NoOpSubscriptionManager(), + isQuickRefund = true, + quickRefundUrl = "", sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index d94896898a..1031ad45fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -19,7 +19,6 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val supportsBilling = MutableStateFlow(false) - override val quickRefundExpiry = null override val quickRefundUrl = null override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) {} @@ -31,4 +30,8 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override suspend fun hasValidSubscription(productId: String): Boolean { return false } + + override suspend fun isWithinQuickRefundWindow(): Boolean { + return false + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index bd86cc4069..ddcfb41f59 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 @@ -20,7 +20,6 @@ interface SubscriptionManager: OnAppStartupComponent { val supportsBilling: StateFlow // Optional. Some store can have a platform specific refund window and url - val quickRefundExpiry: Instant? val quickRefundUrl: String? val availablePlans: List @@ -37,11 +36,9 @@ interface SubscriptionManager: OnAppStartupComponent { fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) /** - * Returns true if a provider has a non null [quickRefundExpiry] and the current time is within that window + * Returns true if a provider has a quick refunds and the current time since purchase is within that window */ - fun isWithinQuickRefundWindow(): Boolean { - return quickRefundExpiry != null && Instant.now().isBefore(quickRefundExpiry) - } + suspend fun isWithinQuickRefundWindow(): Boolean /** * Checks whether there is a valid subscription for the given product id for the current user within this subscriber's billing API 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 f593479208..ff920162bd 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 @@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import org.thoughtcrime.securesms.util.CurrentActivityObserver import java.time.Instant +import java.time.temporal.ChronoUnit import javax.inject.Inject import javax.inject.Singleton @@ -70,7 +71,6 @@ class PlayStoreSubscriptionManager @Inject constructor( } .stateIn(scope, SharingStarted.Eagerly, false) - override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" private val _purchaseEvents = MutableSharedFlow( @@ -256,6 +256,19 @@ class PlayStoreSubscriptionManager @Inject constructor( else getExistingSubscription() != null } + override suspend fun isWithinQuickRefundWindow(): Boolean { + val purchaseTimeMillis = getExistingSubscription()?.purchaseTime ?: return false + + val now = Instant.now() + val purchaseInstant = Instant.ofEpochMilli(purchaseTimeMillis) + + // Google Play allows refunds within 48 hours of purchase + val refundWindowHours = 48 + val refundDeadline = purchaseInstant.plus(refundWindowHours.toLong(), ChronoUnit.HOURS) + + return now.isBefore(refundDeadline) + } + companion object { private const val TAG = "PlayStoreSubscriptionManager" } From bffd6cc84949ad9454d9c4f546896f95e294fe48 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 16:23:11 +1100 Subject: [PATCH 11/14] Adding back temp suffix removal --- app/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index da96b2db71..d769563e80 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -203,10 +203,11 @@ android { enableUnitTestCoverage = false signingConfig = signingConfigs.getByName("debug") + applicationIdSuffix = ".${name}" enablePermissiveNetworkSecurityConfig(true) devNetDefaultOn(false) setAlternativeAppName("Session Debug") - setAuthorityPostfix("") + setAuthorityPostfix(".debug") } } From c161e1276cde5aa5212c44ef6b60c9a254183c84 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 16:28:06 +1100 Subject: [PATCH 12/14] Clean up --- .../securesms/preferences/prosettings/ProSettingsViewModel.kt | 2 -- .../securesms/pro/subscription/PlayStoreSubscriptionManager.kt | 2 -- 2 files changed, 4 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 d72b85f67b..c8111020a4 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 @@ -249,8 +249,6 @@ class ProSettingsViewModel @AssistedInject constructor( val subType = subscriptionState.type _proSettingsUIState.update { - Log.w("", " *** SETTING VM TO: ${subscriptionCoordinator.getCurrentManager().supportsBilling.value}") - ProSettingsState( subscriptionState = subscriptionState, //todo PRO need to get the product id from libsession - also this might be a long running operation 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 ff920162bd..af3f3091d6 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 @@ -214,14 +214,12 @@ class PlayStoreSubscriptionManager @Inject constructor( billingClient.startConnection(object : BillingClientStateListener { override fun onBillingServiceDisconnected() { - Log.w(TAG, " *** SETTING TO FALSE") _playBillingAvailable.update { false } } override fun onBillingSetupFinished(result: BillingResult) { Log.d(TAG, "onBillingSetupFinished with $result") if (result.responseCode == BillingClient.BillingResponseCode.OK) { - Log.w(TAG, " *** SETTING TO TRUE") _playBillingAvailable.update { true } } } From f95ef222d7456279eec035c434fa36ae8559fa21 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 18:16:47 +1100 Subject: [PATCH 13/14] Update app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt Co-authored-by: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> --- .../securesms/preferences/prosettings/BaseProSettingsScreens.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 661dc14976..126419b867 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 @@ -90,7 +90,7 @@ fun BaseProSettingsScreen( val lazyListState = rememberLazyListState() // Calculate scroll fraction val density = LocalDensity.current - val thresholdPx = remember { with(density) { 28.dp.toPx() } } // amount before the appbar gets fully opaque + val thresholdPx = remember(density) { with(density) { 28.dp.toPx() } } // amount before the appbar gets fully opaque // raw fraction 0..1 derived from scrolling val rawFraction by remember { From 4095fafee29a3e8f79fb63f42d5131ca63b2f0c7 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 28 Oct 2025 09:13:45 +1100 Subject: [PATCH 14/14] Change the loading style to a nicer UI --- .../prosettings/BaseProSettingsScreens.kt | 88 ++++--------------- .../chooseplan/ChoosePlanScreen.kt | 27 ++++-- .../PlayStoreSubscriptionManager.kt | 1 - 3 files changed, 37 insertions(+), 79 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 661dc14976..719141cbce 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 @@ -33,33 +33,21 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.consumeAllChanges -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.ProgressBarRangeInfo -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.liveRegion -import androidx.compose.ui.semantics.progressBarRangeInfo -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.AnimateFade import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogBg import org.thoughtcrime.securesms.ui.SessionProSettingsHeader import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator import org.thoughtcrime.securesms.ui.components.DangerFillButtonRect import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.components.inlineContentMap @@ -69,7 +57,6 @@ import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors -import org.thoughtcrime.securesms.ui.theme.blackAlpha40 import org.thoughtcrime.securesms.ui.theme.bold /** @@ -79,7 +66,6 @@ import org.thoughtcrime.securesms.ui.theme.bold @Composable fun BaseProSettingsScreen( disabled: Boolean, - loading: Boolean = false, hideHomeAppBar: Boolean = false, onBack: () -> Unit, onHeaderClick: (() -> Unit)? = null, @@ -121,64 +107,28 @@ fun BaseProSettingsScreen( }} else {{}}, contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { paddings -> - - Box( + LazyColumn( modifier = Modifier .fillMaxSize() + .consumeWindowInsets(paddings) + .padding(horizontal = LocalDimensions.current.spacing), + state = lazyListState, + contentPadding = PaddingValues( + top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) + .coerceAtLeast(0.dp) + 46.dp, + bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing + ), + horizontalAlignment = CenterHorizontally ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .consumeWindowInsets(paddings) - .padding(horizontal = LocalDimensions.current.spacing), - state = lazyListState, - contentPadding = PaddingValues( - top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) - .coerceAtLeast(0.dp) + 46.dp, - bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing - ), - horizontalAlignment = CenterHorizontally - ) { - item { - SessionProSettingsHeader( - disabled = disabled, - onClick = onHeaderClick, - extraContent = extraHeaderContent - ) - } - - item { content() } + item { + SessionProSettingsHeader( + disabled = disabled, + onClick = onHeaderClick, + extraContent = extraHeaderContent + ) } - } - AnimateFade(loading) { - val loadingLabel = stringResource(R.string.loading) - Box( - modifier = Modifier - .fillMaxSize() - // dim the background so it's visually obvious it's blocked (optional) - .background(blackAlpha40) - // Intercept click events so that when the loading is on we can't click beneath - .pointerInput(loading) { - if (loading) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent() - event.changes.forEach { it.consume() } - } - } - } - } - // Provide proper a11y semantics: announce an indeterminate progress. - .semantics(mergeDescendants = true) { - progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate - contentDescription = loadingLabel - liveRegion = LiveRegionMode.Polite - }, - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } + item { content() } } } } @@ -195,12 +145,10 @@ fun BaseCellButtonProSettingsScreen( dangerButton: Boolean, onButtonClick: () -> Unit, title: CharSequence? = null, - loading: Boolean = false, content: @Composable () -> Unit ) { BaseProSettingsScreen( disabled = disabled, - loading = loading, onBack = onBack, ) { Spacer(Modifier.height(LocalDimensions.current.spacing)) @@ -286,14 +234,12 @@ fun BaseNonOriginatingProSettingsScreen( headerTitle: CharSequence?, contentTitle: String?, contentDescription: CharSequence?, - loading: Boolean = false, contentClick: (() -> Unit)? = null, linkCellsInfo: String?, linkCells: List = emptyList(), ) { BaseCellButtonProSettingsScreen( disabled = disabled, - loading = loading, onBack = onBack, buttonText = buttonText, dangerButton = dangerButton, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index 8e3140e21a..f81430fc97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -65,6 +66,7 @@ import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.P import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.pro.subscription.expiryFromNow +import org.thoughtcrime.securesms.ui.LoadingArcOr import org.thoughtcrime.securesms.ui.SpeechBubbleTooltip import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.RadioButtonIndicator @@ -92,7 +94,6 @@ fun ChoosePlan( ) { BaseProSettingsScreen( disabled = false, - loading = planData.loading, onBack = onBack, ) { // Keeps track of the badge height dynamically so we can adjust the padding accordingly @@ -162,6 +163,7 @@ fun ChoosePlan( proPlan = data, badgePadding = badgeHeight / 2, onBadgeLaidOut = { height -> badgeHeight = max(badgeHeight, height) }, + enabled = !planData.loading, onClick = { sendCommand(SelectProPlan(data)) } @@ -184,12 +186,15 @@ fun ChoosePlan( AccentFillButtonRect( modifier = Modifier.fillMaxWidth() .widthIn(max = LocalDimensions.current.maxContentWidth), - text = buttonLabel, enabled = planData.enableButton, onClick = { sendCommand(GetProPlan) } - ) + ){ + LoadingArcOr(loading = planData.loading) { + Text(text = buttonLabel) + } + } Spacer(Modifier.height(LocalDimensions.current.xxsSpacing)) @@ -240,6 +245,7 @@ fun ChoosePlan( private fun PlanItem( proPlan: ProPlan, badgePadding: Dp, + enabled: Boolean, modifier: Modifier= Modifier, onBadgeLaidOut: (Dp) -> Unit, onClick: () -> Unit @@ -262,9 +268,13 @@ private fun PlanItem( shape = MaterialTheme.shapes.small ) .clip(MaterialTheme.shapes.small) - .clickable( - onClick = onClick - ) + .then( + if (enabled) Modifier.clickable( + onClick = onClick + ) + else Modifier + ), + ) { Row( modifier = Modifier.fillMaxWidth() @@ -289,7 +299,7 @@ private fun PlanItem( RadioButtonIndicator( selected = proPlan.selected, - enabled = true, + enabled = enabled, colors = radioButtonColors( unselectedBorder = LocalColors.current.borders, selectedBorder = LocalColors.current.accent, @@ -399,6 +409,7 @@ private fun PreviewUpdatePlanItems( ), badgePadding = 0.dp, onBadgeLaidOut = {}, + enabled = true, onClick = {} ) @@ -415,6 +426,7 @@ private fun PreviewUpdatePlanItems( ), ), badgePadding = 0.dp, + enabled = true, onBadgeLaidOut = {}, onClick = {} ) @@ -435,6 +447,7 @@ private fun PreviewUpdatePlanItems( ), ), badgePadding = 0.dp, + enabled = true, onBadgeLaidOut = {}, 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 af3f3091d6..404f92b389 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 @@ -16,7 +16,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow