diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MockProvider.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MockProvider.kt index daca791f17..9e35a8c33b 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MockProvider.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/MockProvider.kt @@ -61,11 +61,11 @@ object MockProvider { } fun createWhoAmIResponse(aci: ServiceId, pni: ServiceId, e164: String): WhoAmIResponse { - return WhoAmIResponse().apply { - this.uuid = aci.toString() - this.pni = pni.toString() - this.number = e164 - } + return WhoAmIResponse( + aci = aci.toString(), + pni = pni.toString(), + number = e164 + ) } fun createPreKeyResponse(identity: IdentityKeyPair = SignalStore.account.aciIdentityKey, deviceId: Int): PreKeyResponse { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index ab24950b4d..0827268427 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -202,6 +202,11 @@ object BackupRepository { return alertAfter <= now } + @JvmStatic + fun shouldDisplayBackupAlreadyRedeemedIndicator(): Boolean { + return !(shouldNotDisplayBackupFailedMessaging() || !SignalStore.backup.hasBackupAlreadyRedeemedError) + } + /** * Whether the "Backup Failed" row should be displayed in settings. * Shown when the initial backup creation has failed @@ -226,6 +231,10 @@ object BackupRepository { return SignalStore.backup.hasBackupBeenUploaded && SignalStore.backup.hasBackupFailure } + fun markBackupAlreadyRedeemedIndicatorClicked() { + SignalStore.backup.hasBackupAlreadyRedeemedError = false + } + /** * Updates the watermark for the indicator display. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt index 44070c7671..d5b48ba06b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt @@ -12,11 +12,16 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -31,11 +36,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat @@ -47,6 +53,7 @@ import org.signal.core.ui.BottomSheets import org.signal.core.ui.Buttons import org.signal.core.ui.Previews import org.signal.core.ui.SignalPreview +import org.signal.core.ui.theme.SignalTheme import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType @@ -125,6 +132,7 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() { is BackupAlert.MediaBackupsAreOff -> { onSubscribeClick() } + BackupAlert.MediaWillBeDeletedToday -> { performFullMediaDownload() } @@ -132,6 +140,8 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() { is BackupAlert.DiskFull -> Unit is BackupAlert.BackupFailed -> PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext()) + + BackupAlert.CouldNotRedeemBackup -> Unit } dismissAllowingStateLoss() @@ -152,6 +162,7 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() { } // TODO [backups] - Update support URL with backups page BackupAlert.BackupFailed -> CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.backup_support_url)) + BackupAlert.CouldNotRedeemBackup -> CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.backup_support_url)) // TODO [backups] final url } dismissAllowingStateLoss() @@ -224,14 +235,14 @@ private fun BackupAlertSheetContent( BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> { Box { Image( - painter = painterResource(id = R.drawable.image_signal_backups), + imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups), contentDescription = null, modifier = Modifier .size(80.dp) .padding(2.dp) ) Icon( - painter = painterResource(R.drawable.symbol_error_circle_fill_24), + imageVector = ImageVector.vectorResource(R.drawable.symbol_error_circle_fill_24), contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.align(Alignment.TopEnd) @@ -242,7 +253,7 @@ private fun BackupAlertSheetContent( else -> { val iconColors = rememberBackupsIconColors(backupAlert = backupAlert) Icon( - painter = painterResource(id = R.drawable.symbol_backup_light), + imageVector = ImageVector.vectorResource(id = R.drawable.symbol_backup_light), contentDescription = null, tint = iconColors.foreground, modifier = Modifier @@ -270,6 +281,7 @@ private fun BackupAlertSheetContent( BackupAlert.MediaWillBeDeletedToday -> MediaWillBeDeletedTodayBody() is BackupAlert.DiskFull -> DiskFullBody(requiredSpace = backupAlert.requiredSpace) BackupAlert.BackupFailed -> BackupFailedBody() + BackupAlert.CouldNotRedeemBackup -> CouldNotRedeemBackup() } val secondaryActionResource = rememberSecondaryActionResource(backupAlert = backupAlert) @@ -297,6 +309,57 @@ private fun BackupAlertSheetContent( } } +@Composable +private fun CouldNotRedeemBackup() { + Text( + text = stringResource(R.string.BackupAlertBottomSheet__too_many_devices_have_tried), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Row( + modifier = Modifier + .height(IntrinsicSize.Min) + .padding(horizontal = 35.dp) + ) { + Box( + modifier = Modifier + .width(4.dp) + .fillMaxHeight() + .padding(vertical = 2.dp) + .background(color = SignalTheme.colors.colorTransparentInverse2) + ) + + Text( + text = stringResource(R.string.BackupAlertBottomSheet__reregistered_your_signal_account), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp) + ) + } + + Row( + modifier = Modifier + .height(IntrinsicSize.Min) + .padding(horizontal = 35.dp) + .padding(top = 12.dp, bottom = 40.dp) + ) { + Box( + modifier = Modifier + .width(4.dp) + .fillMaxHeight() + .padding(vertical = 2.dp) + .background(color = SignalTheme.colors.colorTransparentInverse2) + ) + + Text( + text = stringResource(R.string.BackupAlertBottomSheet__have_too_many_devices_using_the_same_subscription), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp) + ) + } +} + @Composable private fun CouldNotCompleteBackup( daysSinceLastBackup: Int @@ -390,7 +453,7 @@ private fun rememberBackupsIconColors(backupAlert: BackupAlert): BackupsIconColo return remember(backupAlert) { when (backupAlert) { BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> error("Not icon-based options.") - is BackupAlert.CouldNotCompleteBackup, BackupAlert.BackupFailed, is BackupAlert.DiskFull -> BackupsIconColors.Warning + is BackupAlert.CouldNotCompleteBackup, BackupAlert.BackupFailed, is BackupAlert.DiskFull, BackupAlert.CouldNotRedeemBackup -> BackupsIconColors.Warning BackupAlert.MediaWillBeDeletedToday -> BackupsIconColors.Error } } @@ -405,6 +468,7 @@ private fun titleString(backupAlert: BackupAlert): String { BackupAlert.MediaWillBeDeletedToday -> stringResource(R.string.BackupAlertBottomSheet__your_media_will_be_deleted_today) is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__free_up_s_on_this_device, backupAlert.requiredSpace) BackupAlert.BackupFailed -> stringResource(R.string.BackupAlertBottomSheet__backup_failed) + BackupAlert.CouldNotRedeemBackup -> stringResource(R.string.BackupAlertBottomSheet__couldnt_redeem_your_backups_subscription) } } @@ -420,6 +484,7 @@ private fun primaryActionString( BackupAlert.MediaWillBeDeletedToday -> stringResource(R.string.BackupAlertBottomSheet__download_media_now) is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__got_it) is BackupAlert.BackupFailed -> stringResource(R.string.BackupAlertBottomSheet__check_for_update) + BackupAlert.CouldNotRedeemBackup -> stringResource(R.string.BackupAlertBottomSheet__got_it) } } @@ -433,6 +498,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int { BackupAlert.MediaWillBeDeletedToday -> R.string.BackupAlertBottomSheet__dont_download_media is BackupAlert.DiskFull -> R.string.BackupAlertBottomSheet__skip_restore is BackupAlert.BackupFailed -> R.string.BackupAlertBottomSheet__learn_more + BackupAlert.CouldNotRedeemBackup -> R.string.BackupAlertBottomSheet__learn_more } } } @@ -504,6 +570,17 @@ private fun BackupAlertSheetContentPreviewBackupFailed() { } } +@SignalPreview +@Composable +private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() { + Previews.BottomSheetPreview { + BackupAlertSheetContent( + backupAlert = BackupAlert.CouldNotRedeemBackup, + mediaTtl = 60.days + ) + } +} + /** * All necessary information to display the sheet should be handed in through the specific alert. */ @@ -547,4 +624,9 @@ sealed class BackupAlert : Parcelable { * */ data class DiskFull(val requiredSpace: String) : BackupAlert() + + /** + * Too many attempts to redeem the backup subscription have occurred this month. + */ + data object CouldNotRedeemBackup : BackupAlert() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index ba77211323..2907c1e6f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -231,6 +231,22 @@ private fun AppSettingsContent( } } + BackupFailureState.ALREADY_REDEEMED -> { + item { + Dividers.Default() + + BackupsWarningRow( + text = stringResource(R.string.AppSettingsFragment__couldnt_redeem_your_backups_subscription), + onClick = { + BackupRepository.markBackupAlreadyRedeemedIndicatorClicked() + callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment) + } + ) + + Dividers.Default() + } + } + BackupFailureState.NONE -> Unit } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt index 7d0ba2ddfa..2a5f59b1e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt @@ -77,6 +77,8 @@ class AppSettingsViewModel : ViewModel() { BackupFailureState.COULD_NOT_COMPLETE_BACKUP } else if (SignalStore.backup.subscriptionStateMismatchDetected) { BackupFailureState.SUBSCRIPTION_STATE_MISMATCH + } else if (SignalStore.backup.hasBackupAlreadyRedeemedError) { + BackupFailureState.ALREADY_REDEEMED } else { BackupFailureState.NONE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/BackupFailureState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/BackupFailureState.kt index c3b3666d09..cc5749b5a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/BackupFailureState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/BackupFailureState.kt @@ -12,5 +12,6 @@ enum class BackupFailureState { NONE, BACKUP_FAILED, COULD_NOT_COMPLETE_BACKUP, - SUBSCRIPTION_STATE_MISMATCH + SUBSCRIPTION_STATE_MISMATCH, + ALREADY_REDEEMED } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt index 7f95ecca66..ac34041e08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState @@ -153,7 +154,8 @@ class RemoteBackupsSettingsFragment : ComposeFragment() { backupProgress = backupProgress, backupSize = state.backupSize, backupState = state.backupState, - backupRestoreState = restoreState + backupRestoreState = restoreState, + hasRedemptionError = state.hasRedemptionError ) } @@ -248,6 +250,10 @@ class RemoteBackupsSettingsFragment : ComposeFragment() { override fun onRestoreUsingCellularClick(canUseCellular: Boolean) { viewModel.setCanRestoreUsingCellular(canUseCellular) } + + override fun onRedemptionErrorDetailsClick() { + BackupAlertBottomSheet.create(BackupAlert.CouldNotRedeemBackup).show(parentFragmentManager, null) + } } private fun displayBackupKey() { @@ -331,6 +337,7 @@ private interface ContentCallbacks { fun onContactSupport() = Unit fun onLearnMoreAboutBackupFailure() = Unit fun onRestoreUsingCellularClick(canUseCellular: Boolean) = Unit + fun onRedemptionErrorDetailsClick() = Unit } @Composable @@ -346,7 +353,8 @@ private fun RemoteBackupsSettingsContent( requestedSnackbar: RemoteBackupsSettingsState.Snackbar, contentCallbacks: ContentCallbacks, backupProgress: ArchiveUploadProgressState?, - backupSize: Long + backupSize: Long, + hasRedemptionError: Boolean ) { val snackbarHostState = remember { SnackbarHostState() @@ -364,6 +372,12 @@ private fun RemoteBackupsSettingsContent( modifier = Modifier .padding(it) ) { + if (hasRedemptionError) { + item { + RedemptionErrorAlert(onDetailsClick = contentCallbacks::onRedemptionErrorDetailsClick) + } + } + item { AnimatedContent(backupState, label = "backup-state-block") { state -> when (state) { @@ -771,6 +785,42 @@ private fun BackupCard( } } +@Composable +private fun RedemptionErrorAlert( + onDetailsClick: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 8.dp, bottom = 4.dp) + .border( + width = 1.dp, + color = colorResource(R.color.signal_colorOutline_38), + shape = RoundedCornerShape(12.dp) + ) + .padding(vertical = 16.dp) + .padding(start = 16.dp, end = 12.dp) + ) { + Icon( + painter = painterResource(R.drawable.symbol_backup_error_24), + tint = Color(0xFFFF9500), + contentDescription = null + ) + + Text( + text = stringResource(R.string.AppSettingsFragment__couldnt_redeem_your_backups_subscription), + modifier = Modifier.padding(start = 16.dp, end = 4.dp).weight(1f) + ) + + Buttons.Small(onClick = onDetailsClick) { + Text( + text = stringResource(R.string.RemoteBackupsSettingsFragment__details) + ) + } + } +} + @Composable private fun BoxCard(content: @Composable () -> Unit) { Box( @@ -988,6 +1038,7 @@ private fun getBackupPhaseMessage(state: ArchiveUploadProgressState): String { (progress.progress * 100).toInt() ) } + else -> stringResource(R.string.RemoteBackupsSettingsFragment__preparing_backup) } } @@ -1272,11 +1323,20 @@ private fun RemoteBackupsSettingsContentPreview() { backupState = RemoteBackupsSettingsState.BackupState.ActiveFree( messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30) ), - backupRestoreState = BackupRestoreState.FromBackupStatusData(BackupStatusData.CouldNotCompleteBackup) + backupRestoreState = BackupRestoreState.FromBackupStatusData(BackupStatusData.CouldNotCompleteBackup), + hasRedemptionError = true ) } } +@SignalPreview +@Composable +private fun RedemptionErrorAlertPreview() { + Previews.Preview { + RedemptionErrorAlert { } + } +} + @SignalPreview @Composable private fun LoadingCardPreview() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt index a3db161bb4..d52dce1abd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt @@ -15,6 +15,7 @@ data class RemoteBackupsSettingsState( val backupsEnabled: Boolean, val canBackUpUsingCellular: Boolean = false, val canRestoreUsingCellular: Boolean = false, + val hasRedemptionError: Boolean = false, val backupState: BackupState = BackupState.Loading, val backupSize: Long = 0, val backupsFrequency: BackupFrequency = BackupFrequency.DAILY, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt index b4fd782fc6..4cea7e158f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt @@ -240,6 +240,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() { Log.d(TAG, "Subscription found. Updating UI state with subscription details.") _state.update { it.copy( + hasRedemptionError = lastPurchase?.data?.error?.data_ == "409", backupState = when { subscription.isActive -> RemoteBackupsSettingsState.BackupState.ActivePaid( messageBackupsType = type, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/InAppPaymentsBottomSheetDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/InAppPaymentsBottomSheetDelegate.kt index 07c8abedbd..0e209ebebc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/InAppPaymentsBottomSheetDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/InAppPaymentsBottomSheetDelegate.kt @@ -23,18 +23,17 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.Th import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.SignalDatabase -import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.RemoteConfig /** * Handles displaying bottom sheets for in-app payments. The current policy is to "fire and forget". */ class InAppPaymentsBottomSheetDelegate( private val fragmentManager: FragmentManager, - private val lifecycleOwner: LifecycleOwner, - private vararg val supportedTypes: InAppPaymentSubscriberRecord.Type = arrayOf(InAppPaymentSubscriberRecord.Type.DONATION) + private val lifecycleOwner: LifecycleOwner ) : DefaultLifecycleObserver { companion object { @@ -56,13 +55,11 @@ class InAppPaymentsBottomSheetDelegate( private val badgeRepository = TerminalDonationRepository() override fun onResume(owner: LifecycleOwner) { - if (InAppPaymentSubscriberRecord.Type.DONATION in supportedTypes) { - handleLegacyTerminalDonationSheets() - handleLegacyVerifiedMonthlyDonationSheets() - handleInAppPaymentDonationSheets() - } + handleLegacyTerminalDonationSheets() + handleLegacyVerifiedMonthlyDonationSheets() + handleInAppPaymentDonationSheets() - if (InAppPaymentSubscriberRecord.Type.BACKUP in supportedTypes) { + if (RemoteConfig.messageBackups) { handleInAppPaymentBackupsSheets() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt index fee869175d..e46aab1e9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt @@ -173,7 +173,47 @@ class InAppPaymentRecurringContextJob private constructor( inAppPayment } - submitAndValidateCredentials(updatedInAppPayment, subscription, requestContext) + if (hasEntitlementAlready(inAppPayment, subscription.endOfCurrentPeriod)) { + info("Already have entitlement for this badge. Marking complete.") + markInAppPaymentCompleted(inAppPayment) + } else { + submitAndValidateCredentials(updatedInAppPayment, subscription, requestContext) + } + } + + private fun hasEntitlementAlready( + inAppPayment: InAppPaymentTable.InAppPayment, + endOfCurrentSubscriptionPeriod: Long + ): Boolean { + @Suppress("UsePropertyAccessSyntax") + val whoAmIResponse = AppDependencies.signalServiceAccountManager.getWhoAmI() + + return when (inAppPayment.type) { + InAppPaymentType.RECURRING_BACKUP -> { + val backupExpirationSeconds = whoAmIResponse.entitlements?.backup?.expirationSeconds ?: return false + + backupExpirationSeconds >= endOfCurrentSubscriptionPeriod + } + + InAppPaymentType.RECURRING_DONATION -> { + val donationExpirationSeconds = whoAmIResponse.entitlements?.badges?.firstOrNull { it.id == inAppPayment.data.badge?.id }?.expirationSeconds ?: return false + + donationExpirationSeconds >= endOfCurrentSubscriptionPeriod + } + + else -> error("Unsupported IAP type ${inAppPayment.type}") + } + } + + private fun markInAppPaymentCompleted(inAppPayment: InAppPaymentTable.InAppPayment) { + SignalDatabase.inAppPayments.update( + inAppPayment = inAppPayment.copy( + state = InAppPaymentTable.State.END, + data = inAppPayment.data.copy( + redemption = InAppPaymentData.RedemptionState(stage = InAppPaymentData.RedemptionState.Stage.REDEEMED) + ) + ) + ) } private fun getAndValidateInAppPayment(): Pair { @@ -435,18 +475,13 @@ class InAppPaymentRecurringContextJob private constructor( } 409 -> { - if (isForKeepAlive) { - warning("Already redeemed this token during keep-alive, ignoring.", applicationError) - SignalDatabase.inAppPayments.update( - inAppPayment.copy( - state = InAppPaymentTable.State.END, - data = inAppPayment.data.copy(redemption = inAppPayment.data.redemption.copy(stage = InAppPaymentData.RedemptionState.Stage.REDEEMED)) - ) - ) - } else { - warning("Already redeemed this token during new subscription. Failing.", applicationError) - updateInAppPaymentWithGenericRedemptionError(inAppPayment) + warning("Already redeemed this token during new subscription. Failing.", applicationError) + + if (inAppPayment.type == InAppPaymentType.RECURRING_BACKUP) { + SignalStore.backup.hasBackupAlreadyRedeemedError = true } + + updateInAppPaymentWithTokenAlreadyRedeemedError(inAppPayment) } else -> { @@ -520,6 +555,20 @@ class InAppPaymentRecurringContextJob private constructor( return isSameLevel && isExpirationAfterSub && isExpiration86400 && isExpirationInTheFuture && isExpirationWithinMax } + private fun updateInAppPaymentWithTokenAlreadyRedeemedError(inAppPayment: InAppPaymentTable.InAppPayment) { + SignalDatabase.inAppPayments.update( + inAppPayment = inAppPayment.copy( + state = InAppPaymentTable.State.END, + data = inAppPayment.data.copy( + error = InAppPaymentData.Error( + type = InAppPaymentData.Error.Type.REDEMPTION, + data_ = "409" + ) + ) + ) + ) + } + private fun updateInAppPaymentWithGenericRedemptionError(inAppPayment: InAppPaymentTable.InAppPayment) { SignalDatabase.inAppPayments.update( inAppPayment = inAppPayment.copy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index 4fc2b9945c..f07cea117c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -63,6 +63,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_COUNT = "backup.failed.acknowledged.snooze.count" private const val KEY_BACKUP_FAIL_SHEET_SNOOZE_TIME = "backup.failed.sheet.snooze" private const val KEY_BACKUP_FAIL_SPACE_REMAINING = "backup.failed.space.remaining" + private const val KEY_BACKUP_ALREADY_REDEEMED = "backup.already.redeemed" private const val KEY_USER_MANUALLY_SKIPPED_MEDIA_RESTORE = "backup.user.manually.skipped.media.restore" @@ -209,6 +210,8 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { val nextBackupFailureSnoozeTime: Duration get() = getLong(KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_TIME, 0L).milliseconds val nextBackupFailureSheetSnoozeTime: Duration get() = getLong(KEY_BACKUP_FAIL_SHEET_SNOOZE_TIME, getNextBackupFailureSheetSnoozeTime(lastBackupTime.milliseconds).inWholeMilliseconds).milliseconds + var hasBackupAlreadyRedeemedError: Boolean by booleanValue(KEY_BACKUP_ALREADY_REDEEMED, false) + /** * Denotes how many bytes are still available on the disk for writing. Used to display * the disk full error and sheet. Set when we believe there might be an "out of space" diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt index 5a79ed541b..b09e6cb305 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt @@ -166,7 +166,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f super.onResume() SimpleTask.run(viewLifecycleOwner.lifecycle, { Recipient.self() }, ::initializeProfileIcon) - _backupsFailedDot.alpha = if (BackupRepository.shouldDisplayBackupFailedIndicator()) { + _backupsFailedDot.alpha = if (BackupRepository.shouldDisplayBackupFailedIndicator() || BackupRepository.shouldDisplayBackupAlreadyRedeemedIndicator()) { 1f } else { 0f diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 51a36d9fe1..d09c35acd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5015,6 +5015,8 @@ Renew your Signal Backups subscription Couldn\'t complete backup + + Couldn\'t redeem your backups subscription Invite your friends @@ -7539,10 +7541,18 @@ If you skip restore the remaining media and attachments in your backup can be downloaded at a later time when storage space becomes available. Backup failed + + Couldn\'t redeem your backups subscription An error occurred and your backup could not be completed. Make sure you\'re on the latest version of Signal and try again. If this problem persists, contact support. Check for update + + Too many devices have tried to redeem your subscription this month. You may have: + + Re-registered your Signal account too many times. + + Have too many devices using the same subscription. @@ -7824,6 +7834,8 @@ A network error occurred. Please check your internet connection and try again. Uploading: %1$s of %2$s (%3$d%%) + + Details diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.java deleted file mode 100644 index 2920ccd728..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.whispersystems.signalservice.internal.push; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class WhoAmIResponse { - @JsonProperty - public String uuid; - - @JsonProperty - public String pni; - - @JsonProperty - public String number; - - @JsonProperty - public String usernameHash; - - public String getAci() { - return uuid; - } - - public String getPni() { - return pni; - } - - public String getNumber() { - return number; - } - - public String getUsernameHash() { - return usernameHash; - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.kt new file mode 100644 index 0000000000..31265cb30b --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/WhoAmIResponse.kt @@ -0,0 +1,31 @@ +package org.whispersystems.signalservice.internal.push + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Response object for /v1/accounts/whoami + */ +data class WhoAmIResponse @JsonCreator constructor( + @JsonProperty("uuid") val aci: String? = null, + @JsonProperty val pni: String? = null, + @JsonProperty val number: String, + @JsonProperty val usernameHash: String? = null, + @JsonProperty val entitlements: Entitlements? = null +) { + data class Entitlements @JsonCreator constructor( + @JsonProperty val badges: List? = null, + @JsonProperty val backup: BackupEntitlement? = null + ) + + data class BadgeEntitlement @JsonCreator constructor( + @JsonProperty val id: String?, + @JsonProperty val visible: Boolean?, + @JsonProperty val expirationSeconds: Long? + ) + + data class BackupEntitlement @JsonCreator constructor( + @JsonProperty val backupLevel: Long?, + @JsonProperty val expirationSeconds: Long? + ) +}