Add whoami check for receipt_credentials.

This commit is contained in:
Alex Hart 2025-01-09 10:04:15 -04:00 committed by Greyson Parrelli
parent 0dbab7ede0
commit 23f90e070e
16 changed files with 300 additions and 69 deletions

View file

@ -61,11 +61,11 @@ object MockProvider {
} }
fun createWhoAmIResponse(aci: ServiceId, pni: ServiceId, e164: String): WhoAmIResponse { fun createWhoAmIResponse(aci: ServiceId, pni: ServiceId, e164: String): WhoAmIResponse {
return WhoAmIResponse().apply { return WhoAmIResponse(
this.uuid = aci.toString() aci = aci.toString(),
this.pni = pni.toString() pni = pni.toString(),
this.number = e164 number = e164
} )
} }
fun createPreKeyResponse(identity: IdentityKeyPair = SignalStore.account.aciIdentityKey, deviceId: Int): PreKeyResponse { fun createPreKeyResponse(identity: IdentityKeyPair = SignalStore.account.aciIdentityKey, deviceId: Int): PreKeyResponse {

View file

@ -202,6 +202,11 @@ object BackupRepository {
return alertAfter <= now return alertAfter <= now
} }
@JvmStatic
fun shouldDisplayBackupAlreadyRedeemedIndicator(): Boolean {
return !(shouldNotDisplayBackupFailedMessaging() || !SignalStore.backup.hasBackupAlreadyRedeemedError)
}
/** /**
* Whether the "Backup Failed" row should be displayed in settings. * Whether the "Backup Failed" row should be displayed in settings.
* Shown when the initial backup creation has failed * Shown when the initial backup creation has failed
@ -226,6 +231,10 @@ object BackupRepository {
return SignalStore.backup.hasBackupBeenUploaded && SignalStore.backup.hasBackupFailure return SignalStore.backup.hasBackupBeenUploaded && SignalStore.backup.hasBackupFailure
} }
fun markBackupAlreadyRedeemedIndicatorClicked() {
SignalStore.backup.hasBackupAlreadyRedeemedError = false
}
/** /**
* Updates the watermark for the indicator display. * Updates the watermark for the indicator display.
*/ */

View file

@ -12,11 +12,16 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Spacer
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -31,11 +36,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat 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.Buttons
import org.signal.core.ui.Previews import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview import org.signal.core.ui.SignalPreview
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
@ -125,6 +132,7 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
is BackupAlert.MediaBackupsAreOff -> { is BackupAlert.MediaBackupsAreOff -> {
onSubscribeClick() onSubscribeClick()
} }
BackupAlert.MediaWillBeDeletedToday -> { BackupAlert.MediaWillBeDeletedToday -> {
performFullMediaDownload() performFullMediaDownload()
} }
@ -132,6 +140,8 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
is BackupAlert.DiskFull -> Unit is BackupAlert.DiskFull -> Unit
is BackupAlert.BackupFailed -> is BackupAlert.BackupFailed ->
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext()) PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
BackupAlert.CouldNotRedeemBackup -> Unit
} }
dismissAllowingStateLoss() dismissAllowingStateLoss()
@ -152,6 +162,7 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
} }
// TODO [backups] - Update support URL with backups page // TODO [backups] - Update support URL with backups page
BackupAlert.BackupFailed -> CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.backup_support_url)) 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() dismissAllowingStateLoss()
@ -224,14 +235,14 @@ private fun BackupAlertSheetContent(
BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> { BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> {
Box { Box {
Image( Image(
painter = painterResource(id = R.drawable.image_signal_backups), imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups),
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(80.dp) .size(80.dp)
.padding(2.dp) .padding(2.dp)
) )
Icon( Icon(
painter = painterResource(R.drawable.symbol_error_circle_fill_24), imageVector = ImageVector.vectorResource(R.drawable.symbol_error_circle_fill_24),
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
modifier = Modifier.align(Alignment.TopEnd) modifier = Modifier.align(Alignment.TopEnd)
@ -242,7 +253,7 @@ private fun BackupAlertSheetContent(
else -> { else -> {
val iconColors = rememberBackupsIconColors(backupAlert = backupAlert) val iconColors = rememberBackupsIconColors(backupAlert = backupAlert)
Icon( Icon(
painter = painterResource(id = R.drawable.symbol_backup_light), imageVector = ImageVector.vectorResource(id = R.drawable.symbol_backup_light),
contentDescription = null, contentDescription = null,
tint = iconColors.foreground, tint = iconColors.foreground,
modifier = Modifier modifier = Modifier
@ -270,6 +281,7 @@ private fun BackupAlertSheetContent(
BackupAlert.MediaWillBeDeletedToday -> MediaWillBeDeletedTodayBody() BackupAlert.MediaWillBeDeletedToday -> MediaWillBeDeletedTodayBody()
is BackupAlert.DiskFull -> DiskFullBody(requiredSpace = backupAlert.requiredSpace) is BackupAlert.DiskFull -> DiskFullBody(requiredSpace = backupAlert.requiredSpace)
BackupAlert.BackupFailed -> BackupFailedBody() BackupAlert.BackupFailed -> BackupFailedBody()
BackupAlert.CouldNotRedeemBackup -> CouldNotRedeemBackup()
} }
val secondaryActionResource = rememberSecondaryActionResource(backupAlert = backupAlert) 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 @Composable
private fun CouldNotCompleteBackup( private fun CouldNotCompleteBackup(
daysSinceLastBackup: Int daysSinceLastBackup: Int
@ -390,7 +453,7 @@ private fun rememberBackupsIconColors(backupAlert: BackupAlert): BackupsIconColo
return remember(backupAlert) { return remember(backupAlert) {
when (backupAlert) { when (backupAlert) {
BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> error("Not icon-based options.") 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 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) 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) is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__free_up_s_on_this_device, backupAlert.requiredSpace)
BackupAlert.BackupFailed -> stringResource(R.string.BackupAlertBottomSheet__backup_failed) 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) BackupAlert.MediaWillBeDeletedToday -> stringResource(R.string.BackupAlertBottomSheet__download_media_now)
is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__got_it) is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__got_it)
is BackupAlert.BackupFailed -> stringResource(R.string.BackupAlertBottomSheet__check_for_update) 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 BackupAlert.MediaWillBeDeletedToday -> R.string.BackupAlertBottomSheet__dont_download_media
is BackupAlert.DiskFull -> R.string.BackupAlertBottomSheet__skip_restore is BackupAlert.DiskFull -> R.string.BackupAlertBottomSheet__skip_restore
is BackupAlert.BackupFailed -> R.string.BackupAlertBottomSheet__learn_more 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. * 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() data class DiskFull(val requiredSpace: String) : BackupAlert()
/**
* Too many attempts to redeem the backup subscription have occurred this month.
*/
data object CouldNotRedeemBackup : BackupAlert()
} }

View file

@ -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 BackupFailureState.NONE -> Unit
} }

View file

@ -77,6 +77,8 @@ class AppSettingsViewModel : ViewModel() {
BackupFailureState.COULD_NOT_COMPLETE_BACKUP BackupFailureState.COULD_NOT_COMPLETE_BACKUP
} else if (SignalStore.backup.subscriptionStateMismatchDetected) { } else if (SignalStore.backup.subscriptionStateMismatchDetected) {
BackupFailureState.SUBSCRIPTION_STATE_MISMATCH BackupFailureState.SUBSCRIPTION_STATE_MISMATCH
} else if (SignalStore.backup.hasBackupAlreadyRedeemedError) {
BackupFailureState.ALREADY_REDEEMED
} else { } else {
BackupFailureState.NONE BackupFailureState.NONE
} }

View file

@ -12,5 +12,6 @@ enum class BackupFailureState {
NONE, NONE,
BACKUP_FAILED, BACKUP_FAILED,
COULD_NOT_COMPLETE_BACKUP, COULD_NOT_COMPLETE_BACKUP,
SUBSCRIPTION_STATE_MISMATCH SUBSCRIPTION_STATE_MISMATCH,
ALREADY_REDEEMED
} }

View file

@ -36,6 +36,7 @@ import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@ -153,7 +154,8 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
backupProgress = backupProgress, backupProgress = backupProgress,
backupSize = state.backupSize, backupSize = state.backupSize,
backupState = state.backupState, backupState = state.backupState,
backupRestoreState = restoreState backupRestoreState = restoreState,
hasRedemptionError = state.hasRedemptionError
) )
} }
@ -248,6 +250,10 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
override fun onRestoreUsingCellularClick(canUseCellular: Boolean) { override fun onRestoreUsingCellularClick(canUseCellular: Boolean) {
viewModel.setCanRestoreUsingCellular(canUseCellular) viewModel.setCanRestoreUsingCellular(canUseCellular)
} }
override fun onRedemptionErrorDetailsClick() {
BackupAlertBottomSheet.create(BackupAlert.CouldNotRedeemBackup).show(parentFragmentManager, null)
}
} }
private fun displayBackupKey() { private fun displayBackupKey() {
@ -331,6 +337,7 @@ private interface ContentCallbacks {
fun onContactSupport() = Unit fun onContactSupport() = Unit
fun onLearnMoreAboutBackupFailure() = Unit fun onLearnMoreAboutBackupFailure() = Unit
fun onRestoreUsingCellularClick(canUseCellular: Boolean) = Unit fun onRestoreUsingCellularClick(canUseCellular: Boolean) = Unit
fun onRedemptionErrorDetailsClick() = Unit
} }
@Composable @Composable
@ -346,7 +353,8 @@ private fun RemoteBackupsSettingsContent(
requestedSnackbar: RemoteBackupsSettingsState.Snackbar, requestedSnackbar: RemoteBackupsSettingsState.Snackbar,
contentCallbacks: ContentCallbacks, contentCallbacks: ContentCallbacks,
backupProgress: ArchiveUploadProgressState?, backupProgress: ArchiveUploadProgressState?,
backupSize: Long backupSize: Long,
hasRedemptionError: Boolean
) { ) {
val snackbarHostState = remember { val snackbarHostState = remember {
SnackbarHostState() SnackbarHostState()
@ -364,6 +372,12 @@ private fun RemoteBackupsSettingsContent(
modifier = Modifier modifier = Modifier
.padding(it) .padding(it)
) { ) {
if (hasRedemptionError) {
item {
RedemptionErrorAlert(onDetailsClick = contentCallbacks::onRedemptionErrorDetailsClick)
}
}
item { item {
AnimatedContent(backupState, label = "backup-state-block") { state -> AnimatedContent(backupState, label = "backup-state-block") { state ->
when (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 @Composable
private fun BoxCard(content: @Composable () -> Unit) { private fun BoxCard(content: @Composable () -> Unit) {
Box( Box(
@ -988,6 +1038,7 @@ private fun getBackupPhaseMessage(state: ArchiveUploadProgressState): String {
(progress.progress * 100).toInt() (progress.progress * 100).toInt()
) )
} }
else -> stringResource(R.string.RemoteBackupsSettingsFragment__preparing_backup) else -> stringResource(R.string.RemoteBackupsSettingsFragment__preparing_backup)
} }
} }
@ -1272,11 +1323,20 @@ private fun RemoteBackupsSettingsContentPreview() {
backupState = RemoteBackupsSettingsState.BackupState.ActiveFree( backupState = RemoteBackupsSettingsState.BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30) 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 @SignalPreview
@Composable @Composable
private fun LoadingCardPreview() { private fun LoadingCardPreview() {

View file

@ -15,6 +15,7 @@ data class RemoteBackupsSettingsState(
val backupsEnabled: Boolean, val backupsEnabled: Boolean,
val canBackUpUsingCellular: Boolean = false, val canBackUpUsingCellular: Boolean = false,
val canRestoreUsingCellular: Boolean = false, val canRestoreUsingCellular: Boolean = false,
val hasRedemptionError: Boolean = false,
val backupState: BackupState = BackupState.Loading, val backupState: BackupState = BackupState.Loading,
val backupSize: Long = 0, val backupSize: Long = 0,
val backupsFrequency: BackupFrequency = BackupFrequency.DAILY, val backupsFrequency: BackupFrequency = BackupFrequency.DAILY,

View file

@ -240,6 +240,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
Log.d(TAG, "Subscription found. Updating UI state with subscription details.") Log.d(TAG, "Subscription found. Updating UI state with subscription details.")
_state.update { _state.update {
it.copy( it.copy(
hasRedemptionError = lastPurchase?.data?.error?.data_ == "409",
backupState = when { backupState = when {
subscription.isActive -> RemoteBackupsSettingsState.BackupState.ActivePaid( subscription.isActive -> RemoteBackupsSettingsState.BackupState.ActivePaid(
messageBackupsType = type, messageBackupsType = type,

View file

@ -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.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase 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.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.keyvalue.SignalStore 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". * Handles displaying bottom sheets for in-app payments. The current policy is to "fire and forget".
*/ */
class InAppPaymentsBottomSheetDelegate( class InAppPaymentsBottomSheetDelegate(
private val fragmentManager: FragmentManager, private val fragmentManager: FragmentManager,
private val lifecycleOwner: LifecycleOwner, private val lifecycleOwner: LifecycleOwner
private vararg val supportedTypes: InAppPaymentSubscriberRecord.Type = arrayOf(InAppPaymentSubscriberRecord.Type.DONATION)
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
companion object { companion object {
@ -56,13 +55,11 @@ class InAppPaymentsBottomSheetDelegate(
private val badgeRepository = TerminalDonationRepository() private val badgeRepository = TerminalDonationRepository()
override fun onResume(owner: LifecycleOwner) { override fun onResume(owner: LifecycleOwner) {
if (InAppPaymentSubscriberRecord.Type.DONATION in supportedTypes) {
handleLegacyTerminalDonationSheets() handleLegacyTerminalDonationSheets()
handleLegacyVerifiedMonthlyDonationSheets() handleLegacyVerifiedMonthlyDonationSheets()
handleInAppPaymentDonationSheets() handleInAppPaymentDonationSheets()
}
if (InAppPaymentSubscriberRecord.Type.BACKUP in supportedTypes) { if (RemoteConfig.messageBackups) {
handleInAppPaymentBackupsSheets() handleInAppPaymentBackupsSheets()
} }
} }

View file

@ -173,8 +173,48 @@ class InAppPaymentRecurringContextJob private constructor(
inAppPayment inAppPayment
} }
if (hasEntitlementAlready(inAppPayment, subscription.endOfCurrentPeriod)) {
info("Already have entitlement for this badge. Marking complete.")
markInAppPaymentCompleted(inAppPayment)
} else {
submitAndValidateCredentials(updatedInAppPayment, subscription, requestContext) 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<InAppPaymentTable.InAppPayment, ReceiptCredentialRequestContext> { private fun getAndValidateInAppPayment(): Pair<InAppPaymentTable.InAppPayment, ReceiptCredentialRequestContext> {
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId) val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
@ -435,18 +475,13 @@ class InAppPaymentRecurringContextJob private constructor(
} }
409 -> { 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) warning("Already redeemed this token during new subscription. Failing.", applicationError)
updateInAppPaymentWithGenericRedemptionError(inAppPayment)
if (inAppPayment.type == InAppPaymentType.RECURRING_BACKUP) {
SignalStore.backup.hasBackupAlreadyRedeemedError = true
} }
updateInAppPaymentWithTokenAlreadyRedeemedError(inAppPayment)
} }
else -> { else -> {
@ -520,6 +555,20 @@ class InAppPaymentRecurringContextJob private constructor(
return isSameLevel && isExpirationAfterSub && isExpiration86400 && isExpirationInTheFuture && isExpirationWithinMax 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) { private fun updateInAppPaymentWithGenericRedemptionError(inAppPayment: InAppPaymentTable.InAppPayment) {
SignalDatabase.inAppPayments.update( SignalDatabase.inAppPayments.update(
inAppPayment = inAppPayment.copy( inAppPayment = inAppPayment.copy(

View file

@ -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_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_SHEET_SNOOZE_TIME = "backup.failed.sheet.snooze"
private const val KEY_BACKUP_FAIL_SPACE_REMAINING = "backup.failed.space.remaining" 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" 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 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 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 * 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" * the disk full error and sheet. Set when we believe there might be an "out of space"

View file

@ -166,7 +166,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
super.onResume() super.onResume()
SimpleTask.run(viewLifecycleOwner.lifecycle, { Recipient.self() }, ::initializeProfileIcon) SimpleTask.run(viewLifecycleOwner.lifecycle, { Recipient.self() }, ::initializeProfileIcon)
_backupsFailedDot.alpha = if (BackupRepository.shouldDisplayBackupFailedIndicator()) { _backupsFailedDot.alpha = if (BackupRepository.shouldDisplayBackupFailedIndicator() || BackupRepository.shouldDisplayBackupAlreadyRedeemedIndicator()) {
1f 1f
} else { } else {
0f 0f

View file

@ -5015,6 +5015,8 @@
<string name="AppSettingsFragment__renew_your_signal_backups_subscription">Renew your Signal Backups subscription</string> <string name="AppSettingsFragment__renew_your_signal_backups_subscription">Renew your Signal Backups subscription</string>
<!-- String alerting user that backup failed --> <!-- String alerting user that backup failed -->
<string name="AppSettingsFragment__couldnt_complete_backup">Couldn\'t complete backup</string> <string name="AppSettingsFragment__couldnt_complete_backup">Couldn\'t complete backup</string>
<!-- String alerting user that backup redemption -->
<string name="AppSettingsFragment__couldnt_redeem_your_backups_subscription">Couldn\'t redeem your backups subscription</string>
<!-- String displayed telling user to invite their friends to Signal --> <!-- String displayed telling user to invite their friends to Signal -->
<string name="AppSettingsFragment__invite_your_friends">Invite your friends</string> <string name="AppSettingsFragment__invite_your_friends">Invite your friends</string>
<!-- String displayed in a toast when we successfully copy the donations subscriber id to the clipboard --> <!-- String displayed in a toast when we successfully copy the donations subscriber id to the clipboard -->
@ -7539,10 +7541,18 @@
<string name="BackupAlertBottomSheet__if_you_skip_restore_the">If you skip restore the remaining media and attachments in your backup can be downloaded at a later time when storage space becomes available.</string> <string name="BackupAlertBottomSheet__if_you_skip_restore_the">If you skip restore the remaining media and attachments in your backup can be downloaded at a later time when storage space becomes available.</string>
<!-- Dialog title when a backup fails to be created --> <!-- Dialog title when a backup fails to be created -->
<string name="BackupAlertBottomSheet__backup_failed">Backup failed</string> <string name="BackupAlertBottomSheet__backup_failed">Backup failed</string>
<!-- Dialog title when a backup redemption fails -->
<string name="BackupAlertBottomSheet__couldnt_redeem_your_backups_subscription">Couldn\'t redeem your backups subscription</string>
<!-- Dialog text for when a backup fails to be created and ways to fix it --> <!-- Dialog text for when a backup fails to be created and ways to fix it -->
<string name="BackupAlertBottomSheet__an_error_occurred">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.</string> <string name="BackupAlertBottomSheet__an_error_occurred">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.</string>
<!-- Dialog action button that will allow you to check for any Signal version updates --> <!-- Dialog action button that will allow you to check for any Signal version updates -->
<string name="BackupAlertBottomSheet__check_for_update">Check for update</string> <string name="BackupAlertBottomSheet__check_for_update">Check for update</string>
<!-- Backup redemption error sheet text line 1 -->
<string name="BackupAlertBottomSheet__too_many_devices_have_tried">Too many devices have tried to redeem your subscription this month. You may have:</string>
<!-- Backup redemption error sheet bullet point 1 -->
<string name="BackupAlertBottomSheet__reregistered_your_signal_account">Re-registered your Signal account too many times.</string>
<!-- Backup redemption error sheet bullet point 2 -->
<string name="BackupAlertBottomSheet__have_too_many_devices_using_the_same_subscription">Have too many devices using the same subscription.</string>
<!-- BackupStatus --> <!-- BackupStatus -->
<!-- Status title when user does not have enough free space to download their media. Placeholder is required disk space. --> <!-- Status title when user does not have enough free space to download their media. Placeholder is required disk space. -->
@ -7824,6 +7834,8 @@
<string name="RemoteBackupsSettingsFragment__a_network_error_occurred">A network error occurred. Please check your internet connection and try again.</string> <string name="RemoteBackupsSettingsFragment__a_network_error_occurred">A network error occurred. Please check your internet connection and try again.</string>
<!-- Progress message when backup file is being uploaded. First placeholder and second placeholder are formatted byte sizes (2 MB) and third is percent completion. --> <!-- Progress message when backup file is being uploaded. First placeholder and second placeholder are formatted byte sizes (2 MB) and third is percent completion. -->
<string name="RemoteBackupsSettingsFragment__uploading_s_of_s_d">Uploading: %1$s of %2$s (%3$d%%)</string> <string name="RemoteBackupsSettingsFragment__uploading_s_of_s_d">Uploading: %1$s of %2$s (%3$d%%)</string>
<!-- Button label to see more details about redemption error -->
<string name="RemoteBackupsSettingsFragment__details">Details</string>
<!-- SubscriptionNotFoundBottomSheet --> <!-- SubscriptionNotFoundBottomSheet -->
<!-- Displayed as a bottom sheet title --> <!-- Displayed as a bottom sheet title -->

View file

@ -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;
}
}

View file

@ -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<BadgeEntitlement>? = 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?
)
}