diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 5a540d1d0c..8af681b86c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -135,6 +135,8 @@ fun ConversationSettingsNavHost( is NavigationAction.ReturnResult -> { returnResult(action.code, action.value) } + + else -> {} } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt index 0eb22ac784..491418be66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt @@ -66,7 +66,7 @@ fun DebugMenuNavHost( navController.context.startActivity(action.intent) } - is NavigationAction.ReturnResult -> {} + else -> {} } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt index 9ce71dfba7..e19cb6de74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -111,133 +111,131 @@ fun StartConversationNavHost( accountId: String, onClose: () -> Unit ){ - SharedTransitionLayout { - val navController = rememberNavController() - val navigator: UINavigator = - remember { UINavigator() } - - ObserveAsEvents(flow = navigator.navigationActions) { action -> - when (action) { - is NavigationAction.Navigate -> navController.navigate( - action.destination - ) { - action.navOptions(this) - } - - NavigationAction.NavigateUp -> navController.navigateUp() + val navController = rememberNavController() + val navigator: UINavigator = + remember { UINavigator() } + + ObserveAsEvents(flow = navigator.navigationActions) { action -> + when (action) { + is NavigationAction.Navigate -> navController.navigate( + action.destination + ) { + action.navOptions(this) + } - is NavigationAction.NavigateToIntent -> { - navController.context.startActivity(action.intent) - } + NavigationAction.NavigateUp -> navController.navigateUp() - is NavigationAction.ReturnResult -> {} + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) } + + else -> {} } + } - val scope = rememberCoroutineScope() - val activity = LocalActivity.current - val context = LocalContext.current + val scope = rememberCoroutineScope() + val activity = LocalActivity.current + val context = LocalContext.current + + NavHost(navController = navController, startDestination = StartConversationDestination.Home) { + // Home + horizontalSlideComposable { + StartConversationScreen ( + accountId = accountId, + onClose = onClose, + navigateTo = { + scope.launch { navigator.navigate(it) } + } + ) + } - NavHost(navController = navController, startDestination = StartConversationDestination.Home) { - // Home - horizontalSlideComposable { - StartConversationScreen ( - accountId = accountId, - onClose = onClose, - navigateTo = { - scope.launch { navigator.navigate(it) } - } - ) - } + // New Message + horizontalSlideComposable { + val viewModel = hiltViewModel() + val uiState by viewModel.state.collectAsState(State()) + + val helpUrl = "https://getsession.org/account-ids" - // New Message - horizontalSlideComposable { - val viewModel = hiltViewModel() - val uiState by viewModel.state.collectAsState(State()) - - val helpUrl = "https://getsession.org/account-ids" - - LaunchedEffect(Unit) { - scope.launch { - viewModel.success.collect { - context.startActivity( - ConversationActivityV2.createIntent( - context, - address = it.address - ) + LaunchedEffect(Unit) { + scope.launch { + viewModel.success.collect { + context.startActivity( + ConversationActivityV2.createIntent( + context, + address = it.address ) + ) - onClose() - } + onClose() } } - - NewMessage( - uiState, - viewModel.qrErrors, - viewModel, - onBack = { scope.launch { navigator.navigateUp() } }, - onClose = onClose, - onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } - ) - if (uiState.showUrlDialog) { - OpenURLAlertDialog( - url = helpUrl, - onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } - ) - } } - // Create Group - horizontalSlideComposable { - CreateGroupScreen( - onNavigateToConversationScreen = { address -> - activity?.startActivity( - ConversationActivityV2.createIntent(activity, address) - ) - }, - onBack = { scope.launch { navigator.navigateUp() }}, - onClose = onClose, - fromLegacyGroupId = null, + NewMessage( + uiState, + viewModel.qrErrors, + viewModel, + onBack = { scope.launch { navigator.navigateUp() } }, + onClose = onClose, + onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } + ) + if (uiState.showUrlDialog) { + OpenURLAlertDialog( + url = helpUrl, + onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } ) } + } + + // Create Group + horizontalSlideComposable { + CreateGroupScreen( + onNavigateToConversationScreen = { address -> + activity?.startActivity( + ConversationActivityV2.createIntent(activity, address) + ) + }, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose, + fromLegacyGroupId = null, + ) + } - // Join Community - horizontalSlideComposable { - val viewModel = hiltViewModel() - val state by viewModel.state.collectAsState() - - LaunchedEffect(Unit){ - scope.launch { - viewModel.uiEvents.collect { - when(it){ - is JoinCommunityViewModel.UiEvent.NavigateToConversation -> { - onClose() - activity?.startActivity(ConversationActivityV2.createIntent(activity, it.address)) - } + // Join Community + horizontalSlideComposable { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit){ + scope.launch { + viewModel.uiEvents.collect { + when(it){ + is JoinCommunityViewModel.UiEvent.NavigateToConversation -> { + onClose() + activity?.startActivity(ConversationActivityV2.createIntent(activity, it.address)) } } } } - - JoinCommunityScreen( - state = state, - sendCommand = { viewModel.onCommand(it) }, - onBack = { scope.launch { navigator.navigateUp() }}, - onClose = onClose - ) } - // Invite Friend - horizontalSlideComposable { - InviteFriend( - accountId = accountId, - onBack = { scope.launch { navigator.navigateUp() }}, - onClose = onClose - ) - } + JoinCommunityScreen( + state = state, + sendCommand = { viewModel.onCommand(it) }, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose + ) + } + // Invite Friend + horizontalSlideComposable { + InviteFriend( + accountId = accountId, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose + ) } + } } 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 8bba3a5f18..4785623c69 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 @@ -13,11 +13,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn @@ -107,15 +109,16 @@ fun BaseProSettingsScreen( onBack = onBack, ) }} else {{}}, - contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + contentWindowInsets = WindowInsets.systemBars, ) { paddings -> LazyColumn( modifier = Modifier .fillMaxWidth() - .consumeWindowInsets(paddings) - .padding(horizontal = LocalDimensions.current.spacing), + .consumeWindowInsets(paddings), state = lazyListState, contentPadding = PaddingValues( + start = LocalDimensions.current.spacing, + end = LocalDimensions.current.spacing, top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) .coerceAtLeast(0.dp) + 46.dp, bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing 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 13c34c79ad..0124c43a47 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 @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.preferences.prosettings +import androidx.activity.compose.BackHandler import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -37,7 +38,6 @@ 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 import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.SubscriptionType @@ -79,6 +79,10 @@ fun PlanConfirmation( sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { + BackHandler { + sendCommand(ProSettingsViewModel.Commands.OnPostPlanConfirmation) + } + Scaffold( topBar = {}, contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), @@ -163,7 +167,7 @@ fun PlanConfirmation( .widthIn(max = LocalDimensions.current.maxContentWidth), text = buttonLabel, onClick = { - sendCommand(GoToProSettings) + sendCommand(ProSettingsViewModel.Commands.OnPostPlanConfirmation) } ) 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 32d837db16..adac24d3ed 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 @@ -790,6 +790,22 @@ fun ProManage( } ) } + + val recoverButton: @Composable ()->Unit = { + IconActionRowItem( + title = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.proAccessRecover) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + ), + icon = R.drawable.ic_refresh_cw, + qaTag = R.string.qa_pro_settings_action_request_refund, + onClick = { + //todo PRO implement + } + ) + } + when(data){ is SubscriptionType.Active.AutoRenewing -> { IconActionRowItem( @@ -814,6 +830,10 @@ fun ProManage( refundButton() } + is SubscriptionType.NeverSubscribed -> { + recoverButton() + } + is SubscriptionType.Expired -> { // the details depend on the loading/error state fun renewIcon(color: Color): @Composable BoxScope.() -> Unit = { @@ -871,18 +891,7 @@ fun ProManage( ) Divider() - IconActionRowItem( - title = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proAccessRecover) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString() - ), - icon = R.drawable.ic_refresh_cw, - qaTag = R.string.qa_pro_settings_action_request_refund, - onClick = { - //todo PRO implement - } - ) + recoverButton() } is SubscriptionType.NeverSubscribed -> {} @@ -899,15 +908,13 @@ fun ProSettingsFooter( sendCommand: (ProSettingsViewModel.Commands) -> Unit, ) { // Manage Pro - Pro - if(subscriptionType is SubscriptionType.Active){ - Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) - ProManage( - data = subscriptionType, - inSheet = inSheet, - subscriptionRefreshState = subscriptionRefreshState, - sendCommand = sendCommand, - ) - } + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + ProManage( + data = subscriptionType, + inSheet = inSheet, + subscriptionRefreshState = subscriptionRefreshState, + sendCommand = sendCommand, + ) // Help Spacer(Modifier.height(LocalDimensions.current.spacing)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index cc7b20e95e..42dcdce86f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -48,6 +48,10 @@ sealed interface ProSettingsDestination: Parcelable { data object RefundSubscription: ProSettingsDestination } +enum class ProNavHostCustomActions { + ON_POST_PLAN_CONFIRMATION +} + @Serializable object ProSettingsGraph @SuppressLint("RestrictedApi") @@ -86,6 +90,32 @@ fun ProSettingsNavHost( navController.context.startActivity(action.intent) } + is NavigationAction.PerformCustomAction -> { + when(action.data as? ProNavHostCustomActions){ + // handle the custom case of dealing with the post "choose plan confirmation"screen + ProNavHostCustomActions.ON_POST_PLAN_CONFIRMATION -> { + // we get here where we either hit back or hit the "ok" button on the plan confirmation screen + // if we are in a sheet we need to close it + if (inSheet) { + onBack() + } // otherwise we should clear the stack and head back to the pro settings home screen + else { + // try to navigate "back" home is possible + val wentBack = navController.popBackStack(route = Home, inclusive = false) + + if (!wentBack) { + // Fallback: if Home wasn't in the back stack + navController.navigate(Home){ + popUpTo(Home){ inclusive = false } + } + } + } + } + + else -> {} + } + } + is NavigationAction.ReturnResult -> {} } } 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 7f35456a45..f04198ddd4 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 @@ -82,7 +82,6 @@ class ProSettingsViewModel @AssistedInject constructor( val cancelPlanState: StateFlow> = _cancelPlanState init { - Log.w("", "*** VM INIT") // observe subscription status viewModelScope.launch { proStatusManager.subscriptionState.collect { @@ -304,16 +303,11 @@ class ProSettingsViewModel @AssistedInject constructor( } } - Commands.GoToProSettings -> { - // navigate back to home and pop all other screens off the stack - navigateTo( - destination = ProSettingsDestination.Home, - navOptions = { - popUpTo(ProSettingsDestination.Home){ - inclusive = true - } - } - ) + Commands.OnPostPlanConfirmation -> { + // send a custom action to deal with "post plan confirmation" + viewModelScope.launch { + navigator.sendCustomAction(ProNavHostCustomActions.ON_POST_PLAN_CONFIRMATION) + } } Commands.OpenSubscriptionPage -> { @@ -553,13 +547,18 @@ class ProSettingsViewModel @AssistedInject constructor( viewModelScope.launch { val subType = _proSettingsUIState.value.subscriptionState.type - // first check if the user has a valid subscription + // first check if the user has a valid subscription and billing + val hasBillingCapacity = subscriptionCoordinator.getCurrentManager().supportsBilling.value val hasValidSub = subscriptionCoordinator.getCurrentManager().hasValidSubscription() - // next get the plans, including their pricing - // there is no point in calculating it if the user is pro but without a valid sub - // (meaning they got pro from a different google account than the one they are on now - val plans = if(subType is SubscriptionType.Active && !hasValidSub) emptyList() + // next get the plans, including their pricing, unless there is no billing + // or the user is pro without a valid subscription + // or the user is pro but non originating + val noPriceNeeded = !hasBillingCapacity + || (subType is SubscriptionType.Active && !hasValidSub) + || (subType is SubscriptionType.Active && subType.subscriptionDetails.isFromAnotherPlatform()) + + val plans = if(noPriceNeeded) emptyList() else { // attempt to get the prices from the subscription provider // return early in case of error @@ -576,7 +575,7 @@ class ProSettingsViewModel @AssistedInject constructor( ChoosePlanState( subscriptionType = subType, hasValidSubscription = hasValidSub, - hasBillingCapacity = subscriptionCoordinator.getCurrentManager().supportsBilling.value, + hasBillingCapacity = hasBillingCapacity, enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state plans = plans ) @@ -737,7 +736,7 @@ class ProSettingsViewModel @AssistedInject constructor( data class GoToChoosePlan(val inSheet: Boolean): Commands object GoToRefund: Commands object GoToCancel: Commands - object GoToProSettings: Commands + object OnPostPlanConfirmation: Commands object OpenSubscriptionPage: Commands diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index 6096804e94..f42437ca75 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -176,8 +176,8 @@ fun ChoosePlanNoBilling( title = Phrase.from(context.getText(R.string.onPlatformStoreWebsite)) .put(PLATFORM_STORE_KEY, subscription.subscriptionDetails.getPlatformDisplayName()) .format(), - info = Phrase.from(context.getText(R.string.proAccessRenewPlatformStoreWebsite)) - .put(PLATFORM_STORE_KEY, subscription.subscriptionDetails.getPlatformDisplayName()) + info = Phrase.from(context.getText(R.string.proAccessRenewPlatformWebsite)) + .put(PLATFORM_KEY, subscription.subscriptionDetails.getPlatformDisplayName()) .put(PLATFORM_ACCOUNT_KEY, subscription.subscriptionDetails.platformAccount) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), 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 ff6a23b1f2..62b87f16c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -271,6 +271,35 @@ class ProStatusManager @Inject constructor( return emptySet() } + suspend fun appProPaymentToBackend() { + //todo PRO call AddProPaymentRequest in libsession + + // we should `AddProPaymentRequest` with exponential backoff + + /** + * Here are the errors from the back end that we will need to be aware of + * UnknownPayment: means it's potentially not acknowledged yet so might need to keep trying until this work or times out + * Error: is non retryable - we might want a custom UI for this. + * + * + * /// Payment was claimed and the pro proof was successfully generated + * Success = SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_SUCCESS, + * + * /// Backend encountered an error when attempting to claim the payment + * Error = SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_ERROR, + * + * /// Request JSON failed to be parsed correctly, payload was malformed or missing values + * ParseError = SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_PARSE_ERROR, + * + * /// Payment is already claimed + * AlreadyRedeemed = SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_ALREADY_REDEEMED, + * + * /// Payment transaction attempted to claim a payment that the backend does not have. Either the + * /// payment doesn't exist or the backend has not witnessed the payment from the provider yet. + * UnknownPayment = SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_UNKNOWN_PAYMENT, + */ + } + enum class MessageProFeature { ProBadge, LongMessage, AnimatedAvatar } 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 05375437ab..91de7bf5f0 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,8 +1,11 @@ package org.thoughtcrime.securesms.pro.subscription +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import javax.inject.Inject import javax.inject.Singleton @@ -11,7 +14,10 @@ import javax.inject.Singleton * An implementation representing a lack of support for subscription */ @Singleton -class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { +class NoOpSubscriptionManager @Inject constructor( + proStatusManager: ProStatusManager, + @param:ManagerScope scope: CoroutineScope, +) : SubscriptionManager(proStatusManager, scope) { override val id = "noop" override val name = "" override val description = "" @@ -27,8 +33,6 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val availablePlans: List get() = emptyList() - override val purchaseEvents: SharedFlow = MutableSharedFlow() - override suspend fun hasValidSubscription(): Boolean { return false } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt index 4eecc37f0c..64c8fb23d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt @@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent @Singleton class SubscriptionCoordinator @Inject constructor( private val availableManagers: Set<@JvmSuppressWildcards SubscriptionManager>, + private val noopSubManager: NoOpSubscriptionManager, private val prefs: TextSecurePreferences ): OnAppStartupComponent { @@ -22,7 +23,7 @@ class SubscriptionCoordinator @Inject constructor( when { managers.isEmpty() -> { - currentManager = NoOpSubscriptionManager() + currentManager = noopSubManager } managers.size == 1 -> { currentManager = managers.first() 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 4606856cfc..6c3d9ddd74 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,28 +1,35 @@ package org.thoughtcrime.securesms.pro.subscription +import kotlinx.coroutines.CoroutineScope 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 kotlinx.coroutines.launch +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.pro.ProStatusManager import java.time.Instant /** * Represents the implementation details of a given subscription provider */ -interface SubscriptionManager: OnAppStartupComponent { - val id: String - val name: String - val description: String - val iconRes: Int? +abstract class SubscriptionManager( + protected val proStatusManager: ProStatusManager, + @param:ManagerScope protected val scope: CoroutineScope, +): OnAppStartupComponent { + abstract val id: String + abstract val name: String + abstract val description: String + abstract val iconRes: Int? - val supportsBilling: StateFlow + abstract val supportsBilling: StateFlow // Optional. Some store can have a platform specific refund window and url - val quickRefundUrl: String? + abstract val quickRefundUrl: String? - val availablePlans: List + abstract val availablePlans: List sealed interface PurchaseEvent { data object Success : PurchaseEvent @@ -31,26 +38,46 @@ interface SubscriptionManager: OnAppStartupComponent { } // purchase events - val purchaseEvents: SharedFlow + protected val _purchaseEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val purchaseEvents: SharedFlow = _purchaseEvents.asSharedFlow() - suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result + abstract suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result /** * Returns true if a provider has a quick refunds and the current time since purchase is within that window */ - suspend fun isWithinQuickRefundWindow(): Boolean + abstract suspend fun isWithinQuickRefundWindow(): Boolean /** * Checks whether there is a valid subscription for the current user within this subscriber's billing API */ - suspend fun hasValidSubscription(): Boolean + abstract suspend fun hasValidSubscription(): Boolean /** * Gets a list of pricing for the subscriptions * @throws Exception in case of errors fetching prices */ @Throws(Exception::class) - suspend fun getSubscriptionPrices(): List + abstract suspend fun getSubscriptionPrices(): List + + /** + * Function called when a purchased has been made successfully from the subscription api + */ + protected fun onPurchaseSuccessful(){ + // we need to tie our purchase with the back end + scope.launch { + try { + proStatusManager.appProPaymentToBackend() + _purchaseEvents.emit(PurchaseEvent.Success) + } catch (e: Exception) { + _purchaseEvents.emit(PurchaseEvent.Failed()) + } + } + } data class SubscriptionPricing( val subscriptionDuration: ProSubscriptionDuration, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index 86e5989152..ada81dc034 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -34,8 +34,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -269,81 +271,86 @@ fun SessionProCTA( onDismissRequest = onCancel, content = { DialogBg { - Column(modifier = Modifier.fillMaxWidth()) { - // hero image - BottomFadingEdgeBox( - fadingEdgeHeight = 70.dp, - fadingColor = LocalColors.current.backgroundSecondary, - content = { _ -> - content() - }, - ) - - // content - Column( - modifier = Modifier - .fillMaxWidth() - .padding(LocalDimensions.current.smallSpacing) - ) { - // title - ProBadgeText( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = title, - textStyle = LocalType.current.h5.copy(color = titleColor), - badgeAtStart = badgeAtStart, - badgeColors = if (disabled) proBadgeColorDisabled() else proBadgeColorStandard(), + BoxWithConstraints(Modifier.fillMaxWidth()) { + val heroMaxHeight = maxHeight * 0.4f + Column(modifier = Modifier.fillMaxWidth() + .verticalScroll(rememberScrollState())) { + // hero image + BottomFadingEdgeBox( + modifier = Modifier.heightIn(max = heroMaxHeight), + fadingEdgeHeight = 70.dp, + fadingColor = LocalColors.current.backgroundSecondary, + content = { _ -> + content() + }, ) - Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) - - // main message - textContent() + // content + Column( + modifier = Modifier + .fillMaxWidth() + .padding(LocalDimensions.current.smallSpacing) + ) { + // title + ProBadgeText( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = title, + textStyle = LocalType.current.h5.copy(color = titleColor), + badgeAtStart = badgeAtStart, + badgeColors = if (disabled) proBadgeColorDisabled() else proBadgeColorStandard(), + ) - Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) - // features - if (features.isNotEmpty()) { - features.forEachIndexed { index, feature -> - ProCTAFeature(data = feature) - if (index < features.size - 1) { - Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) - } - } + // main message + textContent() Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) - } - // buttons - Row( - Modifier.height(IntrinsicSize.Min) - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy( - LocalDimensions.current.xsSpacing, - Alignment.CenterHorizontally - ), - ) { - positiveButtonText?.let { - AccentFillButtonRect( - modifier = Modifier.then( - if (negativeButtonText != null) - Modifier.weight(1f) - else Modifier - ).shimmerOverlay(), - text = it, - onClick = onUpgrade ?: defaultUpgrade - ) + // features + if (features.isNotEmpty()) { + features.forEachIndexed { index, feature -> + ProCTAFeature(data = feature) + if (index < features.size - 1) { + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + } + } + + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) } - negativeButtonText?.let { - TertiaryFillButtonRect( - modifier = Modifier.then( - if (positiveButtonText != null) - Modifier.weight(1f) - else Modifier - ), - text = it, - onClick = onCancel - ) + // buttons + Row( + Modifier.height(IntrinsicSize.Min) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + LocalDimensions.current.xsSpacing, + Alignment.CenterHorizontally + ), + ) { + positiveButtonText?.let { + AccentFillButtonRect( + modifier = Modifier.then( + if (negativeButtonText != null) + Modifier.weight(1f) + else Modifier + ).shimmerOverlay(), + text = it, + onClick = onUpgrade ?: defaultUpgrade + ) + } + + negativeButtonText?.let { + TertiaryFillButtonRect( + modifier = Modifier.then( + if (positiveButtonText != null) + Modifier.weight(1f) + else Modifier + ), + text = it, + onClick = onCancel + ) + } } } } @@ -580,8 +587,8 @@ fun GenericProCTA( .format() .toString(), features = listOf( - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), + CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), onCancel = { @@ -613,7 +620,7 @@ fun LongMessageProCTA( .toString(), features = listOf( CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), onCancel = { @@ -646,7 +653,7 @@ fun AnimatedProfilePicProCTA( .toString(), features = listOf( CTAFeature.Icon(stringResource(R.string.proFeatureListAnimatedDisplayPicture)), - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), onCancel = { @@ -680,6 +687,7 @@ fun PinProCTA( .toString() !overTheLimit && expired -> Phrase.from(context, R.string.proRenewPinFiveConversations) + .put(LIMIT_KEY, ProStatusManager.MAX_PIN_REGULAR.toString()) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() .toString() @@ -699,7 +707,7 @@ fun PinProCTA( text = title, features = listOf( CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), onCancel = { @@ -708,23 +716,26 @@ fun PinProCTA( ) } -@Preview +@Preview(name = "Compact", widthDp = 200, heightDp = 300) +@Preview(name = "Medium", widthDp = 720, heightDp = 1280) @Composable private fun PreviewProCTA( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { PreviewTheme(colors) { - SimpleSessionProCTA( - heroImage = R.drawable.cta_hero_char_limit, - text = "This is a description of this Pro feature", - features = listOf( - CTAFeature.Icon("Feature one"), - CTAFeature.Icon("Feature two", R.drawable.ic_eye), - CTAFeature.RainbowIcon("Feature three"), - ), - onUpgrade = {}, - onCancel = {} - ) + Box(Modifier.fillMaxSize()) { + SimpleSessionProCTA( + heroImage = R.drawable.cta_hero_char_limit, + text = "This is a description of this Pro feature", + features = listOf( + CTAFeature.Icon("Feature one"), + CTAFeature.Icon("Feature two", R.drawable.ic_eye), + CTAFeature.RainbowIcon("Feature three"), + ), + onUpgrade = {}, + onCancel = {} + ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt index 6fdcf5ce90..8aa4c05bc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt @@ -38,6 +38,10 @@ class UINavigator () { _navigationActions.send(NavigationAction.NavigateToIntent(intent)) } + suspend fun sendCustomAction(data: Any){ + _navigationActions.send(NavigationAction.PerformCustomAction(data)) + } + suspend fun returnResult(code: String, value: Boolean) { _navigationActions.send(NavigationAction.ReturnResult(code, value)) } @@ -59,4 +63,8 @@ sealed interface NavigationAction { val code: String, val value: Boolean ) : NavigationAction + + data class PerformCustomAction( + val data: Any + ) : NavigationAction } \ No newline at end of file diff --git a/app/src/main/res/drawable/cta_hero_animated_bg.webp b/app/src/main/res/drawable/cta_hero_animated_bg.webp index 9d2ee88e15..d6571612a7 100644 Binary files a/app/src/main/res/drawable/cta_hero_animated_bg.webp and b/app/src/main/res/drawable/cta_hero_animated_bg.webp differ diff --git a/app/src/main/res/drawable/cta_hero_char_limit.webp b/app/src/main/res/drawable/cta_hero_char_limit.webp index 87e7e3e56f..12e3118006 100644 Binary files a/app/src/main/res/drawable/cta_hero_char_limit.webp and b/app/src/main/res/drawable/cta_hero_char_limit.webp differ diff --git a/app/src/main/res/drawable/cta_hero_generic_bg.webp b/app/src/main/res/drawable/cta_hero_generic_bg.webp index f4d150ea6a..2780c953ce 100644 Binary files a/app/src/main/res/drawable/cta_hero_generic_bg.webp and b/app/src/main/res/drawable/cta_hero_generic_bg.webp differ diff --git a/app/src/main/res/drawable/cta_hero_pins.webp b/app/src/main/res/drawable/cta_hero_pins.webp index 8a5070fb59..9fb34151e5 100644 Binary files a/app/src/main/res/drawable/cta_hero_pins.webp and b/app/src/main/res/drawable/cta_hero_pins.webp differ diff --git a/app/src/main/res/drawable/ic_square_play.xml b/app/src/main/res/drawable/ic_square_play.xml index ddec1f8d6c..e69e5cbc11 100644 --- a/app/src/main/res/drawable/ic_square_play.xml +++ b/app/src/main/res/drawable/ic_square_play.xml @@ -1,4 +1,4 @@ - + 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 dce3d0ee46..347f7d4a0f 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 @@ -13,6 +13,8 @@ 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 com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -37,6 +39,7 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import org.thoughtcrime.securesms.util.CurrentActivityObserver import java.time.Instant @@ -50,10 +53,11 @@ import javax.inject.Singleton @Singleton class PlayStoreSubscriptionManager @Inject constructor( private val application: Application, - @param:ManagerScope private val scope: CoroutineScope, private val currentActivityObserver: CurrentActivityObserver, private val prefs: TextSecurePreferences, -) : SubscriptionManager { + proStatusManager: ProStatusManager, + @param:ManagerScope scope: CoroutineScope, +) : SubscriptionManager(proStatusManager, scope) { override val id = "google_play_store" override val name = "Google Play Store" override val description = "" @@ -75,29 +79,17 @@ class PlayStoreSubscriptionManager @Inject constructor( 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(DebugLogGroup.PRO_SUBSCRIPTION.label, "onPurchasesUpdated: $result, $purchases") + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Billing callback. Result: $result, Purchases: ${purchases?.map { it.orderId }}") + 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) - } + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, + "Billing callback. We have a purchase [${it.orderId}]. Acknowledged? ${it.isAcknowledged}") + + onPurchaseSuccessful() } } else { Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Purchase failed or cancelled: $result") @@ -127,8 +119,8 @@ class PlayStoreSubscriptionManager @Inject constructor( val result = getProductDetails() - check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - "Failed to query product details. Reason: ${result.billingResult}" + check(result?.billingResult?.responseCode == BillingClient.BillingResponseCode.OK) { + "Failed to query product details. Reason: ${result?.billingResult}" } val productDetails = checkNotNull(result.productDetailsList?.firstOrNull()) { @@ -194,7 +186,9 @@ class PlayStoreSubscriptionManager @Inject constructor( } } - private suspend fun getProductDetails(): ProductDetailsResult { + private suspend fun getProductDetails(): ProductDetailsResult? { + if(!billingClient.isReady || !_playBillingAvailable.value) return null + return billingClient.queryProductDetails( QueryProductDetailsParams.newBuilder() .setProductList( @@ -212,6 +206,12 @@ class PlayStoreSubscriptionManager @Inject constructor( override fun onPostAppStarted() { super.onPostAppStarted() + if (!hasPlayServices() || !hasPlayStore()) { + _playBillingAvailable.update { false } + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Play Billing unavailable (GMS/Play Store missing).") + return + } + billingClient.startConnection(object : BillingClientStateListener { override fun onBillingServiceDisconnected() { @@ -222,16 +222,35 @@ class PlayStoreSubscriptionManager @Inject constructor( Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "onBillingSetupFinished with $result") if (result.responseCode == BillingClient.BillingResponseCode.OK) { _playBillingAvailable.update { true } + } else { + _playBillingAvailable.update { false } + runCatching { billingClient.endConnection() } } } }) } + private fun hasPlayServices(): Boolean { + val gms = GoogleApiAvailability.getInstance() + return gms.isGooglePlayServicesAvailable(application) == ConnectionResult.SUCCESS + } + + private fun hasPlayStore(): Boolean { + return try { + val ai = application.packageManager.getApplicationInfo("com.android.vending", 0) + ai.enabled + } catch (_: Exception) { + false + } + } + /** * Gets the user's existing active subscription if one exists. * Returns null if no active subscription is found. */ private suspend fun getExistingSubscription(): Purchase? { + if(!billingClient.isReady || !_playBillingAvailable.value) return null + return try { val params = QueryPurchasesParams.newBuilder() .setProductType(BillingClient.ProductType.SUBS) @@ -273,8 +292,8 @@ class PlayStoreSubscriptionManager @Inject constructor( @Throws(Exception::class) override suspend fun getSubscriptionPrices(): List { val result = getProductDetails() - check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - "Failed to query product details. Reason: ${result.billingResult}" + check(result?.billingResult?.responseCode == BillingClient.BillingResponseCode.OK) { + "Failed to query product details. Reason: ${result?.billingResult}" } val productDetails = result.productDetailsList?.firstOrNull() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79a3f4750a..daf49578bb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ assertjCoreVersion = "3.27.6" biometricVersion = "1.1.0" cameraCamera2Version = "1.5.1" cardviewVersion = "1.0.0" -composeBomVersion = "2025.10.01" +composeBomVersion = "2025.11.00" conscryptAndroidVersion = "2.5.3" conscryptJavaVersion = "2.5.2" constraintlayoutVersion = "2.2.1"