From 6c1897d8d57fc5c2e7ffc02e78f0714fb1565d66 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 13 Mar 2026 10:46:11 -0400 Subject: [PATCH] Add infra for regV5 restore flows. --- .../registration/src/main/AndroidManifest.xml | 2 + .../TransferAccountScreen.kt | 247 ++++++++++++++++++ .../TransferAccountViewModel.kt | 167 ++++++++++++ .../registration/RegistrationNavigation.kt | 42 ++- .../screens/permissions/PermissionsScreen.kt | 2 +- .../phonenumber/PhoneNumberEntryScreen.kt | 2 +- .../screens/pincreation/PinCreationState.kt | 4 +- .../quickrestore/QuickRestoreQrState.kt | 1 - .../restoreselection/ArchiveRestoreOption.kt | 17 ++ .../ArchiveRestoreSelectionScreen.kt | 212 +++++++++++++++ .../ArchiveRestoreSelectionScreenEvents.kt | 15 ++ .../ArchiveRestoreSelectionState.kt | 14 + .../ArchiveRestoreSelectionViewModel.kt | 93 +++++++ .../screens/welcome/WelcomeScreen.kt | 2 +- .../org/signal/registration/test/TestTags.kt | 8 + .../registration/util/DebugLoggableModel.kt | 2 +- .../src/main/res/drawable/symbol_file_24.xml | 10 + .../main/res/drawable/symbol_folder_24.xml | 9 + .../main/res/drawable/symbol_transfer_24.xml | 12 + .../src/main/res/values/strings.xml | 36 +++ 20 files changed, 867 insertions(+), 30 deletions(-) create mode 100644 demo/registration/src/main/java/org/signal/registration/sample/screens/olddevicetransfer/TransferAccountScreen.kt create mode 100644 demo/registration/src/main/java/org/signal/registration/sample/screens/olddevicetransfer/TransferAccountViewModel.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreOption.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionScreen.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionScreenEvents.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionState.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt create mode 100644 feature/registration/src/main/res/drawable/symbol_file_24.xml create mode 100644 feature/registration/src/main/res/drawable/symbol_folder_24.xml create mode 100644 feature/registration/src/main/res/drawable/symbol_transfer_24.xml diff --git a/demo/registration/src/main/AndroidManifest.xml b/demo/registration/src/main/AndroidManifest.xml index 37d9f43c48..c2600d8731 100644 --- a/demo/registration/src/main/AndroidManifest.xml +++ b/demo/registration/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/olddevicetransfer/TransferAccountScreen.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/olddevicetransfer/TransferAccountScreen.kt new file mode 100644 index 0000000000..e0faabaac6 --- /dev/null +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/olddevicetransfer/TransferAccountScreen.kt @@ -0,0 +1,247 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.sample.screens.olddevicetransfer + +import android.Manifest +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import io.reactivex.rxjava3.disposables.Disposable +import org.signal.qr.QrScannerView + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun TransferAccountScreen( + state: TransferAccountState, + onEvent: (TransferAccountEvent) -> Unit, + modifier: Modifier = Modifier +) { + when (state) { + TransferAccountState.Scanning -> { + val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA) + + if (cameraPermission.status.isGranted) { + QrScannerContent( + onQrScanned = { onEvent(TransferAccountEvent.QrCodeScanned(it)) }, + onBack = { onEvent(TransferAccountEvent.Back) }, + modifier = modifier + ) + } else { + CameraPermissionContent( + onRequestPermission = { cameraPermission.launchPermissionRequest() }, + onBack = { onEvent(TransferAccountEvent.Back) }, + modifier = modifier + ) + } + } + TransferAccountState.Sending -> SendingContent(modifier = modifier) + TransferAccountState.Success -> SuccessContent( + onBack = { onEvent(TransferAccountEvent.Back) }, + modifier = modifier + ) + is TransferAccountState.Error -> ErrorContent( + message = state.message, + onRetry = { onEvent(TransferAccountEvent.Retry) }, + onBack = { onEvent(TransferAccountEvent.Back) }, + modifier = modifier + ) + } +} + +@Composable +private fun QrScannerContent( + onQrScanned: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val lifecycleOwner = LocalLifecycleOwner.current + var disposable = remember { null } + + DisposableEffect(Unit) { + onDispose { disposable?.dispose() } + } + + Column(modifier = modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + AndroidView( + factory = { context -> + QrScannerView(context).apply { + start(lifecycleOwner) + disposable = qrData.subscribe { data -> + if (data.startsWith("sgnl://rereg")) { + onQrScanned(data) + } + } + } + }, + modifier = Modifier.fillMaxSize() + ) + + Text( + text = "Scan the QR code on the new device", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(24.dp) + ) + } + + OutlinedButton( + onClick = onBack, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text("Cancel") + } + } +} + +@Composable +private fun CameraPermissionContent( + onRequestPermission: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Camera Permission Required", + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Camera access is needed to scan the QR code on the new device.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onRequestPermission) { + Text("Grant Camera Permission") + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onBack) { + Text("Cancel") + } + } +} + +@Composable +private fun SendingContent(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Sending account data...", + style = MaterialTheme.typography.bodyLarge + ) + } +} + +@Composable +private fun SuccessContent( + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Transfer Sent", + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Account data has been sent to the new device.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onBack) { + Text("Done") + } + } +} + +@Composable +private fun ErrorContent( + message: String, + onRetry: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Transfer Failed", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onRetry) { + Text("Try Again") + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onBack) { + Text("Cancel") + } + } +} diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/olddevicetransfer/TransferAccountViewModel.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/olddevicetransfer/TransferAccountViewModel.kt new file mode 100644 index 0000000000..26b15f16da --- /dev/null +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/olddevicetransfer/TransferAccountViewModel.kt @@ -0,0 +1,167 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.sample.screens.olddevicetransfer + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.Credentials +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.signal.core.util.Base64 +import org.signal.core.util.logging.Log +import org.signal.libsignal.protocol.ecc.ECPublicKey +import org.signal.registration.proto.RegistrationProvisionMessage +import org.signal.registration.sample.RegistrationApplication +import org.signal.registration.sample.storage.RegistrationPreferences +import org.whispersystems.signalservice.api.buildOkHttpClient +import org.whispersystems.signalservice.api.chooseUrl +import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher +import java.io.IOException +import java.net.URLEncoder +import java.util.UUID + +class TransferAccountViewModel( + private val onBack: () -> Unit +) : ViewModel() { + + companion object { + private val TAG = Log.tag(TransferAccountViewModel::class) + } + + private val _state = MutableStateFlow(TransferAccountState.Scanning) + val state: StateFlow = _state.asStateFlow() + + private var hasProcessedQr = false + + fun onEvent(event: TransferAccountEvent) { + when (event) { + is TransferAccountEvent.QrCodeScanned -> { + if (!hasProcessedQr) { + hasProcessedQr = true + viewModelScope.launch { processQrCode(event.data) } + } + } + TransferAccountEvent.Retry -> { + hasProcessedQr = false + _state.value = TransferAccountState.Scanning + } + TransferAccountEvent.Back -> onBack() + } + } + + private suspend fun processQrCode(data: String) { + val uri = Uri.parse(data) + if (uri.host != "rereg") { + Log.w(TAG, "Not a re-registration QR code: ${uri.host}") + _state.value = TransferAccountState.Error("Not a valid re-registration QR code") + return + } + + val deviceId = uri.getQueryParameter("uuid") + val publicKeyEncoded = uri.getQueryParameter("pub_key") + + if (deviceId == null || publicKeyEncoded == null) { + Log.w(TAG, "QR code missing uuid or pub_key") + _state.value = TransferAccountState.Error("Invalid QR code: missing parameters") + return + } + + _state.value = TransferAccountState.Sending + + try { + withContext(Dispatchers.IO) { + val publicKey = ECPublicKey(Base64.decode(publicKeyEncoded)) + + val e164 = checkNotNull(RegistrationPreferences.e164) { "No e164 stored" } + val aci = checkNotNull(RegistrationPreferences.aci) { "No ACI stored" } + val aep = checkNotNull(RegistrationPreferences.aep) { "No AEP stored" } + val aciKeyPair = checkNotNull(RegistrationPreferences.aciIdentityKeyPair) { "No ACI identity key pair stored" } + val pniKeyPair = checkNotNull(RegistrationPreferences.pniIdentityKeyPair) { "No PNI identity key pair stored" } + val restoreMethodToken = UUID.randomUUID().toString() + + val message = RegistrationProvisionMessage( + e164 = e164, + aci = okio.ByteString.of(*aci.toByteArray()), + accountEntropyPool = aep.value, + pin = RegistrationPreferences.pin, + platform = RegistrationProvisionMessage.Platform.ANDROID, + tier = null, + restoreMethodToken = restoreMethodToken, + aciIdentityKeyPublic = okio.ByteString.of(*aciKeyPair.publicKey.serialize()), + aciIdentityKeyPrivate = okio.ByteString.of(*aciKeyPair.privateKey.serialize()), + pniIdentityKeyPublic = okio.ByteString.of(*pniKeyPair.publicKey.serialize()), + pniIdentityKeyPrivate = okio.ByteString.of(*pniKeyPair.privateKey.serialize()), + backupVersion = 0 + ) + + val cipherText = PrimaryProvisioningCipher(publicKey).encrypt(message) + sendProvisioningMessage(deviceId, cipherText) + } + Log.i(TAG, "Provisioning message sent successfully") + _state.value = TransferAccountState.Success + } catch (e: Exception) { + Log.w(TAG, "Failed to send provisioning message", e) + _state.value = TransferAccountState.Error("Failed: ${e.message}") + } + } + + private fun sendProvisioningMessage(deviceId: String, cipherText: ByteArray) { + val serviceConfiguration = RegistrationApplication.serviceConfiguration + val aci = checkNotNull(RegistrationPreferences.aci) { "Not registered" } + val password = checkNotNull(RegistrationPreferences.servicePassword) { "Not registered" } + + val serviceUrl = serviceConfiguration.signalServiceUrls.chooseUrl() + val okhttp = serviceUrl.buildOkHttpClient(serviceConfiguration) + + val credentials = Credentials.basic(aci.toString(), password) + val body = """{"body":"${Base64.encodeWithPadding(cipherText)}"}""" + .toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url("${serviceUrl.url}/v1/provisioning/${URLEncoder.encode(deviceId, "UTF-8")}") + .put(body) + .header("Authorization", credentials) + .build() + + okhttp.newCall(request).execute().use { response -> + when (response.code) { + 200, 204 -> Log.i(TAG, "[sendProvisioningMessage] Success (${response.code})") + 404 -> throw IOException("No provisioning socket found for device (404)") + else -> throw IOException("Unexpected response: ${response.code} ${response.body?.string()}") + } + } + } + + class Factory( + private val onBack: () -> Unit + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return TransferAccountViewModel(onBack) as T + } + } +} + +sealed interface TransferAccountState { + data object Scanning : TransferAccountState + data object Sending : TransferAccountState + data object Success : TransferAccountState + data class Error(val message: String) : TransferAccountState +} + +sealed interface TransferAccountEvent { + data class QrCodeScanned(val data: String) : TransferAccountEvent + data object Retry : TransferAccountEvent + data object Back : TransferAccountEvent +} diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index e3279e284c..07e850f529 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -30,9 +30,6 @@ import org.signal.core.ui.navigation.TransitionSpecs import org.signal.registration.screens.accountlocked.AccountLockedScreen import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents import org.signal.registration.screens.accountlocked.AccountLockedState -// TODO [regV5] Uncomment when restore selection flow is ready -// import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionScreen -// import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionViewModel import org.signal.registration.screens.captcha.CaptchaScreen import org.signal.registration.screens.captcha.CaptchaScreenEvents import org.signal.registration.screens.captcha.CaptchaState @@ -52,6 +49,8 @@ import org.signal.registration.screens.pinentry.PinEntryForSvrRestoreViewModel import org.signal.registration.screens.pinentry.PinEntryScreen import org.signal.registration.screens.quickrestore.QuickRestoreQrScreen import org.signal.registration.screens.quickrestore.QuickRestoreQrViewModel +import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionScreen +import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionViewModel import org.signal.registration.screens.util.navigateBack import org.signal.registration.screens.util.navigateTo import org.signal.registration.screens.verificationcode.VerificationCodeScreen @@ -101,9 +100,8 @@ sealed interface RegistrationRoute : NavKey, Parcelable { @Serializable data object PinCreate : RegistrationRoute - // TODO [regV5] Uncomment when restore selection flow is ready - // @Serializable - // data object ArchiveRestoreSelection : RegistrationRoute + @Serializable + data object ArchiveRestoreSelection : RegistrationRoute @Serializable data object ChooseRestoreOptionBeforeRegistration : RegistrationRoute @@ -404,22 +402,22 @@ private fun EntryProviderScope.navigationEntries( ) } - // TODO [regV5] Uncomment when restore selection flow is ready - // entry { - // val viewModel: ArchiveRestoreSelectionViewModel = viewModel( - // factory = ArchiveRestoreSelectionViewModel.Factory( - // repository = registrationRepository, - // parentState = registrationViewModel.state, - // parentEventEmitter = registrationViewModel::onEvent - // ) - // ) - // val state by viewModel.state.collectAsStateWithLifecycle() - // - // ArchiveRestoreSelectionScreen( - // state = state, - // onEvent = { viewModel.onEvent(it) } - // ) - // } + // -- Archive Restore Selection Screen + entry { + val viewModel: ArchiveRestoreSelectionViewModel = viewModel( + factory = ArchiveRestoreSelectionViewModel.Factory( + repository = registrationRepository, + parentState = registrationViewModel.state, + parentEventEmitter = registrationViewModel::onEvent + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + ArchiveRestoreSelectionScreen( + state = state, + onEvent = { viewModel.onEvent(it) } + ) + } entry { // TODO: Implement RestoreScreen diff --git a/feature/registration/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt index 0ea837d79c..e15a69d485 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/permissions/PermissionsScreen.kt @@ -34,8 +34,8 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState -import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.horizontalGutters import org.signal.registration.R diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt index c632c195cb..f28e139413 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt @@ -46,9 +46,9 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.CircularProgressWrapper -import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews import org.signal.registration.R diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt index d90ae0ad7a..bde1766d72 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationState.kt @@ -6,8 +6,6 @@ package org.signal.registration.screens.pincreation import org.signal.core.models.AccountEntropyPool -import org.signal.registration.BuildConfig -import org.signal.registration.util.DebugLoggable import org.signal.registration.util.DebugLoggableModel data class PinCreationState( @@ -15,4 +13,4 @@ data class PinCreationState( val inputLabel: String? = null, val isConfirmEnabled: Boolean = false, val accountEntropyPool: AccountEntropyPool? = null -) : DebugLoggableModel() \ No newline at end of file +) : DebugLoggableModel() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrState.kt b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrState.kt index 7b41fcaeae..486cf7f8ba 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrState.kt @@ -25,4 +25,3 @@ sealed class QrState : DebugLoggableModel() { data object Scanned : QrState() data object Failed : QrState() } - diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreOption.kt b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreOption.kt new file mode 100644 index 0000000000..9c4bde879b --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreOption.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.restoreselection + +/** + * Restore options that may be presented on the archive restore selection screen. + * The available options are determined by the [org.signal.registration.StorageController]. + */ +enum class ArchiveRestoreOption { + SignalSecureBackup, + LocalBackup, + DeviceTransfer, + None +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionScreen.kt new file mode 100644 index 0000000000..4ae0459859 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionScreen.kt @@ -0,0 +1,212 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.restoreselection + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalIcons +import org.signal.registration.R +import org.signal.registration.test.TestTags + +@Composable +fun ArchiveRestoreSelectionScreen( + state: ArchiveRestoreSelectionState, + onEvent: (ArchiveRestoreSelectionScreenEvents) -> Unit, + modifier: Modifier = Modifier +) { + if (state.showSkipRestoreWarning) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.ArchiveRestoreSelectionScreen__skip_restore_dialog_title), + body = stringResource(R.string.ArchiveRestoreSelectionScreen__skip_restore_dialog_warning), + confirm = stringResource(R.string.ArchiveRestoreSelectionScreen__skip_restore_dialog_confirm_button), + dismiss = stringResource(android.R.string.cancel), + onConfirm = { onEvent(ArchiveRestoreSelectionScreenEvents.ConfirmSkip) }, + onDismiss = { onEvent(ArchiveRestoreSelectionScreenEvents.DismissSkipWarning) }, + confirmColor = MaterialTheme.colorScheme.error, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) + ) + } + + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp) + .testTag(TestTags.ARCHIVE_RESTORE_SELECTION_SCREEN), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(40.dp)) + + Text( + text = stringResource(R.string.ArchiveRestoreSelectionScreen__restore_or_transfer_account), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.ArchiveRestoreSelectionScreen__subheading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(28.dp)) + + state.restoreOptions.forEachIndexed { index, option -> + if (index > 0) { + Spacer(modifier = Modifier.height(12.dp)) + } + RestoreOptionCard( + option = option, + onClick = { onEvent(ArchiveRestoreSelectionScreenEvents.RestoreOptionSelected(option)) } + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + TextButton( + onClick = { onEvent(ArchiveRestoreSelectionScreenEvents.Skip) }, + modifier = Modifier + .padding(bottom = 32.dp) + .testTag(TestTags.ARCHIVE_RESTORE_SELECTION_SKIP) + ) { + Text( + text = stringResource(R.string.ArchiveRestoreSelectionScreen__skip), + color = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Composable +private fun RestoreOptionCard( + option: ArchiveRestoreOption, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + when (option) { + ArchiveRestoreOption.SignalSecureBackup -> { + SelectionCard( + icon = { Icon(painter = SignalIcons.Backup.painter, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(32.dp)) }, + title = stringResource(R.string.ArchiveRestoreSelectionScreen__from_signal_backups), + subtitle = stringResource(R.string.ArchiveRestoreSelectionScreen__your_free_or_paid_signal_backup_plan), + onClick = onClick, + modifier = modifier.testTag(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_SIGNAL_BACKUPS) + ) + } + ArchiveRestoreOption.DeviceTransfer -> { + SelectionCard( + icon = { Icon(painter = painterResource(R.drawable.symbol_transfer_24), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(32.dp)) }, + title = stringResource(R.string.ArchiveRestoreSelectionScreen__from_your_old_phone), + subtitle = stringResource(R.string.ArchiveRestoreSelectionScreen__transfer_directly_from_old), + onClick = onClick, + modifier = modifier.testTag(TestTags.ARCHIVE_RESTORE_SELECTION_DEVICE_TRANSFER) + ) + } + ArchiveRestoreOption.LocalBackup -> { + SelectionCard( + icon = { Icon(painter = painterResource(R.drawable.symbol_folder_24), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(32.dp)) }, + title = stringResource(R.string.ArchiveRestoreSelectionScreen__local_backup_card_title), + subtitle = stringResource(R.string.ArchiveRestoreSelectionScreen__local_backup_card_description), + onClick = onClick, + modifier = modifier.testTag(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_BACKUP_FOLDER) + ) + } + + ArchiveRestoreOption.None -> { + SelectionCard( + icon = { Icon(painter = painterResource(R.drawable.symbol_folder_24), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(32.dp)) }, + title = stringResource(R.string.ArchiveRestoreSelectionScreen__skip_restore_title), + subtitle = stringResource(R.string.ArchiveRestoreSelectionScreen__skip_restore_description), + onClick = onClick, + modifier = modifier.testTag(TestTags.ARCHIVE_RESTORE_SELECTION_FROM_BACKUP_FOLDER) + ) + } + } +} + +@Composable +private fun SelectionCard( + icon: @Composable () -> Unit, + title: String, + subtitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp) + ) { + icon() + + Spacer(modifier = Modifier.width(16.dp)) + + Column { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@AllDevicePreviews +@Composable +private fun ArchiveRestoreSelectionScreenPreview() { + Previews.Preview { + ArchiveRestoreSelectionScreen( + state = ArchiveRestoreSelectionState( + restoreOptions = listOf(ArchiveRestoreOption.SignalSecureBackup, ArchiveRestoreOption.LocalBackup, ArchiveRestoreOption.DeviceTransfer, ArchiveRestoreOption.None) + ), + onEvent = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionScreenEvents.kt new file mode 100644 index 0000000000..cf120ebb08 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionScreenEvents.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.restoreselection + +import org.signal.registration.util.DebugLoggableModel + +sealed class ArchiveRestoreSelectionScreenEvents : DebugLoggableModel() { + data class RestoreOptionSelected(val option: ArchiveRestoreOption) : ArchiveRestoreSelectionScreenEvents() + data object Skip : ArchiveRestoreSelectionScreenEvents() + data object ConfirmSkip : ArchiveRestoreSelectionScreenEvents() + data object DismissSkipWarning : ArchiveRestoreSelectionScreenEvents() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionState.kt b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionState.kt new file mode 100644 index 0000000000..61249dbf88 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionState.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.restoreselection + +import org.signal.registration.util.DebugLoggableModel + +data class ArchiveRestoreSelectionState( + val restoreOptions: List = emptyList(), + val showSkipButton: Boolean = false, + val showSkipRestoreWarning: Boolean = false +) : DebugLoggableModel() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt new file mode 100644 index 0000000000..badb9b828f --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.restoreselection + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.signal.core.util.logging.Log +import org.signal.registration.RegistrationFlowEvent +import org.signal.registration.RegistrationFlowState +import org.signal.registration.RegistrationRepository +import org.signal.registration.RegistrationRoute +import org.signal.registration.screens.util.navigateTo + +class ArchiveRestoreSelectionViewModel( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit +) : ViewModel() { + + companion object { + private val TAG = Log.tag(ArchiveRestoreSelectionViewModel::class) + } + + private val _localState = MutableStateFlow(ArchiveRestoreSelectionState()) + val state = combine(_localState, parentState) { state, parentState -> applyParentState(state, parentState) } + .onEach { Log.d(TAG, "[State] $it") } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ArchiveRestoreSelectionState()) + + init { +// viewModelScope.launch { +// val options = repository.isSignalSecureBackupAvailable() +// _localState.value = _localState.value.copy(restoreOptions = options) +// } + } + + fun onEvent(event: ArchiveRestoreSelectionScreenEvents) { + Log.d(TAG, "[Event] $event") + viewModelScope.launch { + val stateEmitter: (ArchiveRestoreSelectionState) -> Unit = { newState -> + _localState.value = newState + } + applyEvent(state.value, event, stateEmitter) + } + } + + @VisibleForTesting + suspend fun applyEvent(state: ArchiveRestoreSelectionState, event: ArchiveRestoreSelectionScreenEvents, stateEmitter: (ArchiveRestoreSelectionState) -> Unit) { + val result = when (event) { + is ArchiveRestoreSelectionScreenEvents.RestoreOptionSelected -> { + Log.w(TAG, "Restore option selected: ${event.option}, but flow not yet implemented") // TODO [registration] - Handle restore option selection + state + } + is ArchiveRestoreSelectionScreenEvents.Skip -> { + state.copy(showSkipRestoreWarning = true) + } + is ArchiveRestoreSelectionScreenEvents.ConfirmSkip -> { + parentEventEmitter.navigateTo(RegistrationRoute.PinCreate) + state.copy(showSkipRestoreWarning = false) + } + is ArchiveRestoreSelectionScreenEvents.DismissSkipWarning -> { + state.copy(showSkipRestoreWarning = false) + } + } + stateEmitter(result) + } + + @VisibleForTesting + fun applyParentState(state: ArchiveRestoreSelectionState, parentState: RegistrationFlowState): ArchiveRestoreSelectionState { + return state + } + + class Factory( + private val repository: RegistrationRepository, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return ArchiveRestoreSelectionViewModel(repository, parentState, parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt index ef110348d7..e96e15ccd2 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreen.kt @@ -44,9 +44,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope +import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.BottomSheets import org.signal.core.ui.compose.Buttons -import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.dismissWithAnimation diff --git a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt index a9cdb721e4..8eaac7b23a 100644 --- a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt +++ b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt @@ -42,4 +42,12 @@ object TestTags { const val VERIFICATION_CODE_RESEND_SMS_BUTTON = "verification_code_resend_sms_button" const val VERIFICATION_CODE_CALL_ME_BUTTON = "verification_code_call_me_button" const val VERIFICATION_CODE_HAVING_TROUBLE_BUTTON = "verification_code_having_trouble_button" + + // Archive Restore Selection Screen + const val ARCHIVE_RESTORE_SELECTION_SCREEN = "archive_restore_selection_screen" + const val ARCHIVE_RESTORE_SELECTION_FROM_SIGNAL_BACKUPS = "archive_restore_selection_from_signal_backups" + const val ARCHIVE_RESTORE_SELECTION_FROM_BACKUP_FOLDER = "archive_restore_selection_from_backup_folder" + const val ARCHIVE_RESTORE_SELECTION_FROM_BACKUP_FILE = "archive_restore_selection_from_backup_file" + const val ARCHIVE_RESTORE_SELECTION_DEVICE_TRANSFER = "archive_restore_selection_device_transfer" + const val ARCHIVE_RESTORE_SELECTION_SKIP = "archive_restore_selection_skip" } diff --git a/feature/registration/src/main/java/org/signal/registration/util/DebugLoggableModel.kt b/feature/registration/src/main/java/org/signal/registration/util/DebugLoggableModel.kt index bbc1e10d4d..6c94ee2e39 100644 --- a/feature/registration/src/main/java/org/signal/registration/util/DebugLoggableModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/util/DebugLoggableModel.kt @@ -15,4 +15,4 @@ open class DebugLoggableModel : DebugLoggable { toSafeString() } } -} \ No newline at end of file +} diff --git a/feature/registration/src/main/res/drawable/symbol_file_24.xml b/feature/registration/src/main/res/drawable/symbol_file_24.xml new file mode 100644 index 0000000000..2db98174d9 --- /dev/null +++ b/feature/registration/src/main/res/drawable/symbol_file_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/registration/src/main/res/drawable/symbol_folder_24.xml b/feature/registration/src/main/res/drawable/symbol_folder_24.xml new file mode 100644 index 0000000000..9afc7adcad --- /dev/null +++ b/feature/registration/src/main/res/drawable/symbol_folder_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/registration/src/main/res/drawable/symbol_transfer_24.xml b/feature/registration/src/main/res/drawable/symbol_transfer_24.xml new file mode 100644 index 0000000000..2c7b67c1aa --- /dev/null +++ b/feature/registration/src/main/res/drawable/symbol_transfer_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 4bc371c119..35bd9bbe2d 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -94,4 +94,40 @@ I don\'t have my old phone Or you\'re reinstalling Signal on the same device + + + + Restore or transfer account + + Choose how you’d like to restore your message history and account data. + + From Signal Backups + + Your free or paid Signal Backup plan + + Restore on-device backup + + Restore your messages from a backup you saved on your device. + + Continue without transferring + + Messages & media won’t be transferred + + From a backup folder + + From a backup file + + Choose a backup you\'ve saved + + From your old phone + + Transfer directly from your old Android + + Skip + + Skip restore? + + If you skip restore now you will not be able to restore later. If you re-enable backups after skipping restore, your current backup will be replaced with your new messaging history. + + Skip restore