diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java index c7a31e741c..af417863b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java @@ -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 - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.kt index 95a8ee4ff3..5092c24ffe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.kt @@ -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() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt index 947df4ff47..f9da7f938e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt @@ -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 = withContext(Dispatchers.IO) { @@ -595,6 +602,26 @@ class AppRegistrationNetworkController( AppDependencies.jobManager.add(RefreshAttributesJob()) } + override suspend fun setRestoreMethod(token: String, method: NetworkController.RestoreMethod): RequestResult = 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 = callbackFlow { val socketHandles = mutableListOf() val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration() diff --git a/demo/registration/build.gradle.kts b/demo/registration/build.gradle.kts index 36520d5e25..dfc1250bff 100644 --- a/demo/registration/build.gradle.kts +++ b/demo/registration/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { // Registration library implementation(project(":feature:registration")) + implementation(project(":lib:device-transfer")) // Core dependencies implementation(project(":core:ui")) diff --git a/demo/registration/src/main/AndroidManifest.xml b/demo/registration/src/main/AndroidManifest.xml index ff24a08edb..b45a9f10a0 100644 --- a/demo/registration/src/main/AndroidManifest.xml +++ b/demo/registration/src/main/AndroidManifest.xml @@ -6,6 +6,12 @@ + + + + + + { @@ -201,6 +203,14 @@ class DebugNetworkController( delegate.enqueueAccountAttributesSyncJob() } + override suspend fun setRestoreMethod(token: String, method: RestoreMethod): RequestResult { + NetworkDebugState.getOverride>("setRestoreMethod")?.let { + Log.d(TAG, "[setRestoreMethod] Returning debug override") + return it + } + return delegate.setRestoreMethod(token, method) + } + override suspend fun getSvrCredentials(): RequestResult { NetworkDebugState.getOverride>("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 diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugOverlay.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugOverlay.kt index 8db694e366..af67a6d60e 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugOverlay.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugOverlay.kt @@ -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 diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugState.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugState.kt index 8267c01ddc..269d87177d 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugState.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugState.kt @@ -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 = _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. diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoBackupStreamReader.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoBackupStreamReader.kt new file mode 100644 index 0000000000..146af36a96 --- /dev/null +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoBackupStreamReader.kt @@ -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 { + 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") + } + } + } + } +} diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoDeviceTransferServerTask.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoDeviceTransferServerTask.kt new file mode 100644 index 0000000000..801d5987eb --- /dev/null +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoDeviceTransferServerTask.kt @@ -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)) + } +} diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt index 27ce8b70db..d4a6159277 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt @@ -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 = callbackFlow { val socketHandles = mutableListOf() @@ -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 = withContext(Dispatchers.IO) { @@ -862,6 +895,36 @@ class DemoNetworkController( } } + override suspend fun setRestoreMethod(token: String, method: NetworkController.RestoreMethod): RequestResult = 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, diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/FakeDeviceTransferRunner.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/FakeDeviceTransferRunner.kt new file mode 100644 index 0000000000..d23205ca0a --- /dev/null +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/FakeDeviceTransferRunner.kt @@ -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 + } +} diff --git a/feature/registration/build.gradle.kts b/feature/registration/build.gradle.kts index fe7b8dd364..c7ef78ec8e 100644 --- a/feature/registration/build.gradle.kts +++ b/feature/registration/build.gradle.kts @@ -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 diff --git a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt index 54db12c4b7..2645e5ed6d 100644 --- a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt +++ b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt @@ -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 -// /** -// * 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 + // /** // * 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?, diff --git a/feature/registration/src/main/java/org/signal/registration/PersistedFlowState.kt b/feature/registration/src/main/java/org/signal/registration/PersistedFlowState.kt index 185cba7b03..59f74bf77a 100644 --- a/feature/registration/src/main/java/org/signal/registration/PersistedFlowState.kt +++ b/feature/registration/src/main/java/org/signal/registration/PersistedFlowState.kt @@ -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 ) } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt index 914fbc468b..8ae94cd4a7 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowEvent.kt @@ -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 diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt index d242ac3388..eae3ed2d6b 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationFlowState.kt @@ -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)" } } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index e855c959a3..b92dee0400 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -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.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.navigationEntries( // TODO: Implement TransferScreen } + // -- Device Transfer: Instructions + entry { + 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 { + 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 { + 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 { + val viewModel: DeviceTransferCompleteViewModel = viewModel( + factory = DeviceTransferCompleteViewModel.Factory(parentEventEmitter) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + DeviceTransferCompleteScreen( + state = state, + onEvent = viewModel::onEvent + ) + } + entry { // TODO: Implement ProfileScreen } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index ab287b9248..59b426a7eb 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -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 = 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( diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt index 2c8df39daa..c70850bbcc 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationViewModel.kt @@ -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() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreen.kt new file mode 100644 index 0000000000..7a948db9b1 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreen.kt @@ -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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreenEvents.kt new file mode 100644 index 0000000000..7b42e385c8 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteState.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteState.kt new file mode 100644 index 0000000000..facb46ca1e --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteState.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModel.kt new file mode 100644 index 0000000000..5b4ec85df2 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModel.kt @@ -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(TAG) { + + companion object { + private val TAG = Log.tag(DeviceTransferCompleteViewModel::class) + } + + private val _state = MutableStateFlow(DeviceTransferCompleteState()) + val state: StateFlow = _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 create(modelClass: Class): T { + return DeviceTransferCompleteViewModel(parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreen.kt new file mode 100644 index 0000000000..036eb4186b --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreen.kt @@ -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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreenEvents.kt new file mode 100644 index 0000000000..cc68d509fc --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsState.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsState.kt new file mode 100644 index 0000000000..52b5a81aad --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsState.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModel.kt new file mode 100644 index 0000000000..f2471e0c05 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModel.kt @@ -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(TAG) { + + companion object { + private val TAG = Log.tag(DeviceTransferInstructionsViewModel::class) + } + + private val _state = MutableStateFlow(DeviceTransferInstructionsState()) + val state: StateFlow = _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 create(modelClass: Class): T { + return DeviceTransferInstructionsViewModel(parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreen.kt new file mode 100644 index 0000000000..2b6cc6e8cf --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreen.kt @@ -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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreenEvents.kt new file mode 100644 index 0000000000..0e0e87e7b3 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressState.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressState.kt new file mode 100644 index 0000000000..1941209e77 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressState.kt @@ -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 + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModel.kt new file mode 100644 index 0000000000..57079df956 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModel.kt @@ -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, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(DeviceTransferProgressViewModel::class) + + /** Cold Flow over [NewDeviceRestoreStatus] EventBus events. */ + fun restoreStatusFlow(): Flow = 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 = _state + + private val _showCancelDialog = MutableStateFlow(false) + val showCancelDialog: StateFlow = _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, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return DeviceTransferProgressViewModel(context, progressEvents, parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreen.kt new file mode 100644 index 0000000000..140d3f9dc8 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreen.kt @@ -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") + } + } + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreenEvents.kt new file mode 100644 index 0000000000..5eeb47a251 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupState.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupState.kt new file mode 100644 index 0000000000..68a0f4d418 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupState.kt @@ -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 + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModel.kt new file mode 100644 index 0000000000..3026d3df60 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModel.kt @@ -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, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit +) : EventDrivenViewModel(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 = 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 = _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, + private val parentState: StateFlow, + private val parentEventEmitter: (RegistrationFlowEvent) -> Unit + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return DeviceTransferSetupViewModel(context, networkController, setupEvents, parentState, parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/SetupStep.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/SetupStep.kt new file mode 100644 index 0000000000..3b783b3f6d --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/SetupStep.kt @@ -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) +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt index 159fc54704..42c7c3ab8f 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/quickrestore/QuickRestoreQrViewModel.kt @@ -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()) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionState.kt b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionState.kt index e5174d6f64..aaa9abbd38 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionState.kt @@ -9,7 +9,9 @@ import org.signal.registration.util.DebugLoggableModel data class ArchiveRestoreSelectionState( val restoreOptions: List = 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 } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt index 8a87928073..6915340e29 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModel.kt @@ -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, private val isPreRegistration: Boolean, + private val repository: RegistrationRepository, + private val parentState: StateFlow, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit ) : EventDrivenViewModel(TAG) { @@ -39,17 +50,29 @@ class ArchiveRestoreSelectionViewModel( ) val state: StateFlow = _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, private val isPreRegistration: Boolean, + private val repository: RegistrationRepository, + private val parentState: StateFlow, private val parentEventEmitter: (RegistrationFlowEvent) -> Unit ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return ArchiveRestoreSelectionViewModel(restoreOptions, isPreRegistration, parentEventEmitter) as T + return ArchiveRestoreSelectionViewModel(restoreOptions, isPreRegistration, repository, parentState, parentEventEmitter) as T } } } diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index fd91a9653f..fb51882f1b 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -398,4 +398,51 @@ Don\'t have Signal on another device? Create account + + + Transfer your account + To transfer your account, open Signal on your old Android device and select Transfer Account. + Continue + + + Preparing to connect to old Android device… + This might take a moment. Should be ready soon. + Waiting for your old device to connect… + Verify the numbers match on both devices + Yes, the numbers match + The numbers do not match + Waiting for your old device to verify… + Signal needs the location permission to discover and connect with your old device. + Grant permission + Signal needs location services enabled to discover and connect with your old device. + Turn on location services + Signal needs Wi-Fi turned on to discover and connect with your old device. + Turn on Wi-Fi + Sorry, it appears your device does not support Wi-Fi Direct. + Restore a backup instead + An unexpected error occurred while attempting to connect to your old device. + Retry + Unable to discover your old device + • 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. + Try again + The numbers do not match + If the numbers on your devices do not match, it\'s possible you connected to the wrong device. + Stop transfer + + + Transferring data + %1$d messages so far + Unable to transfer + Cannot transfer from a newer version of Signal. + Some data could not be transferred due to a database constraint. + The transfer failed. Please try again. + Try again + Cancel + Stop transfer? + All transfer progress will be lost. + + + Transfer complete + Your account is now on this device. + Continue diff --git a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModelTest.kt new file mode 100644 index 0000000000..884ad8c1ce --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModelTest.kt @@ -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 + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + private lateinit var emittedStates: MutableList + 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) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModelTest.kt new file mode 100644 index 0000000000..fe7706ac3f --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsViewModelTest.kt @@ -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 + private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit + private lateinit var emittedStates: MutableList + 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) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModelTest.kt new file mode 100644 index 0000000000..b1f7e73458 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressViewModelTest.kt @@ -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 + private lateinit var emittedEvents: MutableList + 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) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModelTest.kt new file mode 100644 index 0000000000..99699079a8 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupViewModelTest.kt @@ -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 + private lateinit var emittedEvents: MutableList + 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) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModelTest.kt index 6cd4a8fbf1..f1943176e3 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/restoreselection/ArchiveRestoreSelectionViewModelTest.kt @@ -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(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() + .prop(RegistrationFlowEvent.NavigateToScreen::route) + .isEqualTo(RegistrationRoute.DeviceTransferInstructions) } @Test diff --git a/lib/device-transfer/src/main/java/org/signal/devicetransfer/NewDeviceRestoreStatus.java b/lib/device-transfer/src/main/java/org/signal/devicetransfer/NewDeviceRestoreStatus.java new file mode 100644 index 0000000000..7faf73b2c2 --- /dev/null +++ b/lib/device-transfer/src/main/java/org/signal/devicetransfer/NewDeviceRestoreStatus.java @@ -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 + } +}