mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 16:32:57 +01:00
Add infra for regV5 restore flows.
This commit is contained in:
committed by
Michelle Tang
parent
39de824bf0
commit
6c1897d8d5
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
|
||||
@@ -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<Disposable?> { 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>(TransferAccountState.Scanning)
|
||||
val state: StateFlow<TransferAccountState> = _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 <T : ViewModel> create(modelClass: Class<T>): 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
|
||||
}
|
||||
@@ -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<NavKey>.navigationEntries(
|
||||
)
|
||||
}
|
||||
|
||||
// TODO [regV5] Uncomment when restore selection flow is ready
|
||||
// entry<RegistrationRoute.ArchiveRestoreSelection> {
|
||||
// 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<RegistrationRoute.ArchiveRestoreSelection> {
|
||||
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<RegistrationRoute.ChooseRestoreOptionAfterRegistration> {
|
||||
// TODO: Implement RestoreScreen
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
) : DebugLoggableModel()
|
||||
|
||||
@@ -25,4 +25,3 @@ sealed class QrState : DebugLoggableModel() {
|
||||
data object Scanned : QrState()
|
||||
data object Failed : QrState()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<ArchiveRestoreOption> = emptyList(),
|
||||
val showSkipButton: Boolean = false,
|
||||
val showSkipRestoreWarning: Boolean = false
|
||||
) : DebugLoggableModel()
|
||||
@@ -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<RegistrationFlowState>,
|
||||
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<RegistrationFlowState>,
|
||||
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return ArchiveRestoreSelectionViewModel(repository, parentState, parentEventEmitter) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -15,4 +15,4 @@ open class DebugLoggableModel : DebugLoggable {
|
||||
toSafeString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12.683,1.69c-0.276,-0.066 -0.56,-0.065 -0.86,-0.065h-2.56c-0.809,0 -1.469,0 -2.005,0.044 -0.554,0.045 -1.052,0.142 -1.517,0.378a3.875,3.875 0,0 0,-1.694 1.694c-0.236,0.465 -0.333,0.963 -0.378,1.517 -0.044,0.536 -0.044,1.196 -0.044,2.005v9.474c0,0.809 0,1.469 0.044,2.005 0.045,0.554 0.142,1.052 0.378,1.517 0.372,0.73 0.965,1.322 1.694,1.694 0.465,0.236 0.963,0.333 1.517,0.378 0.536,0.044 1.196,0.044 2.005,0.044h5.474c0.809,0 1.469,0 2.005,-0.044 0.554,-0.045 1.052,-0.142 1.517,-0.378a3.875,3.875 0,0 0,1.694 -1.694c0.236,-0.465 0.333,-0.963 0.378,-1.517 0.044,-0.536 0.044,-1.196 0.044,-2.005v-6.56c0,-0.3 0,-0.584 -0.066,-0.86a2.376,2.376 0,0 0,-0.284 -0.687c-0.148,-0.242 -0.35,-0.442 -0.562,-0.655l-5.438,-5.438c-0.213,-0.213 -0.413,-0.413 -0.655,-0.562a2.374,2.374 0,0 0,-0.687 -0.284ZM11.125,3.375L9.3,3.375c-0.855,0 -1.443,0 -1.9,0.038 -0.445,0.036 -0.688,0.103 -0.865,0.194 -0.4,0.203 -0.725,0.528 -0.928,0.928 -0.09,0.177 -0.158,0.42 -0.194,0.866 -0.037,0.456 -0.038,1.045 -0.038,1.899v9.4c0,0.855 0,1.443 0.038,1.9 0.036,0.445 0.103,0.688 0.194,0.865 0.203,0.4 0.528,0.725 0.928,0.928 0.177,0.09 0.42,0.158 0.866,0.194 0.456,0.037 1.044,0.038 1.899,0.038h5.4c0.855,0 1.443,0 1.9,-0.038 0.445,-0.036 0.688,-0.103 0.865,-0.194 0.4,-0.203 0.725,-0.528 0.928,-0.928 0.09,-0.177 0.158,-0.42 0.194,-0.866 0.037,-0.456 0.038,-1.044 0.038,-1.899v-5.825h-1.862c-0.809,0 -1.469,0 -2.005,-0.044 -0.554,-0.045 -1.052,-0.141 -1.517,-0.378a3.875,3.875 0,0 1,-1.694 -1.694c-0.237,-0.465 -0.333,-0.963 -0.378,-1.517 -0.044,-0.536 -0.044,-1.196 -0.044,-2.005L11.125,3.375ZM18.338,9.125L17.013,8l-2.988,-2.988 -1.15,-1.35L12.875,5.2c0,0.855 0,1.443 0.038,1.9 0.036,0.445 0.103,0.688 0.194,0.865 0.203,0.4 0.528,0.725 0.928,0.928 0.177,0.09 0.42,0.158 0.866,0.194 0.456,0.037 1.044,0.038 1.899,0.038h1.538Z"
|
||||
android:fillColor="#000"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M1.63 6v7.2 0.06 1.98c0 0.8 0 1.47 0.04 2C1.7 17.8 1.8 18.3 2.05 18.76c0.37 0.73 0.96 1.32 1.7 1.7 0.46 0.23 0.95 0.33 1.5 0.37 0.54 0.05 1.2 0.05 2.01 0.05h9.48c0.8 0 1.47 0 2-0.05 0.56-0.04 1.05-0.14 1.52-0.38 0.73-0.37 1.32-0.96 1.7-1.7 0.23-0.46 0.33-0.95 0.37-1.5 0.05-0.54 0.05-1.2 0.05-2.01V9.76c0-0.8 0-1.47-0.05-2C22.3 7.2 22.2 6.7 21.95 6.24c-0.37-0.73-0.96-1.32-1.7-1.7-0.46-0.23-0.95-0.33-1.5-0.37-0.54-0.05-1.2-0.05-2.01-0.05h-5.75c-0.41 0-0.81-0.15-1.11-0.43L9.72 3.54c-0.63-0.59-1.45-0.92-2.3-0.92H5C3.14 2.63 1.62 4.14 1.62 6Zm18.96 5.4c0.03 0.46 0.04 1.05 0.04 1.9v1.9c0 0.85 0 1.44-0.04 1.9-0.04 0.45-0.1 0.69-0.2 0.86-0.2 0.4-0.53 0.73-0.93 0.93-0.17 0.1-0.41 0.16-0.86 0.2-0.46 0.03-1.05 0.04-1.9 0.04H7.3c-0.85 0-1.44 0-1.9-0.04-0.45-0.04-0.69-0.1-0.86-0.2-0.4-0.2-0.73-0.53-0.93-0.93-0.1-0.17-0.16-0.41-0.2-0.86-0.03-0.46-0.04-1.05-0.04-1.9v-1.9c0-0.85 0-1.44 0.04-1.9 0.04-0.45 0.1-0.69 0.2-0.86 0.2-0.4 0.53-0.73 0.93-0.93C4.7 9.5 4.95 9.45 5.4 9.4c0.46-0.03 1.05-0.04 1.9-0.04h9.4c0.85 0 1.44 0 1.9 0.04 0.45 0.04 0.69 0.1 0.86 0.2 0.4 0.2 0.73 0.53 0.93 0.93 0.1 0.17 0.16 0.41 0.2 0.86ZM3.38 6C3.38 5.1 4.1 4.37 5 4.37h2.41c0.41 0 0.81 0.16 1.11 0.44l0.16 0.15c0.63 0.59 1.45 0.92 2.3 0.92h5.72c0.85 0 1.44 0 1.9 0.03 0.45 0.04 0.69 0.1 0.86 0.2 0.4 0.2 0.73 0.53 0.93 0.93 0.06 0.11 0.12 0.25 0.15 0.46 0.05 0.36 0.13 0.5 0.57 0.93l-0.1 0.1c-0.23-0.2-0.5-0.35-0.75-0.48-0.47-0.24-0.96-0.34-1.52-0.38-0.53-0.05-1.2-0.05-2-0.04H7.26c-0.8 0-1.47 0-2 0.04C4.7 7.7 4.2 7.8 3.74 8.05c-0.29 0.14-0.56 0.33-0.8 0.54L2.86 8.52C3.28 8.09 3.38 7.9 3.38 7.54V6Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M8.12 5.13C7.95 4.97 7.73 4.88 7.5 4.88c-0.23 0-0.45 0.09-0.62 0.25l-3.5 3.5c-0.34 0.34-0.34 0.9 0 1.24 0.34 0.34 0.9 0.34 1.24 0l2.13-2.13L6.62 9.5v8.25c0 0.48 0.4 0.88 0.88 0.88s0.88-0.4 0.88-0.88V9.5L8.24 7.74l2.13 2.13c0.34 0.34 0.9 0.34 1.24 0 0.34-0.34 0.34-0.9 0-1.24l-3.5-3.5Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17.12 18.87c-0.17 0.16-0.39 0.25-0.62 0.25-0.23 0-0.45-0.09-0.62-0.25l-3.5-3.5c-0.34-0.34-0.34-0.9 0-1.24 0.34-0.34 0.9-0.34 1.24 0l2.13 2.13-0.13-1.76V6.25c0-0.48 0.4-0.88 0.88-0.88s0.88 0.4 0.88 0.88v8.25l-0.13 1.76 2.13-2.13c0.34-0.34 0.9-0.34 1.24 0 0.34 0.34 0.34 0.9 0 1.24l-3.5 3.5Z"/>
|
||||
</vector>
|
||||
@@ -94,4 +94,40 @@
|
||||
<string name="WelcomeFragment_restore_action_i_dont_have_my_old_phone">I don\'t have my old phone</string>
|
||||
<!-- Row subtitle for restore/transfer without using a previous device -->
|
||||
<string name="WelcomeFragment_restore_action_reinstalling">Or you\'re reinstalling Signal on the same device</string>
|
||||
|
||||
<!-- ArchiveRestoreSelectionScreen -->
|
||||
<!-- Title text for the archive restore selection screen -->
|
||||
<string name="ArchiveRestoreSelectionScreen__restore_or_transfer_account">Restore or transfer account</string>
|
||||
<!-- Subtitle text for the archive restore selection screen -->
|
||||
<string name="ArchiveRestoreSelectionScreen__subheading">Choose how you’d like to restore your message history and account data.</string>
|
||||
<!-- Title for the Signal Backups option card -->
|
||||
<string name="ArchiveRestoreSelectionScreen__from_signal_backups">From Signal Backups</string>
|
||||
<!-- Subtitle for the Signal Backups option card -->
|
||||
<string name="ArchiveRestoreSelectionScreen__your_free_or_paid_signal_backup_plan">Your free or paid Signal Backup plan</string>
|
||||
<!-- Title for an option that, when clicked, will take the user to a flow to restore form a local backup -->
|
||||
<string name="ArchiveRestoreSelectionScreen__local_backup_card_title">Restore on-device backup</string>
|
||||
<!-- Description for an option that, when clicked, will take the user to a flow to restore form a local backup -->
|
||||
<string name="ArchiveRestoreSelectionScreen__local_backup_card_description">Restore your messages from a backup you saved on your device.</string>
|
||||
<!-- Title for an option that, when clicked, will take the user to a flow to restore form a local backup -->
|
||||
<string name="ArchiveRestoreSelectionScreen__skip_restore_title">Continue without transferring</string>
|
||||
<!-- Description for an option that, when clicked, will take the user to a flow to restore form a local backup -->
|
||||
<string name="ArchiveRestoreSelectionScreen__skip_restore_description">Messages & media won’t be transferred</string>
|
||||
<!-- Title for the backup folder option card (V2 format) -->
|
||||
<string name="ArchiveRestoreSelectionScreen__from_a_backup_folder">From a backup folder</string>
|
||||
<!-- Title for the backup file option card (V1 legacy format) -->
|
||||
<string name="ArchiveRestoreSelectionScreen__from_a_backup_file">From a backup file</string>
|
||||
<!-- Subtitle for the backup folder/file option cards -->
|
||||
<string name="ArchiveRestoreSelectionScreen__choose_a_backup_youve_saved">Choose a backup you\'ve saved</string>
|
||||
<!-- Title for the device transfer option card -->
|
||||
<string name="ArchiveRestoreSelectionScreen__from_your_old_phone">From your old phone</string>
|
||||
<!-- Subtitle for the device transfer option card -->
|
||||
<string name="ArchiveRestoreSelectionScreen__transfer_directly_from_old">Transfer directly from your old Android</string>
|
||||
<!-- Skip button text -->
|
||||
<string name="ArchiveRestoreSelectionScreen__skip">Skip</string>
|
||||
<!-- Title for the skip restore confirmation dialog -->
|
||||
<string name="ArchiveRestoreSelectionScreen__skip_restore_dialog_title">Skip restore?</string>
|
||||
<!-- Warning body text for the skip restore confirmation dialog -->
|
||||
<string name="ArchiveRestoreSelectionScreen__skip_restore_dialog_warning">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.</string>
|
||||
<!-- Confirm button text for the skip restore dialog -->
|
||||
<string name="ArchiveRestoreSelectionScreen__skip_restore_dialog_confirm_button">Skip restore</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user