Add device transfer flow to regV5.

This commit is contained in:
Greyson Parrelli
2026-04-27 13:24:56 -04:00
committed by Cody Henthorne
parent 566c2d5838
commit 754dd15c94
48 changed files with 2820 additions and 66 deletions
@@ -10,6 +10,7 @@ import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.logging.Log;
import org.signal.devicetransfer.NewDeviceRestoreStatus;
import org.signal.devicetransfer.ServerTask;
import org.thoughtcrime.securesms.AppInitialization;
import org.thoughtcrime.securesms.backup.BackupEvent;
@@ -29,7 +30,10 @@ import java.io.InputStream;
* Performs the restore with the backup data coming in over the input stream. Used in
* conjunction with {@link org.signal.devicetransfer.DeviceToDeviceTransferService}.
*/
final class NewDeviceServerTask implements ServerTask {
public final class NewDeviceServerTask implements ServerTask {
public NewDeviceServerTask() {}
private static final String TAG = Log.tag(NewDeviceServerTask.class);
@@ -62,13 +66,13 @@ final class NewDeviceServerTask implements ServerTask {
Log.i(TAG, "Backup restore complete.");
} catch (FullBackupImporter.DatabaseDowngradeException e) {
Log.w(TAG, "Failed due to the backup being from a newer version of Signal.", e);
EventBus.getDefault().post(new Status(0, Status.State.FAILURE_VERSION_DOWNGRADE));
EventBus.getDefault().post(new NewDeviceRestoreStatus(0, NewDeviceRestoreStatus.State.FAILURE_VERSION_DOWNGRADE));
} catch (FullBackupImporter.ForeignKeyViolationException e) {
Log.w(TAG, "Failed due to foreign key constraint violations.", e);
EventBus.getDefault().post(new Status(0, Status.State.FAILURE_FOREIGN_KEY));
EventBus.getDefault().post(new NewDeviceRestoreStatus(0, NewDeviceRestoreStatus.State.FAILURE_FOREIGN_KEY));
} catch (IOException e) {
Log.w(TAG, e);
EventBus.getDefault().post(new Status(0, Status.State.FAILURE_UNKNOWN));
EventBus.getDefault().post(new NewDeviceRestoreStatus(0, NewDeviceRestoreStatus.State.FAILURE_UNKNOWN));
} finally {
EventBus.getDefault().unregister(this);
DataRestoreConstraint.setRestoringData(false);
@@ -77,42 +81,16 @@ final class NewDeviceServerTask implements ServerTask {
long end = System.currentTimeMillis();
Log.i(TAG, "Receive took: " + (end - start));
EventBus.getDefault().post(new Status(0, Status.State.RESTORE_COMPLETE));
EventBus.getDefault().post(new NewDeviceRestoreStatus(0, NewDeviceRestoreStatus.State.RESTORE_COMPLETE));
}
@Subscribe(threadMode = ThreadMode.POSTING)
public void onEvent(BackupEvent event) {
if (event.getType() == BackupEvent.Type.PROGRESS) {
EventBus.getDefault().post(new Status(event.getCount(), Status.State.IN_PROGRESS));
EventBus.getDefault().post(new NewDeviceRestoreStatus(event.getCount(), NewDeviceRestoreStatus.State.IN_PROGRESS));
} else if (event.getType() == BackupEvent.Type.FINISHED) {
EventBus.getDefault().post(new Status(event.getCount(), Status.State.TRANSFER_COMPLETE));
EventBus.getDefault().post(new NewDeviceRestoreStatus(event.getCount(), NewDeviceRestoreStatus.State.TRANSFER_COMPLETE));
}
}
public static final class Status {
private final long messageCount;
private final State state;
public Status(long messageCount, State state) {
this.messageCount = messageCount;
this.state = state;
}
public long getMessageCount() {
return messageCount;
}
public @NonNull State getState() {
return state;
}
public enum State {
IN_PROGRESS,
TRANSFER_COMPLETE,
RESTORE_COMPLETE,
FAILURE_VERSION_DOWNGRADE,
FAILURE_FOREIGN_KEY,
FAILURE_UNKNOWN
}
}
}
@@ -8,6 +8,7 @@ import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.devicetransfer.DeviceToDeviceTransferService
import org.signal.devicetransfer.NewDeviceRestoreStatus
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.restore.RestoreActivity
@@ -62,17 +63,17 @@ class NewDeviceTransferFragment : DeviceTransferFragment() {
private inner class ServerTaskListener {
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEventMainThread(event: NewDeviceServerTask.Status) {
fun onEventMainThread(event: NewDeviceRestoreStatus) {
status.text = getString(R.string.DeviceTransfer__d_messages_so_far, event.messageCount)
when (event.state) {
NewDeviceServerTask.Status.State.IN_PROGRESS,
NewDeviceServerTask.Status.State.TRANSFER_COMPLETE -> Unit
NewDeviceRestoreStatus.State.IN_PROGRESS,
NewDeviceRestoreStatus.State.TRANSFER_COMPLETE -> Unit
NewDeviceServerTask.Status.State.RESTORE_COMPLETE -> onRestoreComplete()
NewDeviceServerTask.Status.State.FAILURE_VERSION_DOWNGRADE -> abort(R.string.NewDeviceTransfer__cannot_transfer_from_a_newer_version_of_signal)
NewDeviceServerTask.Status.State.FAILURE_FOREIGN_KEY -> abort(R.string.NewDeviceTransfer__failure_foreign_key)
NewDeviceServerTask.Status.State.FAILURE_UNKNOWN -> abort()
NewDeviceRestoreStatus.State.RESTORE_COMPLETE -> onRestoreComplete()
NewDeviceRestoreStatus.State.FAILURE_VERSION_DOWNGRADE -> abort(R.string.NewDeviceTransfer__cannot_transfer_from_a_newer_version_of_signal)
NewDeviceRestoreStatus.State.FAILURE_FOREIGN_KEY -> abort(R.string.NewDeviceTransfer__failure_foreign_key)
NewDeviceRestoreStatus.State.FAILURE_UNKNOWN -> abort()
}
}
}
@@ -41,6 +41,7 @@ import org.signal.registration.NetworkController.RestoreMasterKeyError
import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.NetworkController.SetAccountAttributesError
import org.signal.registration.NetworkController.SetRegistrationLockError
import org.signal.registration.NetworkController.SetRestoreMethodError
import org.signal.registration.NetworkController.SubmitVerificationCodeError
import org.signal.registration.NetworkController.SvrCredentials
import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse
@@ -72,6 +73,7 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import org.whispersystems.signalservice.api.account.AccountAttributes as ServiceAccountAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection as ServicePreKeyCollection
import org.whispersystems.signalservice.api.provisioning.RestoreMethod as ServiceRestoreMethod
/**
* Implementation of [NetworkController] that bridges to the app's existing network infrastructure.
@@ -443,8 +445,13 @@ class AppRegistrationNetworkController(
}
}
override suspend fun enqueueSvrGuessResetJob() {
override suspend fun enqueueSvrGuessResetJobIfPossible(): Boolean {
if (SignalStore.svr.pin == null) {
return false
}
AppDependencies.jobManager.add(ResetSvrGuessCountJob())
return true
}
override suspend fun enableRegistrationLock(): RequestResult<Unit, SetRegistrationLockError> = withContext(Dispatchers.IO) {
@@ -595,6 +602,26 @@ class AppRegistrationNetworkController(
AppDependencies.jobManager.add(RefreshAttributesJob())
}
override suspend fun setRestoreMethod(token: String, method: NetworkController.RestoreMethod): RequestResult<Unit, SetRestoreMethodError> = withContext(Dispatchers.IO) {
val serviceMethod = when (method) {
NetworkController.RestoreMethod.REMOTE_BACKUP -> ServiceRestoreMethod.REMOTE_BACKUP
NetworkController.RestoreMethod.LOCAL_BACKUP -> ServiceRestoreMethod.LOCAL_BACKUP
NetworkController.RestoreMethod.DEVICE_TRANSFER -> ServiceRestoreMethod.DEVICE_TRANSFER
NetworkController.RestoreMethod.DECLINE -> ServiceRestoreMethod.DECLINE
}
when (val result = AppDependencies.registrationApi.setRestoreMethod(token, serviceMethod)) {
is NetworkResult.Success -> RequestResult.Success(Unit)
is NetworkResult.StatusCodeError -> {
when (result.code) {
429 -> RequestResult.NonSuccess(SetRestoreMethodError.RateLimited(0.seconds))
else -> RequestResult.NonSuccess(SetRestoreMethodError.InvalidRequest("HTTP ${result.code}"))
}
}
is NetworkResult.NetworkError -> RequestResult.RetryableNetworkError(result.exception)
is NetworkResult.ApplicationError -> RequestResult.ApplicationError(result.throwable)
}
}
override suspend fun getBackupFileLastModified(
aep: AccountEntropyPool,
backupInfo: NetworkController.GetBackupInfoResponse
@@ -627,6 +654,26 @@ class AppRegistrationNetworkController(
}
}
override fun startNewDeviceTransferServer(context: Context, aep: AccountEntropyPool) {
val pendingIntent = android.app.PendingIntent.getActivity(
context,
0,
org.thoughtcrime.securesms.MainActivity.clearTop(context),
org.signal.core.util.PendingIntentFlags.mutable()
)
val notificationData = org.signal.devicetransfer.DeviceToDeviceTransferService.TransferNotificationData(
org.thoughtcrime.securesms.notifications.NotificationIds.DEVICE_TRANSFER,
org.thoughtcrime.securesms.notifications.NotificationChannels.getInstance().BACKUPS,
org.thoughtcrime.securesms.R.drawable.ic_signal_backup
)
org.signal.devicetransfer.DeviceToDeviceTransferService.startServer(
context,
org.thoughtcrime.securesms.devicetransfer.newdevice.NewDeviceServerTask(),
notificationData,
pendingIntent
)
}
override fun startProvisioning(): Flow<ProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<java.io.Closeable>()
val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration()
+1
View File
@@ -53,6 +53,7 @@ dependencies {
// Registration library
implementation(project(":feature:registration"))
implementation(project(":lib:device-transfer"))
// Core dependencies
implementation(project(":core:ui"))
@@ -6,6 +6,12 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Device transfer (Wi-Fi Direct). ACCESS_FINE_LOCATION is required on API < 33; NEARBY_WIFI_DEVICES on API 33+. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<application
android:name=".RegistrationApplication"
android:backupAgent=".absbackup.RegistrationBackupAgent"
@@ -46,6 +46,7 @@ class RegistrationApplication : Application() {
Log.initialize(AndroidLogger)
RegistrationPreferences.init(this)
createDeviceTransferNotificationChannel()
val trustStore = SampleTrustStore()
val configuration = createServiceConfiguration(trustStore)
@@ -76,6 +77,16 @@ class RegistrationApplication : Application() {
)
}
private fun createDeviceTransferNotificationChannel() {
val manager = getSystemService(android.app.NotificationManager::class.java) ?: return
val channel = android.app.NotificationChannel(
DemoNetworkController.DEVICE_TRANSFER_NOTIFICATION_CHANNEL_ID,
"Device transfer",
android.app.NotificationManager.IMPORTANCE_LOW
)
manager.createNotificationChannel(channel)
}
private fun createPushServiceSocket(configuration: SignalServiceConfiguration): PushServiceSocket {
val credentialsProvider = NoopCredentialsProvider()
val signalAgent = "Signal-Android/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.SDK_INT}"
@@ -27,9 +27,11 @@ import org.signal.registration.NetworkController.RegisterAccountError
import org.signal.registration.NetworkController.RegisterAccountResponse
import org.signal.registration.NetworkController.RequestVerificationCodeError
import org.signal.registration.NetworkController.RestoreMasterKeyError
import org.signal.registration.NetworkController.RestoreMethod
import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.NetworkController.SetAccountAttributesError
import org.signal.registration.NetworkController.SetRegistrationLockError
import org.signal.registration.NetworkController.SetRestoreMethodError
import org.signal.registration.NetworkController.SubmitVerificationCodeError
import org.signal.registration.NetworkController.SvrCredentials
import org.signal.registration.NetworkController.UpdateSessionError
@@ -168,9 +170,9 @@ class DebugNetworkController(
return delegate.setPinAndMasterKeyOnSvr(pin, masterKey)
}
override suspend fun enqueueSvrGuessResetJob() {
override suspend fun enqueueSvrGuessResetJobIfPossible(): Boolean {
// No override support for simple value methods
delegate.enqueueSvrGuessResetJob()
return delegate.enqueueSvrGuessResetJobIfPossible()
}
override suspend fun enableRegistrationLock(): RequestResult<Unit, SetRegistrationLockError> {
@@ -201,6 +203,14 @@ class DebugNetworkController(
delegate.enqueueAccountAttributesSyncJob()
}
override suspend fun setRestoreMethod(token: String, method: RestoreMethod): RequestResult<Unit, SetRestoreMethodError> {
NetworkDebugState.getOverride<RequestResult<Unit, SetRestoreMethodError>>("setRestoreMethod")?.let {
Log.d(TAG, "[setRestoreMethod] Returning debug override")
return it
}
return delegate.setRestoreMethod(token, method)
}
override suspend fun getSvrCredentials(): RequestResult<SvrCredentials, GetSvrCredentialsError> {
NetworkDebugState.getOverride<RequestResult<SvrCredentials, GetSvrCredentialsError>>("getSvrCredentials")?.let {
Log.d(TAG, "[getSvrCredentials] Returning debug override")
@@ -213,6 +223,15 @@ class DebugNetworkController(
return delegate.startProvisioning()
}
override fun startNewDeviceTransferServer(context: android.content.Context, aep: org.signal.core.models.AccountEntropyPool) {
if (NetworkDebugState.fakeDeviceTransfer.value) {
Log.d(TAG, "[startNewDeviceTransferServer] Fake device transfer enabled (debug override)")
org.signal.registration.sample.dependencies.FakeDeviceTransferRunner.start()
} else {
delegate.startNewDeviceTransferServer(context, aep)
}
}
override suspend fun checkSvrCredentials(
e164: String,
credentials: List<SvrCredentials>
@@ -164,6 +164,10 @@ private fun NetworkDebugDialog(
SkipPushChallengeToggle()
Spacer(modifier = Modifier.height(12.dp))
FakeDeviceTransferToggle()
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
LazyColumn(
@@ -231,6 +235,37 @@ private fun SkipPushChallengeToggle() {
}
}
@Composable
private fun FakeDeviceTransferToggle() {
val fakeDeviceTransfer by NetworkDebugState.fakeDeviceTransfer.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Fake device transfer",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = "Scripts the transfer events so you don't need a second device",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = fakeDeviceTransfer,
onCheckedChange = { NetworkDebugState.setFakeDeviceTransfer(it) }
)
}
}
@Composable
private fun MethodOverrideRow(
methodInfo: DebugNetworkMockData.MethodOverrideInfo
@@ -24,6 +24,18 @@ object NetworkDebugState {
_skipPushChallenge.value = skip
}
/**
* When true, `startNewDeviceTransferServer` skips Wi-Fi Direct and replays a scripted sequence
* of `TransferStatus` + `NewDeviceRestoreStatus` events on a timer so the whole flow can be
* exercised without a second device. Off by default.
*/
private val _fakeDeviceTransfer = MutableStateFlow(false)
val fakeDeviceTransfer: StateFlow<Boolean> = _fakeDeviceTransfer.asStateFlow()
fun setFakeDeviceTransfer(fake: Boolean) {
_fakeDeviceTransfer.value = fake
}
/**
* Map of method name to the selected option name (e.g., "createSession" -> "success")
* A value of "unset" or absence means no override is active.
@@ -0,0 +1,285 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.dependencies
import org.signal.libsignal.protocol.kdf.HKDF
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Port of the bits of `org.thoughtcrime.securesms.backup.BackupRecordInputStream` that the demo
* ServerTask actually needs: reads the plaintext header to derive keys, then decrypts each
* frame's length + payload so we can detect the `end = true` sentinel, and drains any attachment
* body that follows a frame (attachments/stickers/avatars). Payloads are only parsed deeply
* enough to spot the end marker and pull the body length out of attachment-ish frames.
*
* Hand-rolled protobuf parsing instead of wiring up Wire codegen in the demo module.
*/
class DemoBackupStreamReader(
private val inputStream: InputStream,
passphrase: String
) {
private val cipher: Cipher = Cipher.getInstance("AES/CTR/NoPadding")
private val mac: Mac = Mac.getInstance("HmacSHA256")
private val cipherKey: ByteArray
private val iv: ByteArray
private val isFrameLengthEncrypted: Boolean
private var counter: Int
init {
val headerLengthBytes = readFully(4)
val headerLength = headerLengthBytes.toBigEndianInt()
val headerBytes = readFully(headerLength)
val header = parseHeader(headerBytes) ?: throw IOException("Backup stream does not start with a header frame")
this.iv = header.iv.copyOf()
if (iv.size != 16) throw IOException("Invalid IV length ${iv.size}")
this.isFrameLengthEncrypted = header.version >= 1
val key = deriveBackupKey(passphrase, header.salt)
val derived = HKDF.deriveSecrets(key, "Backup Export".toByteArray(), 64)
this.cipherKey = derived.copyOfRange(0, 32)
val macKey = derived.copyOfRange(32, 64)
this.mac.init(SecretKeySpec(macKey, "HmacSHA256"))
this.counter = iv.toBigEndianInt()
}
/**
* Reads one frame. Returns null if we've exhausted the stream. The returned [FrameInfo.end]
* tells the caller to stop; [FrameInfo.attachmentBodyLength] tells them to also drain that
* many attachment bytes via [drainAttachmentBody].
*/
fun readFrame(): FrameInfo {
val frameLength = decryptFrameLength()
if (frameLength <= 0) throw IOException("Bogus decrypted frame length $frameLength — wrong passphrase?")
val frame = readFully(frameLength)
val payloadLength = frameLength - 10
if (payloadLength < 0) throw IOException("Frame too small to contain MAC")
val theirMac = frame.copyOfRange(payloadLength, frameLength)
mac.update(frame, 0, payloadLength)
val ourMac = mac.doFinal().copyOf(10)
if (!MessageDigest.isEqual(ourMac, theirMac)) {
throw IOException("Bad MAC on frame — wrong passphrase?")
}
val plaintext = cipher.doFinal(frame, 0, payloadLength)
return parseFrame(plaintext)
}
/** Drains a post-frame attachment/sticker/avatar body of [length] bytes + its trailing MAC. */
fun drainAttachmentBody(length: Int) {
setCounter(counter++)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
mac.update(iv)
val buffer = ByteArray(8192)
var remaining = length
while (remaining > 0) {
val read = inputStream.read(buffer, 0, minOf(buffer.size, remaining))
if (read < 0) throw EOFException("Stream closed mid-attachment (need $remaining more bytes)")
mac.update(buffer, 0, read)
cipher.update(buffer, 0, read)
remaining -= read
}
cipher.doFinal()
val ourMac = mac.doFinal().copyOf(10)
val theirMac = readFully(10)
if (!MessageDigest.isEqual(ourMac, theirMac)) {
throw IOException("Bad attachment MAC")
}
}
private fun decryptFrameLength(): Int {
val lengthBytes = readFully(4)
setCounter(counter++)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
return if (isFrameLengthEncrypted) {
mac.update(lengthBytes)
val decrypted = cipher.update(lengthBytes)
if (decrypted == null || decrypted.size != 4) throw IOException("Cipher was not a stream cipher")
decrypted.toBigEndianInt()
} else {
lengthBytes.toBigEndianInt()
}
}
private fun setCounter(value: Int) {
iv[0] = (value ushr 24).toByte()
iv[1] = (value ushr 16).toByte()
iv[2] = (value ushr 8).toByte()
iv[3] = value.toByte()
}
private fun readFully(n: Int): ByteArray {
val buf = ByteArray(n)
var off = 0
while (off < n) {
val read = inputStream.read(buf, off, n - off)
if (read < 0) throw EOFException("Stream closed after reading $off of $n")
off += read
}
return buf
}
data class FrameInfo(val end: Boolean, val attachmentBodyLength: Int?)
private data class ParsedHeader(val iv: ByteArray, val salt: ByteArray?, val version: Int)
companion object {
private const val DIGEST_ROUNDS = 250_000
private fun deriveBackupKey(passphrase: String, salt: ByteArray?): ByteArray {
val digest = MessageDigest.getInstance("SHA-512")
val input = passphrase.replace(" ", "").toByteArray()
var hash = input
if (salt != null) digest.update(salt)
repeat(DIGEST_ROUNDS) {
digest.update(hash)
hash = digest.digest(input)
}
return hash.copyOf(32)
}
private fun ByteArray.toBigEndianInt(): Int {
require(size >= 4)
return ((this[0].toInt() and 0xFF) shl 24) or
((this[1].toInt() and 0xFF) shl 16) or
((this[2].toInt() and 0xFF) shl 8) or
(this[3].toInt() and 0xFF)
}
/** Parses a `BackupFrame { header = 1 }` wrapper and extracts Header fields. */
private fun parseHeader(backupFrameBytes: ByteArray): ParsedHeader? {
val outer = ProtoReader(backupFrameBytes)
while (outer.hasRemaining()) {
val (field, wireType) = outer.readTag()
if (field == 1 && wireType == WIRE_LENGTH_DELIMITED) {
val headerBytes = outer.readLengthDelimited()
return parseInnerHeader(headerBytes)
}
outer.skip(wireType)
}
return null
}
private fun parseInnerHeader(bytes: ByteArray): ParsedHeader {
val r = ProtoReader(bytes)
var iv: ByteArray? = null
var salt: ByteArray? = null
var version = 0
while (r.hasRemaining()) {
val (field, wireType) = r.readTag()
when (field) {
1 -> iv = r.readLengthDelimited()
2 -> salt = r.readLengthDelimited()
3 -> version = r.readVarint().toInt()
else -> r.skip(wireType)
}
}
return ParsedHeader(iv ?: ByteArray(0), salt, version)
}
/** Inspects a decrypted BackupFrame for end-of-stream and attached-body length. */
private fun parseFrame(frameBytes: ByteArray): FrameInfo {
val r = ProtoReader(frameBytes)
var end = false
var bodyLength: Int? = null
while (r.hasRemaining()) {
val (field, wireType) = r.readTag()
when (field) {
4 -> { // attachment { length = 3 }
val sub = r.readLengthDelimited()
bodyLength = extractLengthField(sub, 3)
}
6 -> end = r.readVarint() != 0L // end (bool)
7 -> { // avatar { length = 2 }
val sub = r.readLengthDelimited()
bodyLength = extractLengthField(sub, 2)
}
8 -> { // sticker { length = 2 }
val sub = r.readLengthDelimited()
bodyLength = extractLengthField(sub, 2)
}
else -> r.skip(wireType)
}
}
return FrameInfo(end, bodyLength)
}
private fun extractLengthField(bytes: ByteArray, fieldNumber: Int): Int? {
val r = ProtoReader(bytes)
while (r.hasRemaining()) {
val (field, wireType) = r.readTag()
if (field == fieldNumber && wireType == WIRE_VARINT) return r.readVarint().toInt()
r.skip(wireType)
}
return null
}
private const val WIRE_VARINT = 0
private const val WIRE_FIXED64 = 1
private const val WIRE_LENGTH_DELIMITED = 2
private const val WIRE_FIXED32 = 5
/** Minimal protobuf wire-format reader. Handles only the wire types we need. */
private class ProtoReader(private val bytes: ByteArray) {
private var pos: Int = 0
fun hasRemaining(): Boolean = pos < bytes.size
fun readVarint(): Long {
var result = 0L
var shift = 0
while (true) {
if (pos >= bytes.size) throw IOException("Truncated varint")
val b = bytes[pos++].toInt() and 0xFF
result = result or ((b and 0x7F).toLong() shl shift)
if (b and 0x80 == 0) return result
shift += 7
if (shift >= 64) throw IOException("Varint too long")
}
}
fun readTag(): Pair<Int, Int> {
val tag = readVarint().toInt()
return (tag ushr 3) to (tag and 0x07)
}
fun readLengthDelimited(): ByteArray {
val len = readVarint().toInt()
if (len < 0 || pos + len > bytes.size) throw IOException("Truncated length-delimited field (len=$len, remaining=${bytes.size - pos})")
val result = bytes.copyOfRange(pos, pos + len)
pos += len
return result
}
fun skip(wireType: Int) {
when (wireType) {
WIRE_VARINT -> readVarint()
WIRE_FIXED64 -> pos += 8
WIRE_LENGTH_DELIMITED -> {
val len = readVarint().toInt()
pos += len
}
WIRE_FIXED32 -> pos += 4
else -> throw IOException("Unknown wire type $wireType")
}
}
}
}
}
@@ -0,0 +1,74 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.dependencies
import android.content.Context
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.logging.Log
import org.signal.devicetransfer.NewDeviceRestoreStatus
import org.signal.devicetransfer.ServerTask
import java.io.InputStream
/**
* Real [ServerTask] for the demo: parses incoming Signal backup frames using the user's AEP as
* the passphrase, counts frames, and exits cleanly when the `end = true` frame arrives (matching
* production `FullBackupImporter` behavior). Frame contents are discarded — we only care about
* driving the transfer protocol and reporting progress.
*/
class DemoDeviceTransferServerTask(private val passphrase: String) : ServerTask {
companion object {
private val TAG = Log.tag(DemoDeviceTransferServerTask::class)
private const val PROGRESS_POST_INTERVAL_MS = 250L
}
override fun run(context: Context, inputStream: InputStream) {
val bus = EventBus.getDefault()
Log.i(TAG, "Reading incoming backup stream with ${passphrase.length}-char passphrase")
val reader: DemoBackupStreamReader = try {
DemoBackupStreamReader(inputStream, passphrase)
} catch (t: Throwable) {
Log.w(TAG, "Failed to initialize backup reader", t)
bus.post(NewDeviceRestoreStatus(0, NewDeviceRestoreStatus.State.FAILURE_UNKNOWN))
return
}
var frames = 0L
var lastPostMs = System.currentTimeMillis()
try {
while (true) {
val info = reader.readFrame()
frames++
val bodyLength = info.attachmentBodyLength
if (bodyLength != null && bodyLength > 0) {
reader.drainAttachmentBody(bodyLength)
}
val now = System.currentTimeMillis()
if (now - lastPostMs >= PROGRESS_POST_INTERVAL_MS) {
lastPostMs = now
Log.d(TAG, "Received $frames frames")
bus.post(NewDeviceRestoreStatus(frames, NewDeviceRestoreStatus.State.IN_PROGRESS))
}
if (info.end) {
Log.i(TAG, "Received end-of-backup frame at $frames — transfer complete")
break
}
}
} catch (t: Throwable) {
Log.w(TAG, "Backup stream read failed after $frames frames", t)
bus.post(NewDeviceRestoreStatus(frames, NewDeviceRestoreStatus.State.FAILURE_UNKNOWN))
return
}
Log.i(TAG, "Transfer stream drained cleanly, frames=$frames")
bus.post(NewDeviceRestoreStatus(frames, NewDeviceRestoreStatus.State.TRANSFER_COMPLETE))
bus.post(NewDeviceRestoreStatus(frames, NewDeviceRestoreStatus.State.RESTORE_COMPLETE))
}
}
@@ -5,6 +5,8 @@
package org.signal.registration.sample.dependencies
import android.app.PendingIntent
import android.content.Intent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
@@ -26,6 +28,7 @@ import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.signal.core.util.SleepTimer
import org.signal.core.util.logging.Log
import org.signal.devicetransfer.DeviceToDeviceTransferService
import org.signal.libsignal.net.Network
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.protocol.IdentityKey
@@ -53,6 +56,7 @@ import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse
import org.signal.registration.NetworkController.UpdateSessionError
import org.signal.registration.NetworkController.VerificationCodeTransport
import org.signal.registration.proto.RegistrationProvisionMessage
import org.signal.registration.sample.MainActivity
import org.signal.registration.sample.fcm.FcmUtil
import org.signal.registration.sample.fcm.PushChallengeReceiver
import org.signal.registration.sample.storage.RegistrationPreferences
@@ -88,6 +92,8 @@ class DemoNetworkController(
companion object {
private val TAG = Log.tag(DemoNetworkController::class)
const val DEVICE_TRANSFER_NOTIFICATION_CHANNEL_ID = "device_transfer"
private const val DEVICE_TRANSFER_NOTIFICATION_ID = 4321
}
private val json = Json { ignoreUnknownKeys = true }
@@ -393,6 +399,27 @@ class DemoNetworkController(
return "https://signalcaptchas.org/staging/registration/generate.html"
}
override fun startNewDeviceTransferServer(context: android.content.Context, aep: AccountEntropyPool) {
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val notificationData = DeviceToDeviceTransferService.TransferNotificationData(
DEVICE_TRANSFER_NOTIFICATION_ID,
DEVICE_TRANSFER_NOTIFICATION_CHANNEL_ID,
android.R.drawable.stat_sys_download
)
DeviceToDeviceTransferService.startServer(
context,
DemoDeviceTransferServerTask(aep.value),
notificationData,
pendingIntent
)
}
override fun startProvisioning(): Flow<ProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<java.io.Closeable>()
@@ -614,7 +641,11 @@ class DemoNetworkController(
}
}
override suspend fun enqueueSvrGuessResetJob() {
override suspend fun enqueueSvrGuessResetJobIfPossible(): Boolean {
if (RegistrationPreferences.pin == null) {
return false
}
val pin = checkNotNull(RegistrationPreferences.pin) { "Pin is not set!" }
val masterKey = checkNotNull(RegistrationPreferences.masterKey) { "Master key is not set!" }
@@ -622,6 +653,8 @@ class DemoNetworkController(
if (result !is RequestResult.Success) {
Log.w(TAG, "Failed to set pin and master key on SVR! A real app would retry. Result: $result")
}
return true
}
override suspend fun enableRegistrationLock(): RequestResult<Unit, NetworkController.SetRegistrationLockError> = withContext(Dispatchers.IO) {
@@ -862,6 +895,36 @@ class DemoNetworkController(
}
}
override suspend fun setRestoreMethod(token: String, method: NetworkController.RestoreMethod): RequestResult<Unit, NetworkController.SetRestoreMethodError> = withContext(Dispatchers.IO) {
try {
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
val body = json.encodeToString(SetRestoreMethodRequest.serializer(), SetRestoreMethodRequest(method))
.toRequestBody("application/json".toMediaType())
val request = okhttp3.Request.Builder()
.url("$baseUrl/v1/devices/restore_account/${java.net.URLEncoder.encode(token, "UTF-8")}")
.put(body)
.build()
okHttpClient.newCall(request).execute().use { response ->
when (response.code) {
200, 204 -> {
Log.i(TAG, "[setRestoreMethod] Successfully reported restore method: $method")
RequestResult.Success(Unit)
}
429 -> RequestResult.NonSuccess(NetworkController.SetRestoreMethodError.RateLimited(0.seconds))
else -> RequestResult.NonSuccess(NetworkController.SetRestoreMethodError.InvalidRequest("HTTP ${response.code}: ${response.body.string()}"))
}
}
} catch (e: IOException) {
Log.w(TAG, "[setRestoreMethod] IOException", e)
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
Log.w(TAG, "[setRestoreMethod] Exception", e)
RequestResult.ApplicationError(e)
}
}
private fun buildCurrentAccountAttributes(): AccountAttributes {
val aep = RegistrationPreferences.aep
val registrationLock = if (RegistrationPreferences.registrationLockEnabled && aep != null) {
@@ -1107,6 +1170,9 @@ class DemoNetworkController(
val redemptionTime: Long
)
@Serializable
private data class SetRestoreMethodRequest(val method: NetworkController.RestoreMethod)
private fun AccountAttributes.toServiceAccountAttributes(): ServiceAccountAttributes {
return ServiceAccountAttributes(
signalingKey,
@@ -0,0 +1,64 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.dependencies
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.signal.devicetransfer.NewDeviceRestoreStatus
import org.signal.devicetransfer.TransferStatus
/**
* Debug-only simulator that replays the sequence of `TransferStatus` and `NewDeviceRestoreStatus`
* events on a timer, so the full device-transfer UX can be exercised on a single device.
* Toggled via the "Fake device transfer" switch in the network debug overlay.
*/
object FakeDeviceTransferRunner {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var job: Job? = null
fun start() {
stop()
job = scope.launch {
val bus = EventBus.getDefault()
delay(500)
bus.postSticky(TransferStatus.startingUp())
delay(1_000)
bus.postSticky(TransferStatus.discovery())
delay(2_000)
bus.postSticky(TransferStatus.verificationRequired(1234567))
// The user has up to this delay to confirm the SAS; regardless of their choice, we fake
// the service-connected signal so the flow can progress.
delay(6_000)
bus.postSticky(TransferStatus.serviceConnected())
// Progress: climb some byte counts, then transfer/restore complete.
for (count in listOf(100L, 500L, 1_500L, 4_200L)) {
delay(700)
bus.post(NewDeviceRestoreStatus(count, NewDeviceRestoreStatus.State.IN_PROGRESS))
}
delay(500)
bus.post(NewDeviceRestoreStatus(4_200, NewDeviceRestoreStatus.State.TRANSFER_COMPLETE))
delay(800)
bus.post(NewDeviceRestoreStatus(0, NewDeviceRestoreStatus.State.RESTORE_COMPLETE))
}
}
fun stop() {
job?.cancel()
job = null
}
}
+1
View File
@@ -44,6 +44,7 @@ dependencies {
implementation(project(":core:util"))
implementation(project(":core:models-jvm"))
implementation(project(":core:serialization"))
implementation(project(":lib:device-transfer"))
implementation(libs.libsignal.android)
// Compose BOM
@@ -135,10 +135,10 @@ interface NetworkController {
/**
* Requests that the currently-set PIN and [MasterKey] are backed up to SVR.
* It should always be the case that when this is called, you should have a stored PIN and [MasterKey].
* If you do not, you should probably crash.
*
* @return True if a job was successfully enqueued, otherwise false. Enqueueing will fail if a PIN is unavailable, which can happen in some restoration flows.
*/
suspend fun enqueueSvrGuessResetJob()
suspend fun enqueueSvrGuessResetJobIfPossible(): Boolean
/**
* Enables registration lock on the account using the registration lock token
@@ -229,11 +229,27 @@ interface NetworkController {
*/
fun startProvisioning(): Flow<ProvisioningEvent>
// /**
// * Set [RestoreMethod] enum on the server for use by the old device to update UX.
// */
// suspend fun setRestoreMethod(token: String, method: RestoreMethod)
//
/**
* Starts `DeviceToDeviceTransferService` in server mode on the new device. The concrete
* [org.signal.devicetransfer.ServerTask] that receives and imports the backup lives in the app
* module (it references SignalDatabase / FullBackupImporter / SignalStore), as does the
* foreground-service notification channel and the tap-through `PendingIntent`. Consolidating
* the start call here keeps this module free of app-specific notification plumbing.
*
* @param aep The user's [AccountEntropyPool]. The production implementation ignores this (it
* pulls the AEP from `SignalStore.account` directly); demo/test implementations need it
* passed in because they have no equivalent store.
*/
fun startNewDeviceTransferServer(context: android.content.Context, aep: AccountEntropyPool)
/**
* Reports the user's chosen restore method to the server so the old device's quick-restore UI can update.
* The [token] is the `restoreMethodToken` delivered in the [ProvisioningMessage].
*
* `PUT /v1/devices/restore_account/{token}`
*/
suspend fun setRestoreMethod(token: String, method: RestoreMethod): RequestResult<Unit, SetRestoreMethodError>
// /**
// * Registers a device as a linked device on a pre-existing account.
// *
@@ -322,6 +338,11 @@ interface NetworkController {
data class InvalidRequest(val message: String) : CheckSvrCredentialsError()
}
sealed class SetRestoreMethodError : BadRequestError {
data class InvalidRequest(val message: String) : SetRestoreMethodError()
data class RateLimited(val retryAfter: Duration) : SetRestoreMethodError()
}
sealed class GetBackupInfoError : BadRequestError {
data class BadArguments(val body: String? = null) : GetBackupInfoError()
data class BadAuthCredential(val body: String? = null) : GetBackupInfoError()
@@ -475,6 +496,13 @@ interface NetworkController {
SMS, VOICE
}
/**
* The user's chosen restore method, reported back to the old device via [setRestoreMethod] so its UX can update.
*/
enum class RestoreMethod {
REMOTE_BACKUP, LOCAL_BACKUP, DEVICE_TRANSFER, DECLINE
}
@Serializable
data class GetBackupInfoResponse(
val cdn: Int?,
@@ -23,7 +23,8 @@ data class PersistedFlowState(
val sessionE164: String?,
val doNotAttemptRecoveryPassword: Boolean,
val pendingRestoreOption: PendingRestoreOption? = null,
val restoredAepValue: String? = null
val restoredAepValue: String? = null,
val restoreMethodToken: String? = null
)
/**
@@ -36,7 +37,8 @@ fun RegistrationFlowState.toPersistedFlowState(): PersistedFlowState {
sessionE164 = sessionE164,
doNotAttemptRecoveryPassword = doNotAttemptRecoveryPassword,
pendingRestoreOption = pendingRestoreOption,
restoredAepValue = unverifiedRestoredAep?.value
restoredAepValue = unverifiedRestoredAep?.value,
restoreMethodToken = restoreMethodToken
)
}
@@ -61,6 +63,7 @@ fun PersistedFlowState.toRegistrationFlowState(
preExistingRegistrationData = preExistingRegistrationData,
doNotAttemptRecoveryPassword = doNotAttemptRecoveryPassword,
pendingRestoreOption = pendingRestoreOption,
unverifiedRestoredAep = restoredAepValue?.let { AccountEntropyPool(it) }
unverifiedRestoredAep = restoredAepValue?.let { AccountEntropyPool(it) },
restoreMethodToken = restoreMethodToken
)
}
@@ -37,6 +37,9 @@ sealed interface RegistrationFlowEvent : DebugLoggable {
/** The user selected (or cleared) a restore option before entering their phone number. */
data class PendingRestoreOptionSelected(val option: PendingRestoreOption?) : RegistrationFlowEvent
/** Provisioning data was received from the old device. Carries the token used to notify it of our restore-method choice. */
data class RestoreMethodTokenReceived(val token: String) : RegistrationFlowEvent
/** An AEP was manually input by the user. It has not yet been verified against the server. */
data class UserSuppliedAepSubmitted(val aep: AccountEntropyPool) : RegistrationFlowEvent
@@ -46,10 +46,16 @@ data class RegistrationFlowState(
/** The AEP obtained via manual entry for local/remote backup restore. May or may not be valid for the current phone number. */
val unverifiedRestoredAep: AccountEntropyPool? = null,
/**
* If non-null, identifies the old device's quick-restore listener. Set when we receive a [NetworkController.ProvisioningMessage]
* from the old device, then used to notify the old device of the user's restore-method selection so its UX can update.
*/
val restoreMethodToken: String? = null,
/** If true, the ViewModel is still deciding whether to restore a previous flow or start fresh. */
val isRestoringNavigationState: Boolean = true
) : Parcelable, DebugLoggableModel() {
override fun toSafeString(): String {
return "RegistrationFlowState(backStack=${backStack.joinToString()}, sessionMetadata=${sessionMetadata.let { "present" }}, sessionE164=$sessionE164, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()}, temporaryMasterKey=${temporaryMasterKey?.toString()?.censor()}, preExistingRegistrationData=${preExistingRegistrationData?.let { "present" }}, doNotAttemptRecoveryPassword=$doNotAttemptRecoveryPassword, pendingRestoreOption=$pendingRestoreOption, unverifiedRestoredAep=${unverifiedRestoredAep?.displayValue?.censor()}, isRestoringNavigation=$isRestoringNavigationState)"
return "RegistrationFlowState(backStack=${backStack.joinToString()}, sessionMetadata=${sessionMetadata.let { "present" }}, sessionE164=$sessionE164, accountEntropyPool=${accountEntropyPool?.displayValue?.censor()}, temporaryMasterKey=${temporaryMasterKey?.toString()?.censor()}, preExistingRegistrationData=${preExistingRegistrationData?.let { "present" }}, doNotAttemptRecoveryPassword=$doNotAttemptRecoveryPassword, pendingRestoreOption=$pendingRestoreOption, unverifiedRestoredAep=${unverifiedRestoredAep?.displayValue?.censor()}, restoreMethodToken=${restoreMethodToken?.censor()}, isRestoringNavigation=$isRestoringNavigationState)"
}
}
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -57,6 +58,14 @@ import org.signal.registration.screens.countrycode.Country
import org.signal.registration.screens.countrycode.CountryCodePickerRepository
import org.signal.registration.screens.countrycode.CountryCodePickerScreen
import org.signal.registration.screens.countrycode.CountryCodePickerViewModel
import org.signal.registration.screens.devicetransfer.complete.DeviceTransferCompleteScreen
import org.signal.registration.screens.devicetransfer.complete.DeviceTransferCompleteViewModel
import org.signal.registration.screens.devicetransfer.instructions.DeviceTransferInstructionsScreen
import org.signal.registration.screens.devicetransfer.instructions.DeviceTransferInstructionsViewModel
import org.signal.registration.screens.devicetransfer.progress.DeviceTransferProgressScreen
import org.signal.registration.screens.devicetransfer.progress.DeviceTransferProgressViewModel
import org.signal.registration.screens.devicetransfer.setup.DeviceTransferSetupScreen
import org.signal.registration.screens.devicetransfer.setup.DeviceTransferSetupViewModel
import org.signal.registration.screens.linkaccount.LinkAccountScreen
import org.signal.registration.screens.linkaccount.LinkAccountScreenEvent
import org.signal.registration.screens.linkaccount.LinkAccountViewModel
@@ -166,7 +175,6 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
restoreOptions = buildList {
add(ArchiveRestoreOption.SignalSecureBackup)
add(ArchiveRestoreOption.LocalBackup)
add(ArchiveRestoreOption.DeviceTransfer)
},
isPreRegistration = true
)
@@ -177,7 +185,6 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
restoreOptions = buildList {
add(ArchiveRestoreOption.SignalSecureBackup)
add(ArchiveRestoreOption.LocalBackup)
add(ArchiveRestoreOption.DeviceTransfer)
add(ArchiveRestoreOption.None)
},
isPreRegistration = false
@@ -211,6 +218,18 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
@Serializable
data object Transfer : RegistrationRoute
@Serializable
data object DeviceTransferInstructions : RegistrationRoute
@Serializable
data object DeviceTransferSetup : RegistrationRoute
@Serializable
data object DeviceTransferProgress : RegistrationRoute
@Serializable
data object DeviceTransferComplete : RegistrationRoute
@Serializable
data object Profile : RegistrationRoute
@@ -632,6 +651,8 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
factory = ArchiveRestoreSelectionViewModel.Factory(
restoreOptions = key.restoreOptions,
isPreRegistration = key.isPreRegistration,
repository = registrationRepository,
parentState = registrationViewModel.state,
parentEventEmitter = registrationViewModel::onEvent
)
)
@@ -766,6 +787,68 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
// TODO: Implement TransferScreen
}
// -- Device Transfer: Instructions
entry<RegistrationRoute.DeviceTransferInstructions> {
val viewModel: DeviceTransferInstructionsViewModel = viewModel(
factory = DeviceTransferInstructionsViewModel.Factory(parentEventEmitter)
)
val state by viewModel.state.collectAsStateWithLifecycle()
DeviceTransferInstructionsScreen(
state = state,
onEvent = viewModel::onEvent
)
}
// -- Device Transfer: Setup (permissions, wifi, verify SAS)
entry<RegistrationRoute.DeviceTransferSetup> {
val context = LocalContext.current.applicationContext
val viewModel: DeviceTransferSetupViewModel = viewModel(
factory = DeviceTransferSetupViewModel.Factory(
context = context,
networkController = RegistrationDependencies.get().networkController,
setupEvents = DeviceTransferSetupViewModel.transferStatusFlow(),
parentState = registrationViewModel.state,
parentEventEmitter = parentEventEmitter
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
DeviceTransferSetupScreen(
state = state,
onEvent = viewModel::onEvent
)
}
// -- Device Transfer: Progress (receiving + importing)
entry<RegistrationRoute.DeviceTransferProgress> {
val context = LocalContext.current.applicationContext
val viewModel: DeviceTransferProgressViewModel = viewModel(
factory = DeviceTransferProgressViewModel.Factory(
context = context,
progressEvents = DeviceTransferProgressViewModel.restoreStatusFlow(),
parentEventEmitter = parentEventEmitter
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
val showCancelDialog by viewModel.showCancelDialog.collectAsState()
DeviceTransferProgressScreen(
state = state,
showCancelDialog = showCancelDialog,
onEvent = viewModel::onEvent
)
}
// -- Device Transfer: Complete
entry<RegistrationRoute.DeviceTransferComplete> {
val viewModel: DeviceTransferCompleteViewModel = viewModel(
factory = DeviceTransferCompleteViewModel.Factory(parentEventEmitter)
)
val state by viewModel.state.collectAsStateWithLifecycle()
DeviceTransferCompleteScreen(
state = state,
onEvent = viewModel::onEvent
)
}
entry<RegistrationRoute.Profile> {
// TODO: Implement ProfileScreen
}
@@ -174,10 +174,10 @@ class RegistrationRepository(val context: Context, val networkController: Networ
}
/**
* See [NetworkController.enqueueSvrGuessResetJob]
* See [NetworkController.enqueueSvrGuessResetJobIfPossible]
*/
suspend fun enqueueSvrResetGuessCountJob() {
networkController.enqueueSvrGuessResetJob()
check(networkController.enqueueSvrGuessResetJobIfPossible()) { "Failed to enqueue SVR guess! Should not happen in this flow." }
}
/**
@@ -247,6 +247,17 @@ class RegistrationRepository(val context: Context, val networkController: Networ
return networkController.startProvisioning()
}
/**
* Reports the user's chosen restore method to the server so the old (quick-restore) device's UI can update.
* See [NetworkController.setRestoreMethod].
*/
suspend fun setRestoreMethod(
token: String,
method: NetworkController.RestoreMethod
): RequestResult<Unit, NetworkController.SetRestoreMethodError> = withContext(Dispatchers.IO) {
networkController.setRestoreMethod(token, method)
}
/**
* Registers an account using data received from the old device via QR provisioning.
*
@@ -549,7 +560,7 @@ class RegistrationRepository(val context: Context, val networkController: Networ
suspend fun commitFinalRegistrationData(): Unit = withContext(Dispatchers.IO) {
storageController.commitRegistrationData()
networkController.enqueueAccountAttributesSyncJob()
networkController.enqueueSvrGuessResetJob()
networkController.enqueueSvrGuessResetJobIfPossible()
}
private fun generateKeyMaterial(
@@ -83,6 +83,7 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
}
is RegistrationFlowEvent.RecoveryPasswordInvalid -> state.copy(doNotAttemptRecoveryPassword = true)
is RegistrationFlowEvent.PendingRestoreOptionSelected -> state.copy(pendingRestoreOption = event.option)
is RegistrationFlowEvent.RestoreMethodTokenReceived -> state.copy(restoreMethodToken = event.token)
is RegistrationFlowEvent.UserSuppliedAepSubmitted -> state.copy(unverifiedRestoredAep = event.aep)
is RegistrationFlowEvent.UserSuppliedAepVerified -> {
repository.saveVerifiedUserSuppliedAep(event.aep)
@@ -187,6 +188,7 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
is RegistrationFlowEvent.E164Chosen,
is RegistrationFlowEvent.RecoveryPasswordInvalid,
is RegistrationFlowEvent.PendingRestoreOptionSelected,
is RegistrationFlowEvent.RestoreMethodTokenReceived,
is RegistrationFlowEvent.UserSuppliedAepSubmitted,
is RegistrationFlowEvent.UserSuppliedAepVerified -> repository.saveFlowState(_state.value)
is RegistrationFlowEvent.RegistrationComplete -> repository.clearFlowState()
@@ -0,0 +1,107 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.complete
import androidx.activity.compose.BackHandler
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.foundation.layout.widthIn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
@Composable
fun DeviceTransferCompleteScreen(
state: DeviceTransferCompleteState,
onEvent: (DeviceTransferCompleteScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
BackHandler(enabled = true) { /* no-op: the transfer is done, don't let the user back out */ }
RegistrationScreen(
modifier = modifier.fillMaxSize(),
content = {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(64.dp))
Icon(
painter = painterResource(R.drawable.symbol_transfer_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.DeviceTransferComplete__transfer_complete),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.DeviceTransferComplete__your_account_is_now_on_this_device),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
},
footer = {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Buttons.LargeTonal(
onClick = { onEvent(DeviceTransferCompleteScreenEvents.ContinueClicked) },
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 320.dp)
) {
Text(stringResource(R.string.DeviceTransferComplete__continue_registration))
}
}
}
)
}
@AllDevicePreviews
@Composable
private fun DeviceTransferCompleteScreenPreview() {
Previews.Preview {
DeviceTransferCompleteScreen(
state = DeviceTransferCompleteState(),
onEvent = {}
)
}
}
@@ -0,0 +1,13 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.complete
import org.signal.registration.util.DebugLoggableModel
sealed class DeviceTransferCompleteScreenEvents : DebugLoggableModel() {
data object ContinueClicked : DeviceTransferCompleteScreenEvents()
data object ConsumeOneTimeEvent : DeviceTransferCompleteScreenEvents()
}
@@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.complete
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.DebugLoggableModel
data class DeviceTransferCompleteState(
val oneTimeEvent: OneTimeEvent? = null
) : DebugLoggableModel() {
sealed interface OneTimeEvent : DebugLoggable
}
@@ -0,0 +1,57 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.complete
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.screens.EventDrivenViewModel
class DeviceTransferCompleteViewModel(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<DeviceTransferCompleteScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(DeviceTransferCompleteViewModel::class)
}
private val _state = MutableStateFlow(DeviceTransferCompleteState())
val state: StateFlow<DeviceTransferCompleteState> = _state
override suspend fun processEvent(event: DeviceTransferCompleteScreenEvents) {
applyEvent(state.value, event, parentEventEmitter) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(
state: DeviceTransferCompleteState,
event: DeviceTransferCompleteScreenEvents,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (DeviceTransferCompleteState) -> Unit
) {
when (event) {
DeviceTransferCompleteScreenEvents.ContinueClicked -> {
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
DeviceTransferCompleteScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
}
}
}
class Factory(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return DeviceTransferCompleteViewModel(parentEventEmitter) as T
}
}
}
@@ -0,0 +1,104 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.instructions
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.foundation.layout.widthIn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
@Composable
fun DeviceTransferInstructionsScreen(
state: DeviceTransferInstructionsState,
onEvent: (DeviceTransferInstructionsScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
modifier = modifier.fillMaxSize(),
content = {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
Icon(
painter = painterResource(R.drawable.symbol_transfer_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.DeviceTransferInstructions__transfer_your_account),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.DeviceTransferInstructions__to_transfer_open_signal_on_your_old_android_device),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
},
footer = {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Buttons.LargeTonal(
onClick = { onEvent(DeviceTransferInstructionsScreenEvents.ContinueClicked) },
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 320.dp)
) {
Text(stringResource(R.string.DeviceTransferInstructions__continue))
}
}
}
)
}
@AllDevicePreviews
@Composable
private fun DeviceTransferInstructionsScreenPreview() {
Previews.Preview {
DeviceTransferInstructionsScreen(
state = DeviceTransferInstructionsState(),
onEvent = {}
)
}
}
@@ -0,0 +1,14 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.instructions
import org.signal.registration.util.DebugLoggableModel
sealed class DeviceTransferInstructionsScreenEvents : DebugLoggableModel() {
data object ContinueClicked : DeviceTransferInstructionsScreenEvents()
data object BackClicked : DeviceTransferInstructionsScreenEvents()
data object ConsumeOneTimeEvent : DeviceTransferInstructionsScreenEvents()
}
@@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.instructions
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.DebugLoggableModel
data class DeviceTransferInstructionsState(
val oneTimeEvent: OneTimeEvent? = null
) : DebugLoggableModel() {
sealed interface OneTimeEvent : DebugLoggable
}
@@ -0,0 +1,63 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.instructions
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.EventDrivenViewModel
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
class DeviceTransferInstructionsViewModel(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<DeviceTransferInstructionsScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(DeviceTransferInstructionsViewModel::class)
}
private val _state = MutableStateFlow(DeviceTransferInstructionsState())
val state: StateFlow<DeviceTransferInstructionsState> = _state
override suspend fun processEvent(event: DeviceTransferInstructionsScreenEvents) {
applyEvent(state.value, event, parentEventEmitter) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(
state: DeviceTransferInstructionsState,
event: DeviceTransferInstructionsScreenEvents,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (DeviceTransferInstructionsState) -> Unit
) {
when (event) {
DeviceTransferInstructionsScreenEvents.ContinueClicked -> {
parentEventEmitter.navigateTo(RegistrationRoute.DeviceTransferSetup)
}
DeviceTransferInstructionsScreenEvents.BackClicked -> {
parentEventEmitter.navigateBack()
}
DeviceTransferInstructionsScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
}
}
}
class Factory(
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return DeviceTransferInstructionsViewModel(parentEventEmitter) as T
}
}
}
@@ -0,0 +1,171 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.progress
import androidx.activity.compose.BackHandler
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.foundation.layout.widthIn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
@Composable
fun DeviceTransferProgressScreen(
state: DeviceTransferProgressState,
showCancelDialog: Boolean,
onEvent: (DeviceTransferProgressScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
BackHandler(enabled = state.status != DeviceTransferProgressState.Status.FAILED) {
onEvent(DeviceTransferProgressScreenEvents.CancelClicked)
}
if (showCancelDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.DeviceTransferProgress__stop_transfer),
body = stringResource(R.string.DeviceTransferProgress__all_transfer_progress_will_be_lost),
confirm = stringResource(R.string.DeviceTransferProgress__stop_transfer),
dismiss = stringResource(android.R.string.cancel),
onConfirm = { onEvent(DeviceTransferProgressScreenEvents.CancelConfirmed) },
onDismiss = { onEvent(DeviceTransferProgressScreenEvents.CancelDismissed) },
confirmColor = MaterialTheme.colorScheme.error
)
}
RegistrationScreen(
modifier = modifier.fillMaxSize(),
content = {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(64.dp))
when (state.status) {
DeviceTransferProgressState.Status.RECEIVING,
DeviceTransferProgressState.Status.IMPORTING,
DeviceTransferProgressState.Status.FINALIZING -> {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.DeviceTransferProgress__transferring_data),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.DeviceTransferProgress__d_messages_so_far, state.messageCount.toInt()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
DeviceTransferProgressState.Status.FAILED -> {
Text(
text = stringResource(R.string.DeviceTransferProgress__unable_to_transfer),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
val messageRes = when (state.errorReason) {
DeviceTransferProgressState.ErrorReason.VERSION_DOWNGRADE -> R.string.DeviceTransferProgress__cannot_transfer_from_newer_signal
DeviceTransferProgressState.ErrorReason.FOREIGN_KEY -> R.string.DeviceTransferProgress__failure_foreign_key
DeviceTransferProgressState.ErrorReason.UNKNOWN, null -> R.string.DeviceTransferProgress__transfer_failed
}
Text(
text = stringResource(messageRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
},
footer = {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.widthIn(max = 320.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (state.status == DeviceTransferProgressState.Status.FAILED) {
Buttons.LargeTonal(
onClick = { onEvent(DeviceTransferProgressScreenEvents.TryAgainClicked) },
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.DeviceTransferProgress__try_again))
}
Spacer(modifier = Modifier.height(12.dp))
}
Buttons.LargeTonal(
onClick = { onEvent(DeviceTransferProgressScreenEvents.CancelClicked) },
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.DeviceTransferProgress__cancel))
}
}
}
}
)
}
@AllDevicePreviews
@Composable
private fun DeviceTransferProgressScreenPreview() {
Previews.Preview {
DeviceTransferProgressScreen(
state = DeviceTransferProgressState(messageCount = 1234),
showCancelDialog = false,
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun DeviceTransferProgressScreenFailedPreview() {
Previews.Preview {
DeviceTransferProgressScreen(
state = DeviceTransferProgressState(status = DeviceTransferProgressState.Status.FAILED, errorReason = DeviceTransferProgressState.ErrorReason.UNKNOWN),
showCancelDialog = false,
onEvent = {}
)
}
}
@@ -0,0 +1,16 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.progress
import org.signal.registration.util.DebugLoggableModel
sealed class DeviceTransferProgressScreenEvents : DebugLoggableModel() {
data object CancelClicked : DeviceTransferProgressScreenEvents()
data object CancelConfirmed : DeviceTransferProgressScreenEvents()
data object CancelDismissed : DeviceTransferProgressScreenEvents()
data object TryAgainClicked : DeviceTransferProgressScreenEvents()
data object ConsumeOneTimeEvent : DeviceTransferProgressScreenEvents()
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.progress
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.DebugLoggableModel
data class DeviceTransferProgressState(
val messageCount: Long = 0,
val status: Status = Status.RECEIVING,
val errorReason: ErrorReason? = null,
val oneTimeEvent: OneTimeEvent? = null
) : DebugLoggableModel() {
enum class Status {
RECEIVING,
IMPORTING,
FINALIZING,
FAILED
}
enum class ErrorReason {
VERSION_DOWNGRADE,
FOREIGN_KEY,
UNKNOWN
}
sealed interface OneTimeEvent : DebugLoggable {
data object TransferCanceled : OneTimeEvent
}
}
@@ -0,0 +1,144 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.progress
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.util.logging.Log
import org.signal.devicetransfer.DeviceToDeviceTransferService
import org.signal.devicetransfer.NewDeviceRestoreStatus
import org.signal.devicetransfer.TransferStatus
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.EventDrivenViewModel
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
class DeviceTransferProgressViewModel(
private val context: Context,
private val progressEvents: Flow<NewDeviceRestoreStatus>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<DeviceTransferProgressScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(DeviceTransferProgressViewModel::class)
/** Cold Flow over [NewDeviceRestoreStatus] EventBus events. */
fun restoreStatusFlow(): Flow<NewDeviceRestoreStatus> = callbackFlow {
val subscriber = object {
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEventMainThread(event: NewDeviceRestoreStatus) {
trySend(event)
}
}
EventBus.getDefault().register(subscriber)
awaitClose { EventBus.getDefault().unregister(subscriber) }
}.flowOn(Dispatchers.Main.immediate)
}
private val _state = MutableStateFlow(DeviceTransferProgressState())
val state: StateFlow<DeviceTransferProgressState> = _state
private val _showCancelDialog = MutableStateFlow(false)
val showCancelDialog: StateFlow<Boolean> = _showCancelDialog
init {
viewModelScope.launch {
progressEvents.collect { handleProgressEvent(it) }
}
}
override suspend fun processEvent(event: DeviceTransferProgressScreenEvents) {
applyEvent(state.value, event, parentEventEmitter) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(
state: DeviceTransferProgressState,
event: DeviceTransferProgressScreenEvents,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (DeviceTransferProgressState) -> Unit
) {
when (event) {
DeviceTransferProgressScreenEvents.CancelClicked -> {
_showCancelDialog.value = true
}
DeviceTransferProgressScreenEvents.CancelDismissed -> {
_showCancelDialog.value = false
}
DeviceTransferProgressScreenEvents.CancelConfirmed -> {
_showCancelDialog.value = false
stopService()
parentEventEmitter.navigateBack()
}
DeviceTransferProgressScreenEvents.TryAgainClicked -> {
stopService()
parentEventEmitter.navigateTo(RegistrationRoute.DeviceTransferInstructions)
}
DeviceTransferProgressScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
}
}
}
private fun handleProgressEvent(event: NewDeviceRestoreStatus) {
when (event.state) {
NewDeviceRestoreStatus.State.IN_PROGRESS -> {
_state.value = _state.value.copy(messageCount = event.messageCount, status = DeviceTransferProgressState.Status.RECEIVING)
}
NewDeviceRestoreStatus.State.TRANSFER_COMPLETE -> {
_state.value = _state.value.copy(messageCount = event.messageCount, status = DeviceTransferProgressState.Status.IMPORTING)
}
NewDeviceRestoreStatus.State.RESTORE_COMPLETE -> {
_state.value = _state.value.copy(status = DeviceTransferProgressState.Status.FINALIZING)
stopService()
parentEventEmitter.navigateTo(RegistrationRoute.DeviceTransferComplete)
}
NewDeviceRestoreStatus.State.FAILURE_VERSION_DOWNGRADE -> {
_state.value = _state.value.copy(status = DeviceTransferProgressState.Status.FAILED, errorReason = DeviceTransferProgressState.ErrorReason.VERSION_DOWNGRADE)
stopService()
}
NewDeviceRestoreStatus.State.FAILURE_FOREIGN_KEY -> {
_state.value = _state.value.copy(status = DeviceTransferProgressState.Status.FAILED, errorReason = DeviceTransferProgressState.ErrorReason.FOREIGN_KEY)
stopService()
}
NewDeviceRestoreStatus.State.FAILURE_UNKNOWN -> {
_state.value = _state.value.copy(status = DeviceTransferProgressState.Status.FAILED, errorReason = DeviceTransferProgressState.ErrorReason.UNKNOWN)
stopService()
}
}
}
private fun stopService() {
DeviceToDeviceTransferService.stop(context)
EventBus.getDefault().removeStickyEvent(TransferStatus::class.java)
}
class Factory(
private val context: Context,
private val progressEvents: Flow<NewDeviceRestoreStatus>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return DeviceTransferProgressViewModel(context, progressEvents, parentEventEmitter) as T
}
}
}
@@ -0,0 +1,353 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
@file:OptIn(ExperimentalPermissionsApi::class)
package org.signal.registration.screens.devicetransfer.setup
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.BackHandler
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.foundation.layout.widthIn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.rememberPermissionState
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.devicetransfer.WifiDirect
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import java.util.Locale
@Composable
fun DeviceTransferSetupScreen(
state: DeviceTransferSetupState,
onEvent: (DeviceTransferSetupScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
val permissionState = rememberPermissionState(WifiDirect.requiredPermission()) { granted ->
onEvent(if (granted) DeviceTransferSetupScreenEvents.PermissionsGranted else DeviceTransferSetupScreenEvents.PermissionsDenied)
}
DeviceTransferSetupScreen(
state = state,
permissionState = permissionState,
onEvent = onEvent,
modifier = modifier
)
}
@Composable
private fun DeviceTransferSetupScreen(
state: DeviceTransferSetupState,
permissionState: PermissionState,
onEvent: (DeviceTransferSetupScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
BackHandler(enabled = true) {
onEvent(DeviceTransferSetupScreenEvents.BackClicked)
}
LaunchedEffect(state.oneTimeEvent) {
val event = state.oneTimeEvent ?: return@LaunchedEffect
onEvent(DeviceTransferSetupScreenEvents.ConsumeOneTimeEvent)
when (event) {
DeviceTransferSetupState.OneTimeEvent.RequestLocationPermission -> {
when (permissionState.status) {
is PermissionStatus.Granted -> onEvent(DeviceTransferSetupScreenEvents.PermissionsGranted)
is PermissionStatus.Denied -> permissionState.launchPermissionRequest()
}
}
DeviceTransferSetupState.OneTimeEvent.OpenLocationSettings -> {
runCatching { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }
.onFailure { runCatching { context.startActivity(Intent(Settings.ACTION_SETTINGS)) } }
}
DeviceTransferSetupState.OneTimeEvent.OpenWifiSettings -> {
runCatching { context.startActivity(Intent(Settings.ACTION_WIFI_SETTINGS)) }
.onFailure { runCatching { context.startActivity(Intent(Settings.ACTION_SETTINGS)) } }
}
DeviceTransferSetupState.OneTimeEvent.OpenAppSettings -> {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
// nothing we can do
}
}
DeviceTransferSetupState.OneTimeEvent.NavigateToProgress,
DeviceTransferSetupState.OneTimeEvent.NavigateAway -> {
// Navigation is handled by the ViewModel via parentEventEmitter.
}
}
}
if (state.showVerifyRejectDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.DeviceTransferSetup__the_numbers_do_not_match),
body = stringResource(R.string.DeviceTransferSetup__if_numbers_dont_match_wrong_device),
confirm = stringResource(R.string.DeviceTransferSetup__stop_transfer),
dismiss = stringResource(android.R.string.cancel),
onConfirm = { onEvent(DeviceTransferSetupScreenEvents.VerifyRejectConfirmed) },
onDismiss = { onEvent(DeviceTransferSetupScreenEvents.VerifyRejectDismissed) },
confirmColor = MaterialTheme.colorScheme.error
)
}
RegistrationScreen(
modifier = modifier.fillMaxSize(),
content = {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (state.step) {
SetupStep.INITIAL,
SetupStep.PERMISSIONS_CHECK,
SetupStep.LOCATION_CHECK,
SetupStep.WIFI_CHECK,
SetupStep.WIFI_DIRECT_CHECK,
SetupStep.START,
SetupStep.CONNECTED -> ProgressStep(statusText = null)
SetupStep.SETTING_UP -> ProgressStep(
statusText = if (state.takingTooLong) {
stringResource(R.string.DeviceTransferSetup__take_a_moment_should_be_ready_soon)
} else {
stringResource(R.string.DeviceTransferSetup__preparing_to_connect)
}
)
SetupStep.WAITING -> ProgressStep(
statusText = stringResource(R.string.DeviceTransferSetup__waiting_for_old_device)
)
SetupStep.VERIFY -> VerifyStep(
authenticationCode = state.authenticationCode ?: 0,
onVerified = { onEvent(DeviceTransferSetupScreenEvents.UserVerifiedCode) },
onRejected = { onEvent(DeviceTransferSetupScreenEvents.UserRejectedCode) }
)
SetupStep.WAITING_FOR_OTHER_TO_VERIFY -> ProgressStep(
statusText = stringResource(R.string.DeviceTransferSetup__waiting_for_other_to_verify)
)
SetupStep.PERMISSIONS_DENIED -> ErrorStep(
message = stringResource(R.string.DeviceTransferSetup__location_permission_required),
buttonText = stringResource(R.string.DeviceTransferSetup__grant_location_permission),
onButtonClick = { onEvent(DeviceTransferSetupScreenEvents.RequestPermissionClicked) }
)
SetupStep.LOCATION_DISABLED -> ErrorStep(
message = stringResource(R.string.DeviceTransferSetup__location_services_required),
buttonText = stringResource(R.string.DeviceTransferSetup__turn_on_location_services),
onButtonClick = { onEvent(DeviceTransferSetupScreenEvents.OpenLocationSettingsClicked) }
)
SetupStep.WIFI_DISABLED -> ErrorStep(
message = stringResource(R.string.DeviceTransferSetup__wifi_required),
buttonText = stringResource(R.string.DeviceTransferSetup__turn_on_wifi),
onButtonClick = { onEvent(DeviceTransferSetupScreenEvents.OpenWifiSettingsClicked) }
)
SetupStep.WIFI_DIRECT_UNAVAILABLE -> ErrorStep(
message = stringResource(R.string.DeviceTransferSetup__wifi_direct_unavailable),
buttonText = stringResource(R.string.DeviceTransferSetup__restore_a_backup),
onButtonClick = { onEvent(DeviceTransferSetupScreenEvents.BackClicked) }
)
SetupStep.TROUBLESHOOTING -> TroubleshootingStep(onTryAgain = { onEvent(DeviceTransferSetupScreenEvents.RetryClicked) })
SetupStep.ERROR -> ErrorStep(
message = stringResource(R.string.DeviceTransferSetup__unexpected_error_connecting),
buttonText = stringResource(R.string.DeviceTransferSetup__retry),
onButtonClick = { onEvent(DeviceTransferSetupScreenEvents.RetryClicked) }
)
}
}
}
)
}
@Composable
private fun ProgressStep(statusText: String?) {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
Spacer(modifier = Modifier.height(24.dp))
if (statusText != null) {
Text(
text = statusText,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun VerifyStep(
authenticationCode: Int,
onVerified: () -> Unit,
onRejected: () -> Unit
) {
Text(
text = stringResource(R.string.DeviceTransferSetup__verify_numbers_match),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = String.format(Locale.US, "%07d", authenticationCode),
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 40.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(40.dp))
Buttons.LargeTonal(
onClick = onVerified,
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 320.dp)
) {
Text(stringResource(R.string.DeviceTransferSetup__numbers_match))
}
Spacer(modifier = Modifier.height(12.dp))
Buttons.LargeTonal(
onClick = onRejected,
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 320.dp)
) {
Text(stringResource(R.string.DeviceTransferSetup__numbers_do_not_match))
}
}
@Composable
private fun ErrorStep(
message: String,
buttonText: String,
onButtonClick: () -> Unit
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Buttons.LargeTonal(
onClick = onButtonClick,
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 320.dp)
) {
Text(buttonText)
}
}
@Composable
private fun TroubleshootingStep(onTryAgain: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.DeviceTransferSetup__unable_to_discover_old_device),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.DeviceTransferSetup__troubleshooting_tips),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Buttons.LargeTonal(
onClick = onTryAgain,
modifier = Modifier.widthIn(max = 320.dp)
) {
Text(stringResource(R.string.DeviceTransferSetup__try_again))
}
}
}
}
@AllDevicePreviews
@Composable
private fun DeviceTransferSetupScreenVerifyPreview() {
Previews.Preview {
RegistrationScreen(
content = {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
VerifyStep(authenticationCode = 1234567, onVerified = {}, onRejected = {})
}
}
)
}
}
@AllDevicePreviews
@Composable
private fun DeviceTransferSetupScreenProgressPreview() {
Previews.Preview {
RegistrationScreen(
content = {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
ProgressStep(statusText = "Preparing to connect")
}
}
)
}
}
@@ -0,0 +1,44 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.setup
import org.signal.registration.util.DebugLoggableModel
sealed class DeviceTransferSetupScreenEvents : DebugLoggableModel() {
/** Kick off the full check-and-start sequence. Emitted once on initial composition and on retries. */
data object CheckPermissions : DeviceTransferSetupScreenEvents()
/** Emitted by the screen after the accompanist permission request resolves. */
data object PermissionsGranted : DeviceTransferSetupScreenEvents()
data object PermissionsDenied : DeviceTransferSetupScreenEvents()
/** Emitted when the user taps "Grant location permission" on the error surface. */
data object RequestPermissionClicked : DeviceTransferSetupScreenEvents()
/** Emitted when the user taps the error-surface recovery button for location/wifi. */
data object OpenLocationSettingsClicked : DeviceTransferSetupScreenEvents()
data object OpenWifiSettingsClicked : DeviceTransferSetupScreenEvents()
data object OpenAppSettingsClicked : DeviceTransferSetupScreenEvents()
/** Re-check the current gate after returning from system settings. */
data object OnResume : DeviceTransferSetupScreenEvents()
/** User confirmed the SAS numbers match. */
data object UserVerifiedCode : DeviceTransferSetupScreenEvents()
/** User tapped "numbers do not match". Shows a confirmation dialog. */
data object UserRejectedCode : DeviceTransferSetupScreenEvents()
data object VerifyRejectConfirmed : DeviceTransferSetupScreenEvents()
data object VerifyRejectDismissed : DeviceTransferSetupScreenEvents()
/** Retry button from the error / troubleshooting screens. */
data object RetryClicked : DeviceTransferSetupScreenEvents()
/** Back / close. Stops the service and pops the nav stack. */
data object BackClicked : DeviceTransferSetupScreenEvents()
data object ConsumeOneTimeEvent : DeviceTransferSetupScreenEvents()
}
@@ -0,0 +1,39 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.setup
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.DebugLoggableModel
data class DeviceTransferSetupState(
val step: SetupStep = SetupStep.INITIAL,
val authenticationCode: Int? = null,
val takingTooLong: Boolean = false,
val showVerifyRejectDialog: Boolean = false,
val showErrorDialog: Boolean = false,
val oneTimeEvent: OneTimeEvent? = null
) : DebugLoggableModel() {
sealed interface OneTimeEvent : DebugLoggable {
/** The screen should launch a runtime permission request. */
data object RequestLocationPermission : OneTimeEvent
/** The screen should launch the system Location settings. */
data object OpenLocationSettings : OneTimeEvent
/** The screen should launch the system Wi-Fi settings. */
data object OpenWifiSettings : OneTimeEvent
/** The screen should launch this app's system settings (for permanent-denial recovery). */
data object OpenAppSettings : OneTimeEvent
/** Both devices verified successfully; navigate to the Progress screen. */
data object NavigateToProgress : OneTimeEvent
/** Unrecoverable setup path (e.g. Wi-Fi Direct unavailable); navigate back. */
data object NavigateAway : OneTimeEvent
}
}
@@ -0,0 +1,339 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.setup
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import android.net.wifi.WifiManager
import android.os.Build
import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.models.AccountEntropyPool
import org.signal.core.util.logging.Log
import org.signal.devicetransfer.DeviceToDeviceTransferService
import org.signal.devicetransfer.TransferStatus
import org.signal.devicetransfer.WifiDirect
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.EventDrivenViewModel
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
import kotlin.time.Duration.Companion.seconds
class DeviceTransferSetupViewModel(
private val context: Context,
private val networkController: NetworkController,
private val setupEvents: Flow<TransferStatus>,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<DeviceTransferSetupScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(DeviceTransferSetupViewModel::class)
private val PREPARE_TAKING_TOO_LONG = 30.seconds
private val WAITING_TAKING_TOO_LONG = 90.seconds
/** Cold Flow over sticky [TransferStatus] EventBus events. */
fun transferStatusFlow(): Flow<TransferStatus> = callbackFlow {
val subscriber = object {
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
fun onEventMainThread(event: TransferStatus) {
trySend(event)
}
}
EventBus.getDefault().register(subscriber)
awaitClose { EventBus.getDefault().unregister(subscriber) }
}.flowOn(Dispatchers.Main.immediate)
}
private val _state = MutableStateFlow(DeviceTransferSetupState())
val state: StateFlow<DeviceTransferSetupState> = _state
private var takingTooLongJob: Job? = null
private var setupEventsJob: Job? = null
private var shutdown: Boolean = false
init {
subscribeToSetupEvents()
onEvent(DeviceTransferSetupScreenEvents.CheckPermissions)
}
override suspend fun processEvent(event: DeviceTransferSetupScreenEvents) {
applyEvent(state.value, event, parentEventEmitter) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(
state: DeviceTransferSetupState,
event: DeviceTransferSetupScreenEvents,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (DeviceTransferSetupState) -> Unit
) {
when (event) {
DeviceTransferSetupScreenEvents.CheckPermissions -> {
shutdown = false
cancelTakingTooLong()
if (isLocationPermissionGranted()) {
stateEmitter(state.copy(step = SetupStep.LOCATION_CHECK, takingTooLong = false))
checkLocation(parentEventEmitter, stateEmitter)
} else {
stateEmitter(state.copy(step = SetupStep.PERMISSIONS_CHECK, takingTooLong = false, oneTimeEvent = DeviceTransferSetupState.OneTimeEvent.RequestLocationPermission))
}
}
DeviceTransferSetupScreenEvents.PermissionsGranted -> {
stateEmitter(state.copy(step = SetupStep.LOCATION_CHECK))
checkLocation(parentEventEmitter, stateEmitter)
}
DeviceTransferSetupScreenEvents.PermissionsDenied -> {
stateEmitter(state.copy(step = SetupStep.PERMISSIONS_DENIED))
}
DeviceTransferSetupScreenEvents.RequestPermissionClicked -> {
stateEmitter(state.copy(oneTimeEvent = DeviceTransferSetupState.OneTimeEvent.RequestLocationPermission))
}
DeviceTransferSetupScreenEvents.OpenLocationSettingsClicked -> {
stateEmitter(state.copy(oneTimeEvent = DeviceTransferSetupState.OneTimeEvent.OpenLocationSettings))
}
DeviceTransferSetupScreenEvents.OpenWifiSettingsClicked -> {
stateEmitter(state.copy(oneTimeEvent = DeviceTransferSetupState.OneTimeEvent.OpenWifiSettings))
}
DeviceTransferSetupScreenEvents.OpenAppSettingsClicked -> {
stateEmitter(state.copy(oneTimeEvent = DeviceTransferSetupState.OneTimeEvent.OpenAppSettings))
}
DeviceTransferSetupScreenEvents.OnResume -> {
when (state.step) {
SetupStep.WIFI_DISABLED -> {
stateEmitter(state.copy(step = SetupStep.WIFI_CHECK))
checkWifi(parentEventEmitter, stateEmitter)
}
SetupStep.LOCATION_DISABLED -> {
stateEmitter(state.copy(step = SetupStep.LOCATION_CHECK))
checkLocation(parentEventEmitter, stateEmitter)
}
else -> Unit
}
}
DeviceTransferSetupScreenEvents.UserVerifiedCode -> {
DeviceToDeviceTransferService.setAuthenticationCodeVerified(context, true)
stateEmitter(state.copy(step = SetupStep.WAITING_FOR_OTHER_TO_VERIFY))
}
DeviceTransferSetupScreenEvents.UserRejectedCode -> {
stateEmitter(state.copy(showVerifyRejectDialog = true))
}
DeviceTransferSetupScreenEvents.VerifyRejectConfirmed -> {
DeviceToDeviceTransferService.setAuthenticationCodeVerified(context, false)
stopService()
stateEmitter(state.copy(showVerifyRejectDialog = false))
parentEventEmitter.navigateBack()
}
DeviceTransferSetupScreenEvents.VerifyRejectDismissed -> {
stateEmitter(state.copy(showVerifyRejectDialog = false))
}
DeviceTransferSetupScreenEvents.RetryClicked -> {
shutdown = false
stopService()
subscribeToSetupEvents()
stateEmitter(DeviceTransferSetupState(step = SetupStep.PERMISSIONS_CHECK))
onEvent(DeviceTransferSetupScreenEvents.CheckPermissions)
}
DeviceTransferSetupScreenEvents.BackClicked -> {
stopService()
parentEventEmitter.navigateBack()
}
DeviceTransferSetupScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
}
}
}
private suspend fun checkLocation(
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (DeviceTransferSetupState) -> Unit
) {
val locationRequired = Build.VERSION.SDK_INT < 33
if (!locationRequired || isLocationEnabled()) {
stateEmitter(_state.value.copy(step = SetupStep.WIFI_CHECK))
checkWifi(parentEventEmitter, stateEmitter)
} else {
stateEmitter(_state.value.copy(step = SetupStep.LOCATION_DISABLED))
}
}
private fun checkWifi(
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (DeviceTransferSetupState) -> Unit
) {
if (isWifiEnabled()) {
stateEmitter(_state.value.copy(step = SetupStep.WIFI_DIRECT_CHECK))
checkWifiDirect(parentEventEmitter, stateEmitter)
} else {
stateEmitter(_state.value.copy(step = SetupStep.WIFI_DISABLED))
}
}
private fun checkWifiDirect(
@Suppress("UNUSED_PARAMETER") parentEventEmitter: (RegistrationFlowEvent) -> Unit,
stateEmitter: (DeviceTransferSetupState) -> Unit
) {
when (WifiDirect.getAvailability(context)) {
WifiDirect.AvailableStatus.AVAILABLE -> {
val aep: AccountEntropyPool? = parentState.value.accountEntropyPool
if (aep == null) {
Log.w(TAG, "No AEP in flow state — cannot start transfer server")
stateEmitter(_state.value.copy(step = SetupStep.ERROR, showErrorDialog = true))
return
}
stateEmitter(_state.value.copy(step = SetupStep.START))
networkController.startNewDeviceTransferServer(context, aep)
stateEmitter(_state.value.copy(step = SetupStep.SETTING_UP))
scheduleSettingUpTooLong()
}
WifiDirect.AvailableStatus.REQUIRED_PERMISSION_NOT_GRANTED -> {
stateEmitter(_state.value.copy(step = SetupStep.PERMISSIONS_CHECK))
}
else -> {
stateEmitter(_state.value.copy(step = SetupStep.WIFI_DIRECT_UNAVAILABLE))
}
}
}
private fun handleSetupEvent(event: TransferStatus) {
if (shutdown) return
Log.i(TAG, "Handling setup event: ${event.transferMode}")
when (event.transferMode) {
TransferStatus.TransferMode.READY,
TransferStatus.TransferMode.STARTING_UP -> {
_state.value = _state.value.copy(step = SetupStep.SETTING_UP)
scheduleSettingUpTooLong()
}
TransferStatus.TransferMode.DISCOVERY -> {
_state.value = _state.value.copy(step = SetupStep.WAITING, takingTooLong = false)
scheduleWaitingTooLong()
}
TransferStatus.TransferMode.VERIFICATION_REQUIRED -> {
cancelTakingTooLong()
_state.value = _state.value.copy(step = SetupStep.VERIFY, authenticationCode = event.authenticationCode, takingTooLong = false)
}
TransferStatus.TransferMode.SERVICE_CONNECTED -> {
cancelTakingTooLong()
_state.value = _state.value.copy(step = SetupStep.CONNECTED)
parentEventEmitter.navigateTo(RegistrationRoute.DeviceTransferProgress)
}
TransferStatus.TransferMode.SHUTDOWN,
TransferStatus.TransferMode.FAILED -> {
cancelTakingTooLong()
_state.value = _state.value.copy(step = SetupStep.ERROR, showErrorDialog = true)
}
TransferStatus.TransferMode.UNAVAILABLE,
TransferStatus.TransferMode.NETWORK_CONNECTED,
TransferStatus.TransferMode.SERVICE_DISCONNECTED -> Unit
}
}
private fun subscribeToSetupEvents() {
setupEventsJob?.cancel()
setupEventsJob = viewModelScope.launch {
setupEvents.collect { handleSetupEvent(it) }
}
}
private fun scheduleSettingUpTooLong() {
cancelTakingTooLong()
takingTooLongJob = viewModelScope.launch {
delay(PREPARE_TAKING_TOO_LONG)
if (_state.value.step == SetupStep.SETTING_UP) {
_state.value = _state.value.copy(takingTooLong = true)
}
}
}
private fun scheduleWaitingTooLong() {
cancelTakingTooLong()
takingTooLongJob = viewModelScope.launch {
delay(WAITING_TAKING_TOO_LONG)
if (_state.value.step == SetupStep.WAITING) {
shutdown = true
stopService()
_state.value = _state.value.copy(step = SetupStep.TROUBLESHOOTING)
}
}
}
private fun cancelTakingTooLong() {
takingTooLongJob?.cancel()
takingTooLongJob = null
}
private fun stopService() {
DeviceToDeviceTransferService.stop(context)
EventBus.getDefault().removeStickyEvent(TransferStatus::class.java)
}
private fun isLocationPermissionGranted(): Boolean {
return ContextCompat.checkSelfPermission(context, WifiDirect.requiredPermission()) == PackageManager.PERMISSION_GRANTED
}
private fun isLocationEnabled(): Boolean {
val locationManager = ContextCompat.getSystemService(context, LocationManager::class.java) ?: return false
return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
}
private fun isWifiEnabled(): Boolean {
val wifiManager = ContextCompat.getSystemService(context, WifiManager::class.java) ?: return false
return wifiManager.isWifiEnabled
}
override fun onCleared() {
super.onCleared()
cancelTakingTooLong()
setupEventsJob?.cancel()
}
class Factory(
private val context: Context,
private val networkController: NetworkController,
private val setupEvents: Flow<TransferStatus>,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return DeviceTransferSetupViewModel(context, networkController, setupEvents, parentState, parentEventEmitter) as T
}
}
}
@@ -0,0 +1,31 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.setup
/**
* Mirrors `org.thoughtcrime.securesms.devicetransfer.SetupStep` for the new-device side of the
* Wi-Fi Direct pairing state machine. `isProgress` screens show a spinner; `isError` screens
* show the red error surface with a recovery action.
*/
enum class SetupStep(val isProgress: Boolean, val isError: Boolean) {
INITIAL(true, false),
PERMISSIONS_CHECK(true, false),
PERMISSIONS_DENIED(false, true),
LOCATION_CHECK(true, false),
LOCATION_DISABLED(false, true),
WIFI_CHECK(true, false),
WIFI_DISABLED(false, true),
WIFI_DIRECT_CHECK(true, false),
WIFI_DIRECT_UNAVAILABLE(false, true),
START(true, false),
SETTING_UP(true, false),
WAITING(true, false),
VERIFY(false, false),
WAITING_FOR_OTHER_TO_VERIFY(false, false),
CONNECTED(true, false),
TROUBLESHOOTING(false, false),
ERROR(false, true)
}
@@ -96,6 +96,8 @@ class QuickRestoreQrViewModel(
}
private suspend fun handleProvisioningMessage(message: NetworkController.ProvisioningMessage) {
parentEventEmitter(RegistrationFlowEvent.RestoreMethodTokenReceived(message.restoreMethodToken))
if (message.platform == NetworkController.ProvisioningMessage.Platform.IOS && message.tier == null) {
// iOS without a backup tier cannot do a quick restore — navigate to the choose-restore screen
parentEventEmitter.navigateTo(RegistrationRoute.ArchiveRestoreSelection.forManualRestore())
@@ -9,7 +9,9 @@ import org.signal.registration.util.DebugLoggableModel
data class ArchiveRestoreSelectionState(
val restoreOptions: List<ArchiveRestoreOption> = emptyList(),
val showSkipWarningDialog: Boolean = false
val showSkipWarningDialog: Boolean = false,
/** Token that, if present, indicates that the user did a quick restore, and we should hit a network endpoint to indicate our restore selection. */
val restoreMethodToken: String? = null
) : DebugLoggableModel() {
val showSkipButton: Boolean get() = ArchiveRestoreOption.None !in restoreOptions
}
@@ -8,11 +8,20 @@ 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.stateIn
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.registration.NetworkController
import org.signal.registration.PendingRestoreOption
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.EventDrivenViewModel
import org.signal.registration.screens.util.navigateTo
@@ -25,6 +34,8 @@ import org.signal.registration.screens.util.navigateTo
class ArchiveRestoreSelectionViewModel(
private val restoreOptions: List<ArchiveRestoreOption>,
private val isPreRegistration: Boolean,
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<ArchiveRestoreSelectionScreenEvents>(TAG) {
@@ -39,17 +50,29 @@ class ArchiveRestoreSelectionViewModel(
)
val state: StateFlow<ArchiveRestoreSelectionState> = _localState
.combine(parentState) { state, parentState -> applyParentState(state, parentState) }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
ArchiveRestoreSelectionState(restoreOptions = restoreOptions)
)
override suspend fun processEvent(event: ArchiveRestoreSelectionScreenEvents) {
applyEvent(state.value, event) { _localState.value = it }
}
@VisibleForTesting
fun applyParentState(state: ArchiveRestoreSelectionState, parentState: RegistrationFlowState): ArchiveRestoreSelectionState {
return state.copy(restoreMethodToken = parentState.restoreMethodToken)
}
@VisibleForTesting
suspend fun applyEvent(state: ArchiveRestoreSelectionState, event: ArchiveRestoreSelectionScreenEvents, stateEmitter: (ArchiveRestoreSelectionState) -> Unit) {
val result = when (event) {
is ArchiveRestoreSelectionScreenEvents.RestoreOptionSelected -> {
when (event.option) {
ArchiveRestoreOption.SignalSecureBackup -> {
notifyOldDevice(state.restoreMethodToken, NetworkController.RestoreMethod.REMOTE_BACKUP)
if (isPreRegistration) {
parentEventEmitter(RegistrationFlowEvent.PendingRestoreOptionSelected(PendingRestoreOption.RemoteBackup))
parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry)
@@ -59,6 +82,7 @@ class ArchiveRestoreSelectionViewModel(
state
}
ArchiveRestoreOption.LocalBackup -> {
notifyOldDevice(state.restoreMethodToken, NetworkController.RestoreMethod.LOCAL_BACKUP)
if (isPreRegistration) {
parentEventEmitter(RegistrationFlowEvent.PendingRestoreOptionSelected(PendingRestoreOption.LocalBackup))
parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry)
@@ -68,7 +92,8 @@ class ArchiveRestoreSelectionViewModel(
state
}
ArchiveRestoreOption.DeviceTransfer -> {
Log.w(TAG, "Device transfer not yet implemented")
notifyOldDevice(state.restoreMethodToken, NetworkController.RestoreMethod.DEVICE_TRANSFER)
parentEventEmitter.navigateTo(RegistrationRoute.DeviceTransferInstructions)
state
}
ArchiveRestoreOption.None -> {
@@ -80,6 +105,7 @@ class ArchiveRestoreSelectionViewModel(
state.copy(showSkipWarningDialog = true)
}
is ArchiveRestoreSelectionScreenEvents.ConfirmSkip -> {
notifyOldDevice(state.restoreMethodToken, NetworkController.RestoreMethod.DECLINE)
parentEventEmitter.navigateTo(RegistrationRoute.PinCreate)
state.copy(showSkipWarningDialog = false)
}
@@ -90,13 +116,32 @@ class ArchiveRestoreSelectionViewModel(
stateEmitter(result)
}
/**
* If a quick-restore [token] is set, fire-and-forget a network call to update the old device's UI
* with the user's [method] selection. The old device is long-polling and will pick up the change.
*/
private fun notifyOldDevice(token: String?, method: NetworkController.RestoreMethod) {
if (token == null) return
viewModelScope.launch {
Log.i(TAG, "[notifyOldDevice] Notifying old device of restore method: $method")
val result = repository.setRestoreMethod(token, method)
if (result is RequestResult.Success) {
Log.i(TAG, "[notifyOldDevice] Successfully notified old device.")
} else {
Log.w(TAG, "[notifyOldDevice] Failed to notify old device: $result")
}
}
}
class Factory(
private val restoreOptions: List<ArchiveRestoreOption>,
private val isPreRegistration: Boolean,
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(restoreOptions, isPreRegistration, parentEventEmitter) as T
return ArchiveRestoreSelectionViewModel(restoreOptions, isPreRegistration, repository, parentState, parentEventEmitter) as T
}
}
}
@@ -398,4 +398,51 @@
<string name="LinkAccountScreen__dont_have_signal_on_another_device">Don\'t have Signal on another device?</string>
<!-- Inline link in the link-account screen footer that takes the user to the new-account creation flow instead of linking -->
<string name="LinkAccountScreen__create_account">Create account</string>
<!-- Device transfer: Instructions screen -->
<string name="DeviceTransferInstructions__transfer_your_account">Transfer your account</string>
<string name="DeviceTransferInstructions__to_transfer_open_signal_on_your_old_android_device">To transfer your account, open Signal on your old Android device and select Transfer Account.</string>
<string name="DeviceTransferInstructions__continue">Continue</string>
<!-- Device transfer: Setup screen -->
<string name="DeviceTransferSetup__preparing_to_connect">Preparing to connect to old Android device…</string>
<string name="DeviceTransferSetup__take_a_moment_should_be_ready_soon">This might take a moment. Should be ready soon.</string>
<string name="DeviceTransferSetup__waiting_for_old_device">Waiting for your old device to connect…</string>
<string name="DeviceTransferSetup__verify_numbers_match">Verify the numbers match on both devices</string>
<string name="DeviceTransferSetup__numbers_match">Yes, the numbers match</string>
<string name="DeviceTransferSetup__numbers_do_not_match">The numbers do not match</string>
<string name="DeviceTransferSetup__waiting_for_other_to_verify">Waiting for your old device to verify…</string>
<string name="DeviceTransferSetup__location_permission_required">Signal needs the location permission to discover and connect with your old device.</string>
<string name="DeviceTransferSetup__grant_location_permission">Grant permission</string>
<string name="DeviceTransferSetup__location_services_required">Signal needs location services enabled to discover and connect with your old device.</string>
<string name="DeviceTransferSetup__turn_on_location_services">Turn on location services</string>
<string name="DeviceTransferSetup__wifi_required">Signal needs Wi-Fi turned on to discover and connect with your old device.</string>
<string name="DeviceTransferSetup__turn_on_wifi">Turn on Wi-Fi</string>
<string name="DeviceTransferSetup__wifi_direct_unavailable">Sorry, it appears your device does not support Wi-Fi Direct.</string>
<string name="DeviceTransferSetup__restore_a_backup">Restore a backup instead</string>
<string name="DeviceTransferSetup__unexpected_error_connecting">An unexpected error occurred while attempting to connect to your old device.</string>
<string name="DeviceTransferSetup__retry">Retry</string>
<string name="DeviceTransferSetup__unable_to_discover_old_device">Unable to discover your old device</string>
<string name="DeviceTransferSetup__troubleshooting_tips">• Make sure location permissions are granted.\n• Make sure Wi-Fi is on and no Wi-Fi Direct groups are remembered.\n• Try turning Wi-Fi off and on on both devices.\n• Make sure both devices are in transfer mode.</string>
<string name="DeviceTransferSetup__try_again">Try again</string>
<string name="DeviceTransferSetup__the_numbers_do_not_match">The numbers do not match</string>
<string name="DeviceTransferSetup__if_numbers_dont_match_wrong_device">If the numbers on your devices do not match, it\'s possible you connected to the wrong device.</string>
<string name="DeviceTransferSetup__stop_transfer">Stop transfer</string>
<!-- Device transfer: Progress screen -->
<string name="DeviceTransferProgress__transferring_data">Transferring data</string>
<string name="DeviceTransferProgress__d_messages_so_far">%1$d messages so far</string>
<string name="DeviceTransferProgress__unable_to_transfer">Unable to transfer</string>
<string name="DeviceTransferProgress__cannot_transfer_from_newer_signal">Cannot transfer from a newer version of Signal.</string>
<string name="DeviceTransferProgress__failure_foreign_key">Some data could not be transferred due to a database constraint.</string>
<string name="DeviceTransferProgress__transfer_failed">The transfer failed. Please try again.</string>
<string name="DeviceTransferProgress__try_again">Try again</string>
<string name="DeviceTransferProgress__cancel">Cancel</string>
<string name="DeviceTransferProgress__stop_transfer">Stop transfer?</string>
<string name="DeviceTransferProgress__all_transfer_progress_will_be_lost">All transfer progress will be lost.</string>
<!-- Device transfer: Complete screen -->
<string name="DeviceTransferComplete__transfer_complete">Transfer complete</string>
<string name="DeviceTransferComplete__your_account_is_now_on_this_device">Your account is now on this device.</string>
<string name="DeviceTransferComplete__continue_registration">Continue</string>
</resources>
@@ -0,0 +1,58 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.complete
import assertk.assertThat
import assertk.assertions.containsExactly
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.registration.RegistrationFlowEvent
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceTransferCompleteViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var viewModel: DeviceTransferCompleteViewModel
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var emittedStates: MutableList<DeviceTransferCompleteState>
private lateinit var stateEmitter: (DeviceTransferCompleteState) -> Unit
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
emittedEvents = mutableListOf()
parentEventEmitter = { emittedEvents.add(it) }
emittedStates = mutableListOf()
stateEmitter = { emittedStates.add(it) }
viewModel = DeviceTransferCompleteViewModel(parentEventEmitter)
testDispatcher.scheduler.advanceUntilIdle()
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `ContinueClicked emits RegistrationComplete`() = runTest {
viewModel.applyEvent(
DeviceTransferCompleteState(),
DeviceTransferCompleteScreenEvents.ContinueClicked,
parentEventEmitter,
stateEmitter
)
assertThat(emittedEvents).containsExactly(RegistrationFlowEvent.RegistrationComplete)
}
}
@@ -0,0 +1,71 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.instructions
import assertk.assertThat
import assertk.assertions.containsExactly
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRoute
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceTransferInstructionsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var viewModel: DeviceTransferInstructionsViewModel
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var emittedStates: MutableList<DeviceTransferInstructionsState>
private lateinit var stateEmitter: (DeviceTransferInstructionsState) -> Unit
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
emittedEvents = mutableListOf()
parentEventEmitter = { emittedEvents.add(it) }
emittedStates = mutableListOf()
stateEmitter = { emittedStates.add(it) }
viewModel = DeviceTransferInstructionsViewModel(parentEventEmitter)
testDispatcher.scheduler.advanceUntilIdle()
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `ContinueClicked navigates to Setup`() = runTest {
viewModel.applyEvent(
DeviceTransferInstructionsState(),
DeviceTransferInstructionsScreenEvents.ContinueClicked,
parentEventEmitter,
stateEmitter
)
assertThat(emittedEvents).containsExactly(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.DeviceTransferSetup))
}
@Test
fun `BackClicked navigates back`() = runTest {
viewModel.applyEvent(
DeviceTransferInstructionsState(),
DeviceTransferInstructionsScreenEvents.BackClicked,
parentEventEmitter,
stateEmitter
)
assertThat(emittedEvents).containsExactly(RegistrationFlowEvent.NavigateBack)
}
}
@@ -0,0 +1,82 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.progress
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isEqualTo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.signal.devicetransfer.NewDeviceRestoreStatus
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRoute
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class DeviceTransferProgressViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var context: Context
private lateinit var progressEvents: MutableSharedFlow<NewDeviceRestoreStatus>
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var viewModel: DeviceTransferProgressViewModel
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
context = ApplicationProvider.getApplicationContext()
progressEvents = MutableSharedFlow(extraBufferCapacity = 16)
emittedEvents = mutableListOf()
parentEventEmitter = { emittedEvents.add(it) }
viewModel = DeviceTransferProgressViewModel(context, progressEvents, parentEventEmitter)
testDispatcher.scheduler.advanceUntilIdle()
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `InProgress updates message count`() = runTest {
progressEvents.tryEmit(NewDeviceRestoreStatus(42, NewDeviceRestoreStatus.State.IN_PROGRESS))
testDispatcher.scheduler.advanceUntilIdle()
assertThat(viewModel.state.value.messageCount).isEqualTo(42L)
assertThat(viewModel.state.value.status).isEqualTo(DeviceTransferProgressState.Status.RECEIVING)
}
@Test
fun `RestoreComplete navigates to Complete screen`() = runTest {
progressEvents.tryEmit(NewDeviceRestoreStatus(0, NewDeviceRestoreStatus.State.RESTORE_COMPLETE))
testDispatcher.scheduler.advanceUntilIdle()
assertThat(emittedEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.DeviceTransferComplete))
}
@Test
fun `VersionDowngrade failure sets FAILED with correct reason`() = runTest {
progressEvents.tryEmit(NewDeviceRestoreStatus(0, NewDeviceRestoreStatus.State.FAILURE_VERSION_DOWNGRADE))
testDispatcher.scheduler.advanceUntilIdle()
assertThat(viewModel.state.value.status).isEqualTo(DeviceTransferProgressState.Status.FAILED)
assertThat(viewModel.state.value.errorReason).isEqualTo(DeviceTransferProgressState.ErrorReason.VERSION_DOWNGRADE)
}
}
@@ -0,0 +1,101 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.devicetransfer.setup
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isEqualTo
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.signal.devicetransfer.TransferStatus
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRoute
/**
* Exercises the event-reducer half of the setup VM (TransferStatus → step mapping + parent
* navigation). The OS gates (permissions / location services / wifi / Wi-Fi Direct) are driven
* by concrete Android APIs against `context`; that side is covered by manual QA rather than unit
* tests.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class DeviceTransferSetupViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var context: Context
private lateinit var networkController: NetworkController
private lateinit var setupEvents: MutableSharedFlow<TransferStatus>
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
context = ApplicationProvider.getApplicationContext()
networkController = mockk(relaxed = true)
setupEvents = MutableSharedFlow(extraBufferCapacity = 16)
emittedEvents = mutableListOf()
parentEventEmitter = { emittedEvents.add(it) }
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `VerificationRequired event moves to VERIFY with SAS code`() = runTest {
val viewModel = DeviceTransferSetupViewModel(context, networkController, setupEvents, MutableStateFlow(RegistrationFlowState()), parentEventEmitter)
testDispatcher.scheduler.advanceUntilIdle()
setupEvents.tryEmit(TransferStatus.verificationRequired(1234567))
testDispatcher.scheduler.advanceUntilIdle()
assertThat(viewModel.state.value.step).isEqualTo(SetupStep.VERIFY)
assertThat(viewModel.state.value.authenticationCode).isEqualTo(1234567)
}
@Test
fun `ServiceConnected event navigates to progress screen`() = runTest {
val viewModel = DeviceTransferSetupViewModel(context, networkController, setupEvents, MutableStateFlow(RegistrationFlowState()), parentEventEmitter)
testDispatcher.scheduler.advanceUntilIdle()
setupEvents.tryEmit(TransferStatus.serviceConnected())
testDispatcher.scheduler.advanceUntilIdle()
assertThat(viewModel.state.value.step).isEqualTo(SetupStep.CONNECTED)
assertThat(emittedEvents).contains(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.DeviceTransferProgress))
}
@Test
fun `Failed event moves to ERROR`() = runTest {
val viewModel = DeviceTransferSetupViewModel(context, networkController, setupEvents, MutableStateFlow(RegistrationFlowState()), parentEventEmitter)
testDispatcher.scheduler.advanceUntilIdle()
setupEvents.tryEmit(TransferStatus.failed())
testDispatcher.scheduler.advanceUntilIdle()
assertThat(viewModel.state.value.step).isEqualTo(SetupStep.ERROR)
assertThat(viewModel.state.value.showErrorDialog).isEqualTo(true)
}
}
@@ -12,11 +12,15 @@ import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
import assertk.assertions.isTrue
import assertk.assertions.prop
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.signal.registration.PendingRestoreOption
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
class ArchiveRestoreSelectionViewModelTest {
@@ -45,6 +49,8 @@ class ArchiveRestoreSelectionViewModelTest {
return ArchiveRestoreSelectionViewModel(
restoreOptions = restoreOptions,
isPreRegistration = isPreRegistration,
repository = mockk<RegistrationRepository>(relaxed = true),
parentState = MutableStateFlow(RegistrationFlowState()),
parentEventEmitter = parentEventEmitter
)
}
@@ -132,7 +138,7 @@ class ArchiveRestoreSelectionViewModelTest {
}
@Test
fun `DeviceTransfer is not implemented and emits no events`() = runTest {
fun `DeviceTransfer navigates to DeviceTransferInstructions`() = runTest {
val viewModel = createViewModel(isPreRegistration = false)
val initialState = ArchiveRestoreSelectionState()
@@ -142,7 +148,11 @@ class ArchiveRestoreSelectionViewModelTest {
stateEmitter
)
assertThat(emittedParentEvents).hasSize(0)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isEqualTo(RegistrationRoute.DeviceTransferInstructions)
}
@Test
@@ -0,0 +1,41 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.devicetransfer;
import androidx.annotation.NonNull;
/**
* Progress event posted on the new device as the incoming backup is received and imported.
* Posted by the concrete {@code NewDeviceServerTask} in the app module and observed by whichever
* UI layer is driving the transfer (app or feature/registration).
*/
public final class NewDeviceRestoreStatus {
private final long messageCount;
private final State state;
public NewDeviceRestoreStatus(long messageCount, @NonNull State state) {
this.messageCount = messageCount;
this.state = state;
}
public long getMessageCount() {
return messageCount;
}
public @NonNull State getState() {
return state;
}
public enum State {
IN_PROGRESS,
TRANSFER_COMPLETE,
RESTORE_COMPLETE,
FAILURE_VERSION_DOWNGRADE,
FAILURE_FOREIGN_KEY,
FAILURE_UNKNOWN
}
}