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 fd2b701221..9c57ebabc7 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 @@ -1164,25 +1164,43 @@ object BackupRepository { } fun restoreBackupTier(aci: ACI): MessageBackupTier? { - // TODO: more complete error handling - try { - val lastModified = getBackupFileLastModified().successOrThrow() - if (lastModified != null) { - SignalStore.backup.lastBackupTime = lastModified.toMillis() + val tierResult = getBackupTier(aci) + when { + tierResult is NetworkResult.Success -> { + SignalStore.backup.backupTier = tierResult.result + Log.d(TAG, "Backup tier restored: ${SignalStore.backup.backupTier}") + } + + tierResult is NetworkResult.StatusCodeError && tierResult.code == 404 -> { + Log.i(TAG, "Backups not enabled") + SignalStore.backup.backupTier = null + } + + else -> { + Log.w(TAG, "Could not retrieve backup tier.", tierResult.getCause()) + return SignalStore.backup.backupTier } - } catch (e: Exception) { - Log.i(TAG, "Could not check for backup file.", e) - SignalStore.backup.backupTier = null - return null - } - SignalStore.backup.backupTier = try { - getBackupTier(aci).successOrThrow() - } catch (e: Exception) { - Log.i(TAG, "Could not retrieve backup tier.", e) - null } + SignalStore.backup.isBackupTierRestored = true + if (SignalStore.backup.backupTier != null) { + val timestampResult = getBackupFileLastModified() + when { + timestampResult is NetworkResult.Success -> { + timestampResult.result?.let { SignalStore.backup.lastBackupTime = it.toMillis() } + } + + timestampResult is NetworkResult.StatusCodeError && timestampResult.code == 404 -> { + Log.i(TAG, "No backup file exists") + SignalStore.backup.lastBackupTime = 0L + } + + else -> { + Log.w(TAG, "Could not check for backup file.", timestampResult.getCause()) + } + } + SignalStore.uiHints.markHasEverEnabledRemoteBackups() } 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 2282fe09cd..ef0ed8b456 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -34,6 +34,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_BACKUP_USED_MEDIA_SPACE = "backup.usedMediaSpace" private const val KEY_BACKUP_LAST_PROTO_SIZE = "backup.lastProtoSize" private const val KEY_BACKUP_TIER = "backup.backupTier" + private const val KEY_BACKUP_TIER_RESTORED = "backup.backupTierRestored" private const val KEY_LATEST_BACKUP_TIER = "backup.latestBackupTier" private const val KEY_LAST_CHECK_IN_MILLIS = "backup.lastCheckInMilliseconds" private const val KEY_LAST_CHECK_IN_SNOOZE_MILLIS = "backup.lastCheckInSnoozeMilliseconds" @@ -167,12 +168,15 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { store.beginWrite() .putLong(KEY_BACKUP_TIER, serializedValue) .putLong(KEY_LATEST_BACKUP_TIER, serializedValue) + .putBoolean(KEY_BACKUP_TIER_RESTORED, true) .apply() } else { putLong(KEY_BACKUP_TIER, serializedValue) } } + var isBackupTierRestored: Boolean by booleanValue(KEY_BACKUP_TIER_RESTORED, false) + /** * When uploading a backup, we store the progress state here so that it can remain across app restarts. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt index f33ea9a642..268a4f3a8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt @@ -15,6 +15,7 @@ class RegistrationValues internal constructor(store: KeyValueStore) : SignalStor private const val LOCAL_REGISTRATION_DATA = "registration.local_registration_data" private const val RESTORE_COMPLETED = "registration.backup_restore_completed" private const val RESTORE_METHOD_TOKEN = "registration.restore_method_token" + private const val IS_OTHER_DEVICE_ANDROID = "registration.is_other_device_android" private const val RESTORING_ON_NEW_DEVICE = "registration.restoring_on_new_device" } @@ -60,6 +61,8 @@ class RegistrationValues internal constructor(store: KeyValueStore) : SignalStor var hasUploadedProfile: Boolean by booleanValue(HAS_UPLOADED_PROFILE, true) var sessionId: String? by stringValue(SESSION_ID, null) var sessionE164: String? by stringValue(SESSION_E164, null) + + var isOtherDeviceAndroid: Boolean by booleanValue(IS_OTHER_DEVICE_ANDROID, false) var restoreMethodToken: String? by stringValue(RESTORE_METHOD_TOKEN, null) @get:JvmName("isRestoringOnNewDevice") diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt index d593ca658d..5029ac58ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt @@ -86,9 +86,10 @@ object QuickRegistrationRepository { backupTimestampMs = SignalStore.backup.lastBackupTime.coerceAtLeast(0L), tier = when (SignalStore.backup.backupTier) { MessageBackupTier.PAID -> RegistrationProvisionMessage.Tier.PAID - MessageBackupTier.FREE, - null -> RegistrationProvisionMessage.Tier.FREE + MessageBackupTier.FREE -> RegistrationProvisionMessage.Tier.FREE + null -> null }, + backupSizeBytes = SignalStore.backup.totalBackupSize, restoreMethodToken = restoreMethodToken ) ) @@ -145,7 +146,7 @@ object QuickRegistrationRepository { Log.d(TAG, "Waiting for restore method with token: ***${restoreMethodToken.takeLast(4)}") while (retries-- > 0 && result !is NetworkResult.Success && coroutineContext.isActive) { - Log.d(TAG, "Remaining tries $retries...") + Log.d(TAG, "Waiting, remaining tries: $retries") val api = AppDependencies.registrationApi result = api.waitForRestoreMethod(restoreMethodToken) Log.d(TAG, "Result: $result") @@ -155,7 +156,7 @@ object QuickRegistrationRepository { Log.i(TAG, "Restore method selected on new device ${result.result}") return result.result } else { - Log.w(TAG, "Failed to determine restore method, using default") + Log.w(TAG, "Failed to determine restore method, using DECLINE") return RestoreMethod.DECLINE } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/NoBackupToRestoreFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/NoBackupToRestoreFragment.kt new file mode 100644 index 0000000000..c03302793b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/NoBackupToRestoreFragment.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.fragment.findNavController +import org.signal.core.ui.Buttons +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * Shown when the old device is iOS and they are trying to transfer/restore on Android without a Signal Backup. + */ +class NoBackupToRestoreFragment : ComposeFragment() { + @Composable + override fun FragmentContent() { + NoBackupToRestoreContent( + onSkipRestore = {}, + onCancel = { + findNavController().safeNavigate(NoBackupToRestoreFragmentDirections.restartRegistrationFlow()) + } + ) + } +} + +@Composable +private fun NoBackupToRestoreContent( + onSkipRestore: () -> Unit = {}, + onCancel: () -> Unit = {} +) { + RegistrationScreen( + title = stringResource(id = R.string.NoBackupToRestore_title), + subtitle = stringResource(id = R.string.NoBackupToRestore_subtitle), + bottomContent = { + Column { + Buttons.LargeTonal( + onClick = onSkipRestore, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.NoBackupToRestore_skip_restore)) + } + + TextButton( + onClick = onCancel, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = android.R.string.cancel)) + } + } + } + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.padding(horizontal = 32.dp) + ) { + StepRow(icon = painterResource(R.drawable.symbol_device_phone_24), text = stringResource(id = R.string.NoBackupToRestore_step1)) + + StepRow(icon = painterResource(R.drawable.symbol_backup_24), text = stringResource(id = R.string.NoBackupToRestore_step2)) + + StepRow(icon = painterResource(R.drawable.symbol_check_circle_24), text = stringResource(id = R.string.NoBackupToRestore_step3)) + } + } +} + +@Composable +private fun StepRow( + icon: Painter, + text: String +) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = icon, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + contentDescription = null + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = text, + style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant) + ) + } +} + +@SignalPreview +@Composable +private fun NoBackupToRestoreContentPreview() { + Previews.Preview { + NoBackupToRestoreContent() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt index ccbc11376a..5de8a3bdf4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt @@ -9,7 +9,6 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent -import androidx.activity.viewModels import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,10 +29,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope @@ -63,6 +58,7 @@ import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.viewModel import java.util.Locale /** @@ -70,12 +66,19 @@ import java.util.Locale */ class RemoteRestoreActivity : BaseActivity() { companion object { - fun getIntent(context: Context): Intent { - return Intent(context, RemoteRestoreActivity::class.java) + + private const val KEY_ONLY_OPTION = "ONLY_OPTION" + + fun getIntent(context: Context, isOnlyOption: Boolean = false): Intent { + return Intent(context, RemoteRestoreActivity::class.java).apply { + putExtra(KEY_ONLY_OPTION, isOnlyOption) + } } } - private val viewModel: RemoteRestoreViewModel by viewModels() + private val viewModel: RemoteRestoreViewModel by viewModel { + RemoteRestoreViewModel(intent.getBooleanExtra(KEY_ONLY_OPTION, false)) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -100,7 +103,14 @@ class RemoteRestoreActivity : BaseActivity() { RestoreFromBackupContent( state = state, onRestoreBackupClick = { viewModel.restore() }, - onCancelClick = { finish() }, + onCancelClick = { + if (state.isRemoteRestoreOnlyOption) { + viewModel.skipRestore() + startActivity(MainActivity.clearTop(this)) + } + + finish() + }, onErrorDialogDismiss = { viewModel.clearError() } ) } @@ -137,25 +147,57 @@ private fun RestoreFromBackupContent( onCancelClick: () -> Unit = {}, onErrorDialogDismiss: () -> Unit = {} ) { - val subtitle = buildAnnotatedString { - append( - stringResource( - id = R.string.RemoteRestoreActivity__backup_created_at, - DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), state.backupTime), - DateUtils.getOnlyTimeString(LocalContext.current, state.backupTime) + when (state.loadState) { + RemoteRestoreViewModel.ScreenState.LoadState.LOADING -> { + Dialogs.IndeterminateProgressDialog( + message = stringResource(R.string.RemoteRestoreActivity__fetching_backup_details) ) - ) - append(" ") - if (state.backupTier != MessageBackupTier.PAID) { - withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { - append(stringResource(id = R.string.RemoteRestoreActivity__only_media_sent_or_received)) - } } + + RemoteRestoreViewModel.ScreenState.LoadState.LOADED -> { + BackupAvailableContent( + state = state, + onRestoreBackupClick = onRestoreBackupClick, + onCancelClick = onCancelClick, + onErrorDialogDismiss = onErrorDialogDismiss + ) + } + + RemoteRestoreViewModel.ScreenState.LoadState.NOT_FOUND -> { + RestoreFailedDialog(onDismiss = onCancelClick) + } + + RemoteRestoreViewModel.ScreenState.LoadState.FAILURE -> { + RestoreFailedDialog(onDismiss = onCancelClick) + } + } +} + +@Composable +private fun BackupAvailableContent( + state: RemoteRestoreViewModel.ScreenState, + onRestoreBackupClick: () -> Unit, + onCancelClick: () -> Unit, + onErrorDialogDismiss: () -> Unit +) { + val subtitle = if (state.backupSize.bytes > 0) { + stringResource( + id = R.string.RemoteRestoreActivity__backup_created_at_with_size, + DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), state.backupTime), + DateUtils.getOnlyTimeString(LocalContext.current, state.backupTime), + state.backupSize.toUnitString() + ) + } else { + stringResource( + id = R.string.RemoteRestoreActivity__backup_created_at, + DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), state.backupTime), + DateUtils.getOnlyTimeString(LocalContext.current, state.backupTime) + ) } RegistrationScreen( title = stringResource(id = R.string.RemoteRestoreActivity__restore_from_backup), - subtitle = if (state.isLoaded()) subtitle else null, + subtitle = subtitle, bottomContent = { Column { if (state.isLoaded()) { @@ -171,45 +213,31 @@ private fun RestoreFromBackupContent( onClick = onCancelClick, modifier = Modifier.fillMaxWidth() ) { - Text(text = stringResource(id = android.R.string.cancel)) + Text(text = stringResource(id = if (state.isRemoteRestoreOnlyOption) R.string.RemoteRestoreActivity__skip_restore else android.R.string.cancel)) } } } ) { - when (state.loadState) { - RemoteRestoreViewModel.ScreenState.LoadState.LOADING -> { - Dialogs.IndeterminateProgressDialog( - message = stringResource(R.string.RemoteRestoreActivity__fetching_backup_details) + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp)) + .padding(horizontal = 20.dp) + .padding(top = 20.dp, bottom = 18.dp) + ) { + Text( + text = stringResource(id = R.string.RemoteRestoreActivity__your_backup_includes), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 6.dp) + ) + + getFeatures(state.backupTier).forEach { + MessageBackupsTypeFeatureRow( + messageBackupsTypeFeature = it, + iconTint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, top = 6.dp) ) } - - RemoteRestoreViewModel.ScreenState.LoadState.LOADED -> { - Column( - modifier = Modifier - .fillMaxWidth() - .background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp)) - .padding(horizontal = 20.dp) - .padding(top = 20.dp, bottom = 18.dp) - ) { - Text( - text = stringResource(id = R.string.RemoteRestoreActivity__your_backup_includes), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 6.dp) - ) - - getFeatures(state.backupTier).forEach { - MessageBackupsTypeFeatureRow( - messageBackupsTypeFeature = it, - iconTint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 16.dp, top = 6.dp) - ) - } - } - } - - RemoteRestoreViewModel.ScreenState.LoadState.FAILURE -> { - RestoreFailedDialog(onDismiss = onCancelClick) - } } when (state.importState) { @@ -229,6 +257,7 @@ private fun RestoreFromBackupContentPreview() { state = RemoteRestoreViewModel.ScreenState( backupTier = MessageBackupTier.PAID, backupTime = System.currentTimeMillis(), + backupSize = 1234567.bytes, importState = RemoteRestoreViewModel.ImportState.None, restoreProgress = null ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt index 3271834407..a333800d0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.signal.core.util.ByteSize +import org.signal.core.util.bytes import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.MessageBackupTier @@ -28,9 +30,11 @@ import org.thoughtcrime.securesms.jobs.ProfileUploadJob import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.registration.util.RegistrationUtil +import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository +import org.whispersystems.signalservice.api.registration.RestoreMethod -class RemoteRestoreViewModel : ViewModel() { +class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() { companion object { private val TAG = Log.tag(RemoteRestoreViewModel::class) @@ -38,8 +42,10 @@ class RemoteRestoreViewModel : ViewModel() { private val store: MutableStateFlow = MutableStateFlow( ScreenState( + isRemoteRestoreOnlyOption = isOnlyRestoreOption, backupTier = SignalStore.backup.backupTier, - backupTime = SignalStore.backup.lastBackupTime + backupTime = SignalStore.backup.lastBackupTime, + backupSize = SignalStore.backup.totalBackupSize.bytes ) ) @@ -47,18 +53,23 @@ class RemoteRestoreViewModel : ViewModel() { init { viewModelScope.launch(Dispatchers.IO) { - val restored = BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) != null + val tier: MessageBackupTier? = BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) store.update { - if (restored) { + if (tier != null) { it.copy( loadState = ScreenState.LoadState.LOADED, backupTier = SignalStore.backup.backupTier, - backupTime = SignalStore.backup.lastBackupTime + backupTime = SignalStore.backup.lastBackupTime, + backupSize = SignalStore.backup.totalBackupSize.bytes ) } else { - it.copy( - loadState = ScreenState.LoadState.FAILURE - ) + if (SignalStore.backup.isBackupTierRestored) { + it.copy(loadState = ScreenState.LoadState.NOT_FOUND) + } else if (it.loadState == ScreenState.LoadState.LOADING) { + it.copy(loadState = ScreenState.LoadState.FAILURE) + } else { + it + } } } } @@ -69,6 +80,8 @@ class RemoteRestoreViewModel : ViewModel() { store.update { it.copy(importState = ImportState.InProgress) } withContext(Dispatchers.IO) { + QuickRegistrationRepository.setRestoreMethodForOldDevice(RestoreMethod.REMOTE_BACKUP) + val jobStateFlow = callbackFlow { val listener = JobTracker.JobListener { _, jobState -> trySend(jobState) @@ -129,9 +142,21 @@ class RemoteRestoreViewModel : ViewModel() { store.update { it.copy(importState = ImportState.None, restoreProgress = null) } } + fun skipRestore() { + SignalStore.registration.markSkippedTransferOrRestore() + + viewModelScope.launch { + withContext(Dispatchers.IO) { + QuickRegistrationRepository.setRestoreMethodForOldDevice(RestoreMethod.DECLINE) + } + } + } + data class ScreenState( + val isRemoteRestoreOnlyOption: Boolean = false, val backupTier: MessageBackupTier? = null, val backupTime: Long = -1, + val backupSize: ByteSize = 0.bytes, val importState: ImportState = ImportState.None, val restoreProgress: RestoreV2Event? = null, val loadState: LoadState = if (backupTier != null) LoadState.LOADED else LoadState.LOADING @@ -141,12 +166,8 @@ class RemoteRestoreViewModel : ViewModel() { return loadState == LoadState.LOADED } - fun isLoading(): Boolean { - return loadState == LoadState.LOADING - } - enum class LoadState { - LOADING, LOADED, FAILURE + LOADING, LOADED, NOT_FOUND, FAILURE } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrFragment.kt index 59b64bcf00..58a689b8dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrFragment.kt @@ -59,12 +59,14 @@ import org.signal.core.ui.Previews import org.signal.core.ui.SignalPreview import org.signal.core.ui.horizontalGutters import org.signal.core.ui.theme.SignalTheme +import org.signal.registration.proto.RegistrationProvisionMessage import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCode import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen +import org.thoughtcrime.securesms.util.navigation.safeNavigate /** * Show QR code on new device to allow registration and restore via old device. @@ -84,7 +86,11 @@ class RestoreViaQrFragment : ComposeFragment() { .mapNotNull { it.provisioningMessage } .distinctUntilChanged() .collect { message -> - sharedViewModel.registerWithBackupKey(requireContext(), message.accountEntropyPool, message.e164, message.pin) + if (message.platform == RegistrationProvisionMessage.Platform.ANDROID || message.tier != null) { + sharedViewModel.registerWithBackupKey(requireContext(), message.accountEntropyPool, message.e164, message.pin) + } else { + findNavController().safeNavigate(RestoreViaQrFragmentDirections.goToNoBackupToRestore()) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt index 5bdb7a01c0..d28bd4f4a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import org.signal.core.util.logging.Log import org.signal.registration.proto.RegistrationProvisionMessage +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -80,6 +81,18 @@ class RestoreViaQrViewModel : ViewModel() { if (result is SecondaryProvisioningCipher.RegistrationProvisionResult.Success) { Log.i(TAG, "Saving restore method token: ***${result.message.restoreMethodToken.takeLast(4)}") SignalStore.registration.restoreMethodToken = result.message.restoreMethodToken + SignalStore.registration.isOtherDeviceAndroid = result.message.platform == RegistrationProvisionMessage.Platform.ANDROID + if (result.message.backupTimestampMs > 0) { + SignalStore.backup.backupTier = result.message.tier.let { + when (it) { + RegistrationProvisionMessage.Tier.FREE -> MessageBackupTier.FREE + RegistrationProvisionMessage.Tier.PAID -> MessageBackupTier.PAID + null -> null + } + } + SignalStore.backup.lastBackupTime = result.message.backupTimestampMs + SignalStore.backup.usedBackupMediaSpace = result.message.backupSizeBytes + } store.update { it.copy(isRegistering = true, provisioningMessage = result.message, qrState = QrState.Scanned) } } else { store.update { it.copy(showProvisioningError = true, qrState = QrState.Scanned) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectRestoreMethodScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectRestoreMethodScreen.kt index 2815c9b383..31c550bbc9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectRestoreMethodScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectRestoreMethodScreen.kt @@ -34,7 +34,7 @@ fun SelectRestoreMethodScreen( onClick = onSkip, modifier = Modifier.align(Alignment.Center) ) { - Text(text = stringResource(R.string.registration_activity__skip)) + Text(text = stringResource(R.string.registration_activity__skip_restore)) } } ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt index 6dfb2a2183..d17879eebc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.BaseActivity import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.RestoreDirections +import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -62,7 +63,14 @@ class RestoreActivity : BaseActivity() { val navTarget = NavTarget.deserialize(intent.getIntExtra(EXTRA_NAV_TARGET, NavTarget.LEGACY_LANDING.value)) when (navTarget) { - NavTarget.NEW_LANDING -> navController.safeNavigate(RestoreDirections.goDirectlyToNewLanding()) + NavTarget.NEW_LANDING -> { + if (sharedViewModel.hasMultipleRestoreMethods()) { + navController.safeNavigate(RestoreDirections.goDirectlyToNewLanding()) + } else { + startActivity(RemoteRestoreActivity.getIntent(this, isOnlyOption = true)) + finish() + } + } NavTarget.LOCAL_RESTORE -> navController.safeNavigate(RestoreDirections.goDirectlyToChooseLocalBackup()) NavTarget.TRANSFER -> navController.safeNavigate(RestoreDirections.goDirectlyToDeviceTransfer()) else -> Unit diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt index 87ec134b11..c47994300a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt @@ -11,6 +11,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.registrationv3.ui.restore.RestoreMethod import org.thoughtcrime.securesms.restore.transferorrestore.BackupRestorationType /** @@ -51,4 +54,29 @@ class RestoreViewModel : ViewModel() { fun getBackupFileUri(): Uri? = store.value.backupFile fun getNextIntent(): Intent? = store.value.nextIntent + + fun hasMultipleRestoreMethods(): Boolean { + return getAvailableRestoreMethods().size > 1 + } + + fun getAvailableRestoreMethods(): List { + if (SignalStore.registration.isOtherDeviceAndroid) { + val methods = mutableListOf(RestoreMethod.FROM_OLD_DEVICE, RestoreMethod.FROM_LOCAL_BACKUP_V1) + when (SignalStore.backup.backupTier) { + MessageBackupTier.FREE -> methods.add(1, RestoreMethod.FROM_SIGNAL_BACKUPS) + MessageBackupTier.PAID -> methods.add(0, RestoreMethod.FROM_SIGNAL_BACKUPS) + null -> if (!SignalStore.backup.isBackupTierRestored) { + methods.add(1, RestoreMethod.FROM_SIGNAL_BACKUPS) + } + } + + return methods + } + + if (SignalStore.backup.backupTier != null || !SignalStore.backup.isBackupTierRestored) { + return listOf(RestoreMethod.FROM_SIGNAL_BACKUPS) + } + + return emptyList() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt index 3e1fc2730d..d83f64db2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt @@ -5,8 +5,8 @@ package org.thoughtcrime.securesms.restore.selection -import android.content.Intent import androidx.compose.runtime.Composable +import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import kotlinx.coroutines.launch @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepositor import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity import org.thoughtcrime.securesms.registrationv3.ui.restore.RestoreMethod import org.thoughtcrime.securesms.registrationv3.ui.restore.SelectRestoreMethodScreen +import org.thoughtcrime.securesms.restore.RestoreViewModel import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.whispersystems.signalservice.api.registration.RestoreMethod as ApiRestoreMethod @@ -24,10 +25,13 @@ import org.whispersystems.signalservice.api.registration.RestoreMethod as ApiRes * Provide options to select restore/transfer operation and flow during quick registration. */ class SelectRestoreMethodFragment : ComposeFragment() { + + private val viewModel: RestoreViewModel by activityViewModels() + @Composable override fun FragmentContent() { SelectRestoreMethodScreen( - restoreMethods = listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_OLD_DEVICE, RestoreMethod.FROM_LOCAL_BACKUP_V1), // TODO [backups] make dynamic + restoreMethods = viewModel.getAvailableRestoreMethods(), onRestoreMethodClicked = this::startRestoreMethod, onSkip = { SignalStore.registration.markSkippedTransferOrRestore() @@ -54,7 +58,7 @@ class SelectRestoreMethodFragment : ComposeFragment() { } when (method) { - RestoreMethod.FROM_SIGNAL_BACKUPS -> startActivity(Intent(requireContext(), RemoteRestoreActivity::class.java)) + RestoreMethod.FROM_SIGNAL_BACKUPS -> startActivity(RemoteRestoreActivity.getIntent(requireContext())) RestoreMethod.FROM_OLD_DEVICE -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToDeviceTransfer()) RestoreMethod.FROM_LOCAL_BACKUP_V1 -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToLocalBackupRestore()) RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported") diff --git a/app/src/main/res/drawable/symbol_device_phone_24.xml b/app/src/main/res/drawable/symbol_device_phone_24.xml new file mode 100644 index 0000000000..3ef35d3b10 --- /dev/null +++ b/app/src/main/res/drawable/symbol_device_phone_24.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/navigation/registration_v3.xml b/app/src/main/res/navigation/registration_v3.xml index cb42553bbd..5d1f87c574 100644 --- a/app/src/main/res/navigation/registration_v3.xml +++ b/app/src/main/res/navigation/registration_v3.xml @@ -48,7 +48,8 @@ + android:label="fragment_grant_permissions" + tools:ignore="NewApi"> + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac16f2a3b9..e9e1cc8b2a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1356,16 +1356,18 @@ All of your messages Restore from backup - - Only media sent or received in the past %1$d days is included. Your backup includes: Restore backup - + Your last backup was made on %1$s at %2$s. + + Your last backup was made on %1$s at %2$s. Your backup size is %3$s. Fetching backup details… + + Skip restore Notify me for Mentions @@ -4315,6 +4317,8 @@ Restore or transfer Transfer account Skip + + Skip restore Chat backups Transfer account Transfer account to a new Android device @@ -7956,6 +7960,18 @@ Okay + + No Backup to Restore + + Because you\'re moving from iPhone to Android, the only way to transfer your messages and media is by enabling Signal Backups on your old device. + + Open Signal on your old device + + Tap Settings > Backups + + Enable backups and wait until your backup is complete + + Skip restore diff --git a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt index f89c829926..d4ee8a4501 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -156,7 +157,7 @@ object Dialogs { Spacer(modifier = Modifier.size(24.dp)) CircularProgressIndicator() Spacer(modifier = Modifier.size(20.dp)) - Text(message) + Text(text = message, textAlign = TextAlign.Center) } }, modifier = Modifier diff --git a/libsignal-service/src/main/protowire/RegistrationProvisioning.proto b/libsignal-service/src/main/protowire/RegistrationProvisioning.proto index df52e7b0d2..4d7593f25f 100644 --- a/libsignal-service/src/main/protowire/RegistrationProvisioning.proto +++ b/libsignal-service/src/main/protowire/RegistrationProvisioning.proto @@ -25,7 +25,8 @@ message RegistrationProvisionMessage { string pin = 4; Platform platform = 5; uint64 backupTimestampMs = 6; - Tier tier = 7; - string restoreMethodToken = 8; - reserved 9; // iOSDeviceTransferMessage + optional Tier tier = 7; + uint64 backupSizeBytes = 8; + string restoreMethodToken = 9; + reserved 10; // iOSDeviceTransferMessage }