diff --git a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt index 542203da87..4008f3f1bb 100644 --- a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt +++ b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt @@ -62,4 +62,7 @@ object StringSubstitutionConstants { const val PERCENT_KEY: StringSubKey = "percent" const val DEVICE_TYPE_KEY: StringSubKey = "device_type" const val SESSION_FOUNDATION_KEY: StringSubKey = "session_foundation" + const val ACTION_TYPE_KEY: StringSubKey = "action_type" + const val ACTIVATION_TYPE_KEY: StringSubKey = "activation_type" + const val ENTITY_KEY: StringSubKey = "entity" } \ No newline at end of file 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 adac24d3ed..f0b3d3a7b8 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 @@ -907,14 +907,16 @@ fun ProSettingsFooter( inSheet: Boolean, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ) { - // Manage Pro - Pro - Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) - ProManage( - data = subscriptionType, - inSheet = inSheet, - subscriptionRefreshState = subscriptionRefreshState, - sendCommand = sendCommand, - ) + // Manage Pro - Expired has this in the header so exclude it here + if(subscriptionType !is SubscriptionType.Expired) { + 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/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index f04198ddd4..c56c7483eb 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 @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.ACTION_TYPE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_LENGTH_KEY @@ -33,7 +34,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_SINGULAR_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY -import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionState @@ -108,7 +108,7 @@ class ProSettingsViewModel @AssistedInject constructor( navigator.navigate(destination = ProSettingsDestination.PlanConfirmation) } - is SubscriptionManager.PurchaseEvent.Failed -> { + is SubscriptionManager.PurchaseEvent.Failed.GenericError -> { Toast.makeText( context, purchaseEvent.errorMessage ?: context.getString(R.string.errorGeneric), @@ -116,6 +116,39 @@ class ProSettingsViewModel @AssistedInject constructor( ).show() } + is SubscriptionManager.PurchaseEvent.Failed.ServerError -> { + // this is a special case of failure. We should display a custom dialog and allow the user to retry + _dialogState.update { + val action = context.getString( + when(_proSettingsUIState.value.subscriptionState.type) { + is SubscriptionType.Active -> R.string.proUpdatingAction + is SubscriptionType.Expired -> R.string.proRenewingAction + else -> R.string.proUpgradingAction + } + ) + + it.copy( + showSimpleDialog = SimpleDialogData( + title = context.getString(R.string.paymentError), + message = Phrase.from(context, R.string.paymentProError) + .put(ACTION_TYPE_KEY, action) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format(), + positiveText = context.getString(R.string.retry), + negativeText = context.getString(R.string.helpSupport), + positiveStyleDanger = false, + showXIcon = true, + onPositive = { + getPlanFromProvider() // retry getting the plan from provider + }, + onNegative = { + onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) + } + ) + ) + } + } + is SubscriptionManager.PurchaseEvent.Cancelled -> { // nothing to do in this case } @@ -699,12 +732,15 @@ class ProSettingsViewModel @AssistedInject constructor( viewModelScope.launch { val selectedPlan = getSelectedPlan() ?: return@launch - val purchaseStarted = subscriptionCoordinator.getCurrentManager().purchasePlan( + // let the provider handle the plan from their UI + val providerResult = subscriptionCoordinator.getCurrentManager().purchasePlan( selectedPlan.durationType ) + // check if we managed to display the plan from the provider val data = choosePlanState.value - if(purchaseStarted.isSuccess && data is State.Success) { + if(providerResult.isSuccess && data is State.Success) { + // show a loader while the user is looking at the UI from the provider _choosePlanState.update { State.Success( data.value.copy(purchaseInProgress = true) @@ -714,10 +750,6 @@ class ProSettingsViewModel @AssistedInject constructor( } } - fun getSubscriptionManager(): SubscriptionManager { - return subscriptionCoordinator.getCurrentManager() - } - private fun navigateTo( destination: ProSettingsDestination, navOptions: NavOptionsBuilder.() -> Unit = {} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt index 7cc98f1380..2f6c8c2700 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt @@ -48,9 +48,13 @@ import com.squareup.phrase.Phrase import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.ACTION_TYPE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.ACTIVATION_TYPE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.ENTITY_KEY import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY @@ -191,25 +195,26 @@ fun ChoosePlan( Spacer(Modifier.height(LocalDimensions.current.xxsSpacing)) - val footer = when (planData.subscriptionType) { + val (footerAction, footerActivation) = when (planData.subscriptionType) { is SubscriptionType.Expired -> - Phrase.from(LocalContext.current.getText(R.string.proRenewTosPrivacy)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(ICON_KEY, iconExternalLink) - .format() + stringResource(R.string.proRenewingAction) to + stringResource(R.string.proReactivatingActivation) - is SubscriptionType.Active -> Phrase.from(LocalContext.current.getText(R.string.proTosPrivacy)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(ICON_KEY, iconExternalLink) - .format() + + is SubscriptionType.Active -> stringResource(R.string.proUpdatingAction) to "" is SubscriptionType.NeverSubscribed -> - Phrase.from(LocalContext.current.getText(R.string.proUpgradingTosPrivacy)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(ICON_KEY, iconExternalLink) - .format() + stringResource(R.string.proUpgradingAction) to + stringResource(R.string.proActivatingActivation) + } + val footer = Phrase.from(LocalContext.current.getText(R.string.noteTosPrivacyPolicy)) + .put(ACTION_TYPE_KEY, footerAction) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(ICON_KEY, iconExternalLink) + .format() + Text( modifier = Modifier.fillMaxWidth() .clickable( @@ -224,13 +229,33 @@ fun ChoosePlan( .clip(MaterialTheme.shapes.extraSmall), text = annotatedStringResource(footer), textAlign = TextAlign.Center, - style = LocalType.current.small, + style = LocalType.current.base, color = LocalColors.current.text, inlineContent = inlineContentMap( textSize = LocalType.current.small.fontSize, imageColor = LocalColors.current.text ), ) + + // add another label in cases other than an active subscription + if (planData.subscriptionType !is SubscriptionType.Active) { + Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) + Text( + modifier = Modifier.fillMaxWidth(), + text = annotatedStringResource( + Phrase.from(LocalContext.current.getText(R.string.proTosDescription)) + .put(ACTION_TYPE_KEY, footerAction) + .put(ACTIVATION_TYPE_KEY, footerActivation) + .put(ENTITY_KEY, NonTranslatableStringConstants.ENTITY_STF) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .format() + ), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.text, + ) + } } } 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 62b87f16c4..d8325ac7c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.pro import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.ProStatus @@ -25,6 +28,7 @@ import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.util.State import java.time.Duration import java.time.Instant @@ -272,32 +276,53 @@ class ProStatusManager @Inject constructor( } 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, - */ + // max 3 attempts as per PRD + val maxAttempts = 3 + + for (attempt in 1..maxAttempts) { + try { + // 5s timeout as per PRD + withTimeout(5_000L) { + //todo PRO call AddProPaymentRequest in libsession + /** + * Here are the errors from the back end that we will need to be aware of + * UnknownPayment: retryable > increment counter and try again + * Error, ParseError: is non retryable - throw PaymentServerException + * Success, AlreadyRedeemed - all good + * + * + * /// 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, + */ + + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // If not the last attempt, backoff a little and retry + if (attempt < maxAttempts) { + // small incremental backoff before retry + val backoffMs = 300L * attempt + delay(backoffMs) + } + } + } + + // All attempts failed - throw our custom exception + throw SubscriptionManager.PaymentServerException() } enum class MessageProFeature { 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 6c3d9ddd74..f365e1ca3d 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 @@ -34,7 +34,10 @@ abstract class SubscriptionManager( sealed interface PurchaseEvent { data object Success : PurchaseEvent data object Cancelled : PurchaseEvent - data class Failed(val errorMessage: String? = null) : PurchaseEvent + sealed interface Failed : PurchaseEvent { + data class GenericError(val errorMessage: String? = null): Failed + data object ServerError : Failed + } } // purchase events @@ -74,11 +77,16 @@ abstract class SubscriptionManager( proStatusManager.appProPaymentToBackend() _purchaseEvents.emit(PurchaseEvent.Success) } catch (e: Exception) { - _purchaseEvents.emit(PurchaseEvent.Failed()) + when (e) { + is PaymentServerException -> _purchaseEvents.emit(PurchaseEvent.Failed.ServerError) + else -> _purchaseEvents.emit(PurchaseEvent.Failed.GenericError()) + } } } } + class PaymentServerException: Exception() + data class SubscriptionPricing( val subscriptionDuration: ProSubscriptionDuration, val priceAmountMicros: Long, 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 347f7d4a0f..c2a2a7eb27 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 @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.pro.subscription import android.app.Application -import android.widget.Toast import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams @@ -17,15 +16,10 @@ 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 -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 @@ -33,14 +27,11 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import network.loki.messenger.R 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 import java.time.temporal.ChronoUnit @@ -178,9 +169,8 @@ class PlayStoreSubscriptionManager @Inject constructor( } catch (e: Exception) { Log.e(DebugLogGroup.PRO_SUBSCRIPTION.label, "Error purchase plan", e) - withContext(Dispatchers.Main) { - Toast.makeText(application, application.getString(R.string.errorGeneric), Toast.LENGTH_LONG).show() - } + // pass the purchase error information to subscribers + _purchaseEvents.emit(PurchaseEvent.Failed.GenericError()) return Result.failure(e) }