mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 00:17:41 +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
|
||||
}
|
||||
Reference in New Issue
Block a user