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