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/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 4652433666..86470a4468 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -25,6 +25,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 @@ -293,7 +294,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 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) } } 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..93fd2fcf08 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,63 @@ 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(density) { 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() } } } } 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 = {}, + ) + } +} + + + 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..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 @@ -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 = { @@ -169,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/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 982a7afb3b..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 @@ -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 @@ -17,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 @@ -48,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) @@ -75,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 { @@ -83,10 +87,36 @@ class ProSettingsViewModel @AssistedInject constructor( } } - // Update choosePlanState whenever proSettingsUIState changes + // 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() + } + + is SubscriptionManager.PurchaseEvent.Cancelled -> { + // nothing to do in this case + } + } + } + } + + // 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 @@ -96,7 +126,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( @@ -195,9 +225,25 @@ class ProSettingsViewModel @AssistedInject constructor( } } } + + // Update refund plan state + viewModelScope.launch { + _proSettingsUIState.map { proUIState -> + val subManager = subscriptionCoordinator.getCurrentManager() + RefundPlanState( + subscriptionType = proUIState.subscriptionState.type, + isQuickRefund = subManager.isWithinQuickRefundWindow(), + quickRefundUrl = subManager.quickRefundUrl + ) + } + .distinctUntilChanged() + .collect { + _refundPlanState.update { it } + } + } } - private fun generateState(subscriptionState: SubscriptionState){ + private suspend fun generateState(subscriptionState: SubscriptionState){ //todo PRO need to properly calculate this val subType = subscriptionState.type @@ -207,7 +253,6 @@ class ProSettingsViewModel @AssistedInject constructor( 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) @@ -259,13 +304,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 { @@ -289,6 +339,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 @@ -296,7 +352,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 { @@ -389,7 +444,6 @@ class ProSettingsViewModel @AssistedInject constructor( Commands.GetProPlan -> { val currentSubscription = _proSettingsUIState.value.subscriptionState.type - if(currentSubscription is SubscriptionType.Active){ val newSubscriptionExpiryString = getSelectedPlan().durationType.expiryFromNow() @@ -461,13 +515,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( @@ -490,13 +549,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( @@ -555,6 +619,10 @@ class ProSettingsViewModel @AssistedInject constructor( } private fun getPlanFromProvider(){ + _choosePlanState.update { + it.copy(loading = true) + } + subscriptionCoordinator.getCurrentManager().purchasePlan( getSelectedPlan().durationType ) @@ -599,7 +667,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" @@ -609,10 +676,17 @@ 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, ) + 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/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index 7681c84c08..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 @@ -161,6 +163,7 @@ fun ChoosePlan( proPlan = data, badgePadding = badgeHeight / 2, onBadgeLaidOut = { height -> badgeHeight = max(badgeHeight, height) }, + enabled = !planData.loading, onClick = { sendCommand(SelectProPlan(data)) } @@ -183,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)) @@ -239,6 +245,7 @@ fun ChoosePlan( private fun PlanItem( proPlan: ProPlan, badgePadding: Dp, + enabled: Boolean, modifier: Modifier= Modifier, onBadgeLaidOut: (Dp) -> Unit, onClick: () -> Unit @@ -261,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() @@ -288,7 +299,7 @@ private fun PlanItem( RadioButtonIndicator( selected = proPlan.selected, - enabled = true, + enabled = enabled, colors = radioButtonColors( unselectedBorder = LocalColors.current.borders, selectedBorder = LocalColors.current.accent, @@ -398,6 +409,7 @@ private fun PreviewUpdatePlanItems( ), badgePadding = 0.dp, onBadgeLaidOut = {}, + enabled = true, onClick = {} ) @@ -414,6 +426,7 @@ private fun PreviewUpdatePlanItems( ), ), badgePadding = 0.dp, + enabled = true, onBadgeLaidOut = {}, onClick = {} ) @@ -434,6 +447,7 @@ private fun PreviewUpdatePlanItems( ), ), badgePadding = 0.dp, + enabled = true, onBadgeLaidOut = {}, onClick = {} ) 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/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 */ 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..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 @@ -1,26 +1,37 @@ package org.thoughtcrime.securesms.pro.subscription +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +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 override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) {} 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 + } + + 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 ab32095926..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 @@ -1,5 +1,10 @@ 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 @@ -12,26 +17,32 @@ 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? val quickRefundUrl: String? val availablePlans: List + sealed interface PurchaseEvent { + data object Success : PurchaseEvent + data object Cancelled : PurchaseEvent + data class Failed(val errorMessage: String? = null) : PurchaseEvent + } + + // purchase events + val purchaseEvents: SharedFlow + 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 */ - fun hasValidSubscription(productId: String): Boolean + suspend fun hasValidSubscription(productId: String): Boolean } 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/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/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/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 54e7bd96c1..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 @@ -7,23 +7,44 @@ 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.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 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 java.time.temporal.ChronoUnit 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, @@ -35,16 +56,52 @@ 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" + 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) { + purchases.firstOrNull()?.let{ + scope.launch { + // signal that purchase was completed + try { + //todo PRO send confirmation to libsession + } catch (e : Exception){ + _purchaseEvents.emit(PurchaseEvent.Failed()) + } + + _purchaseEvents.emit(PurchaseEvent.Success) + } + } + } else { + Log.w(TAG, "Purchase failed or cancelled: $result") + scope.launch { + _purchaseEvents.emit(PurchaseEvent.Cancelled) + } + } } .enableAutoServiceReconnection() .enablePendingPurchases( @@ -94,23 +151,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) { @@ -135,17 +212,58 @@ class PlayStoreSubscriptionManager @Inject constructor( billingClient.startConnection(object : BillingClientStateListener { override fun onBillingServiceDisconnected() { - Log.w(TAG, "onBillingServiceDisconnected") + + _playBillingAvailable.update { false } } override fun onBillingSetupFinished(result: BillingResult) { Log.d(TAG, "onBillingSetupFinished with $result") + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + _playBillingAvailable.update { true } + } } }) } - 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 + } + + 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 { 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) {