mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-29 21:22:15 +01:00
Add quick restore flow and DebugLoggableModel to regV5.
Renames restore → quickrestore package, adds QuickRestoreQrViewModel, introduces DebugLoggableModel for safe toString in release builds, updates all State/Events classes to extend it, switches previews to AllDevicePreviews, and enables BuildConfig for the registration module.
This commit is contained in:
committed by
Michelle Tang
parent
889ebcadd4
commit
39de824bf0
@@ -119,3 +119,11 @@ fun String.splitByByteLength(byteLength: Int): Pair<String, String?> {
|
|||||||
val remainder = this.substring(firstPart.length)
|
val remainder = this.substring(firstPart.length)
|
||||||
return firstPart to remainder
|
return firstPart to remainder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a new string with the same length, but all chars replaced with the [censorChar].
|
||||||
|
* e.g. "abc".censor() -> "***"
|
||||||
|
*/
|
||||||
|
fun String.censor(censorChar: Char = '*'): String {
|
||||||
|
return String(CharArray(this.length) { censorChar })
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ dependencies {
|
|||||||
implementation(project(":core:util"))
|
implementation(project(":core:util"))
|
||||||
implementation(project(":core:models-jvm"))
|
implementation(project(":core:models-jvm"))
|
||||||
implementation(project(":lib:libsignal-service"))
|
implementation(project(":lib:libsignal-service"))
|
||||||
|
implementation(project(":lib:qr"))
|
||||||
|
|
||||||
// libsignal-protocol for PreKeyCollection types
|
// libsignal-protocol for PreKeyCollection types
|
||||||
implementation(libs.libsignal.client)
|
implementation(libs.libsignal.client)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".RegistrationApplication"
|
android:name=".RegistrationApplication"
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ import org.signal.registration.sample.debug.NetworkDebugOverlay
|
|||||||
import org.signal.registration.sample.screens.RegistrationCompleteScreen
|
import org.signal.registration.sample.screens.RegistrationCompleteScreen
|
||||||
import org.signal.registration.sample.screens.main.MainScreen
|
import org.signal.registration.sample.screens.main.MainScreen
|
||||||
import org.signal.registration.sample.screens.main.MainScreenViewModel
|
import org.signal.registration.sample.screens.main.MainScreenViewModel
|
||||||
|
import org.signal.registration.sample.screens.olddevicetransfer.TransferAccountScreen
|
||||||
|
import org.signal.registration.sample.screens.olddevicetransfer.TransferAccountViewModel
|
||||||
import org.signal.registration.sample.screens.pinsettings.PinSettingsScreen
|
import org.signal.registration.sample.screens.pinsettings.PinSettingsScreen
|
||||||
import org.signal.registration.sample.screens.pinsettings.PinSettingsViewModel
|
import org.signal.registration.sample.screens.pinsettings.PinSettingsViewModel
|
||||||
|
|
||||||
@@ -74,6 +76,9 @@ sealed interface SampleRoute : NavKey {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object RegistrationComplete : SampleRoute
|
data object RegistrationComplete : SampleRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object TransferAccount : SampleRoute
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object PinSettings : SampleRoute
|
data object PinSettings : SampleRoute
|
||||||
}
|
}
|
||||||
@@ -146,7 +151,9 @@ private fun SampleNavHost(
|
|||||||
val viewModel: MainScreenViewModel = viewModel(
|
val viewModel: MainScreenViewModel = viewModel(
|
||||||
factory = MainScreenViewModel.Factory(
|
factory = MainScreenViewModel.Factory(
|
||||||
storageController = registrationDependencies.storageController,
|
storageController = registrationDependencies.storageController,
|
||||||
|
networkController = registrationDependencies.networkController,
|
||||||
onLaunchRegistration = { backStack.add(SampleRoute.Registration) },
|
onLaunchRegistration = { backStack.add(SampleRoute.Registration) },
|
||||||
|
onTransferAccount = { backStack.add(SampleRoute.TransferAccount) },
|
||||||
onOpenPinSettings = { backStack.add(SampleRoute.PinSettings) }
|
onOpenPinSettings = { backStack.add(SampleRoute.PinSettings) }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -177,6 +184,20 @@ private fun SampleNavHost(
|
|||||||
RegistrationCompleteScreen(onStartOver = onStartOver)
|
RegistrationCompleteScreen(onStartOver = onStartOver)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entry<SampleRoute.TransferAccount> {
|
||||||
|
val viewModel: TransferAccountViewModel = viewModel(
|
||||||
|
factory = TransferAccountViewModel.Factory(
|
||||||
|
onBack = { backStack.removeLastOrNull() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
TransferAccountScreen(
|
||||||
|
state = state,
|
||||||
|
onEvent = { viewModel.onEvent(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
entry<SampleRoute.PinSettings>(
|
entry<SampleRoute.PinSettings>(
|
||||||
metadata = BottomSheetTransitionSpec
|
metadata = BottomSheetTransitionSpec
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ class RegistrationApplication : Application() {
|
|||||||
companion object {
|
companion object {
|
||||||
// Staging SVR2 mrEnclave value
|
// Staging SVR2 mrEnclave value
|
||||||
private const val SVR2_MRENCLAVE = "97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535"
|
private const val SVR2_MRENCLAVE = "97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535"
|
||||||
|
|
||||||
|
lateinit var serviceConfiguration: SignalServiceConfiguration
|
||||||
|
private set
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -46,6 +49,7 @@ class RegistrationApplication : Application() {
|
|||||||
|
|
||||||
val trustStore = SampleTrustStore()
|
val trustStore = SampleTrustStore()
|
||||||
val configuration = createServiceConfiguration(trustStore)
|
val configuration = createServiceConfiguration(trustStore)
|
||||||
|
serviceConfiguration = configuration
|
||||||
val pushServiceSocket = createPushServiceSocket(configuration)
|
val pushServiceSocket = createPushServiceSocket(configuration)
|
||||||
val demoNetworkController = DemoNetworkController(this, pushServiceSocket, configuration, SVR2_MRENCLAVE)
|
val demoNetworkController = DemoNetworkController(this, pushServiceSocket, configuration, SVR2_MRENCLAVE)
|
||||||
val networkController = DebugNetworkController(demoNetworkController)
|
val networkController = DebugNetworkController(demoNetworkController)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
package org.signal.registration.sample.debug
|
package org.signal.registration.sample.debug
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.signal.core.models.MasterKey
|
import org.signal.core.models.MasterKey
|
||||||
import org.signal.core.util.logging.Log
|
import org.signal.core.util.logging.Log
|
||||||
import org.signal.registration.NetworkController
|
import org.signal.registration.NetworkController
|
||||||
@@ -17,6 +18,7 @@ import org.signal.registration.NetworkController.GetSessionStatusError
|
|||||||
import org.signal.registration.NetworkController.GetSvrCredentialsError
|
import org.signal.registration.NetworkController.GetSvrCredentialsError
|
||||||
import org.signal.registration.NetworkController.MasterKeyResponse
|
import org.signal.registration.NetworkController.MasterKeyResponse
|
||||||
import org.signal.registration.NetworkController.PreKeyCollection
|
import org.signal.registration.NetworkController.PreKeyCollection
|
||||||
|
import org.signal.registration.NetworkController.ProvisioningEvent
|
||||||
import org.signal.registration.NetworkController.RegisterAccountError
|
import org.signal.registration.NetworkController.RegisterAccountError
|
||||||
import org.signal.registration.NetworkController.RegisterAccountResponse
|
import org.signal.registration.NetworkController.RegisterAccountResponse
|
||||||
import org.signal.registration.NetworkController.RegistrationNetworkResult
|
import org.signal.registration.NetworkController.RegistrationNetworkResult
|
||||||
@@ -197,6 +199,10 @@ class DebugNetworkController(
|
|||||||
return delegate.getSvrCredentials()
|
return delegate.getSvrCredentials()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun startProvisioning(): Flow<ProvisioningEvent> {
|
||||||
|
return delegate.startProvisioning()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun checkSvrCredentials(
|
override suspend fun checkSvrCredentials(
|
||||||
e164: String,
|
e164: String,
|
||||||
credentials: List<SvrCredentials>
|
credentials: List<SvrCredentials>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
package org.signal.registration.sample.dependencies
|
package org.signal.registration.sample.dependencies
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
@@ -14,6 +20,9 @@ import okhttp3.Response
|
|||||||
import org.signal.core.models.MasterKey
|
import org.signal.core.models.MasterKey
|
||||||
import org.signal.core.util.logging.Log
|
import org.signal.core.util.logging.Log
|
||||||
import org.signal.libsignal.net.Network
|
import org.signal.libsignal.net.Network
|
||||||
|
import org.signal.libsignal.protocol.IdentityKey
|
||||||
|
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||||
|
import org.signal.libsignal.protocol.ecc.ECPrivateKey
|
||||||
import org.signal.libsignal.protocol.util.Hex
|
import org.signal.libsignal.protocol.util.Hex
|
||||||
import org.signal.registration.NetworkController
|
import org.signal.registration.NetworkController
|
||||||
import org.signal.registration.NetworkController.AccountAttributes
|
import org.signal.registration.NetworkController.AccountAttributes
|
||||||
@@ -22,6 +31,8 @@ import org.signal.registration.NetworkController.CheckSvrCredentialsResponse
|
|||||||
import org.signal.registration.NetworkController.CreateSessionError
|
import org.signal.registration.NetworkController.CreateSessionError
|
||||||
import org.signal.registration.NetworkController.GetSessionStatusError
|
import org.signal.registration.NetworkController.GetSessionStatusError
|
||||||
import org.signal.registration.NetworkController.PreKeyCollection
|
import org.signal.registration.NetworkController.PreKeyCollection
|
||||||
|
import org.signal.registration.NetworkController.ProvisioningEvent
|
||||||
|
import org.signal.registration.NetworkController.ProvisioningMessage
|
||||||
import org.signal.registration.NetworkController.RegisterAccountError
|
import org.signal.registration.NetworkController.RegisterAccountError
|
||||||
import org.signal.registration.NetworkController.RegisterAccountResponse
|
import org.signal.registration.NetworkController.RegisterAccountResponse
|
||||||
import org.signal.registration.NetworkController.RegistrationLockResponse
|
import org.signal.registration.NetworkController.RegistrationLockResponse
|
||||||
@@ -32,9 +43,11 @@ import org.signal.registration.NetworkController.SubmitVerificationCodeError
|
|||||||
import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse
|
import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse
|
||||||
import org.signal.registration.NetworkController.UpdateSessionError
|
import org.signal.registration.NetworkController.UpdateSessionError
|
||||||
import org.signal.registration.NetworkController.VerificationCodeTransport
|
import org.signal.registration.NetworkController.VerificationCodeTransport
|
||||||
|
import org.signal.registration.proto.RegistrationProvisionMessage
|
||||||
import org.signal.registration.sample.fcm.FcmUtil
|
import org.signal.registration.sample.fcm.FcmUtil
|
||||||
import org.signal.registration.sample.fcm.PushChallengeReceiver
|
import org.signal.registration.sample.fcm.PushChallengeReceiver
|
||||||
import org.signal.registration.sample.storage.RegistrationPreferences
|
import org.signal.registration.sample.storage.RegistrationPreferences
|
||||||
|
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket
|
||||||
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
|
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
|
||||||
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse
|
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse
|
||||||
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2
|
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2
|
||||||
@@ -43,6 +56,7 @@ import org.whispersystems.signalservice.api.websocket.HealthMonitor
|
|||||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||||
import org.whispersystems.signalservice.api.websocket.WebSocketFactory
|
import org.whispersystems.signalservice.api.websocket.WebSocketFactory
|
||||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||||
|
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
|
||||||
import org.whispersystems.signalservice.internal.push.AuthCredentials
|
import org.whispersystems.signalservice.internal.push.AuthCredentials
|
||||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||||
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider
|
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider
|
||||||
@@ -368,6 +382,88 @@ class DemoNetworkController(
|
|||||||
return "https://signalcaptchas.org/staging/registration/generate.html"
|
return "https://signalcaptchas.org/staging/registration/generate.html"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun startProvisioning(): Flow<ProvisioningEvent> = callbackFlow {
|
||||||
|
val socketHandles = mutableListOf<java.io.Closeable>()
|
||||||
|
|
||||||
|
fun startSocket() {
|
||||||
|
val handle = ProvisioningSocket.start<RegistrationProvisionMessage>(
|
||||||
|
mode = ProvisioningSocket.Mode.REREG,
|
||||||
|
identityKeyPair = IdentityKeyPair.generate(),
|
||||||
|
configuration = serviceConfiguration,
|
||||||
|
handler = { id, t ->
|
||||||
|
Log.w(TAG, "[startProvisioning] Socket [$id] failed", t)
|
||||||
|
trySend(ProvisioningEvent.Error(t))
|
||||||
|
}
|
||||||
|
) { socket ->
|
||||||
|
val url = socket.getProvisioningUrl()
|
||||||
|
trySend(ProvisioningEvent.QrCodeReady(url))
|
||||||
|
|
||||||
|
val result = socket.getProvisioningMessageDecryptResult()
|
||||||
|
|
||||||
|
if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) {
|
||||||
|
val msg = result.message
|
||||||
|
trySend(
|
||||||
|
ProvisioningEvent.MessageReceived(
|
||||||
|
ProvisioningMessage(
|
||||||
|
accountEntropyPool = msg.accountEntropyPool,
|
||||||
|
e164 = msg.e164,
|
||||||
|
pin = msg.pin,
|
||||||
|
aciIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.aciIdentityKeyPublic.toByteArray()), ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray())),
|
||||||
|
pniIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.pniIdentityKeyPublic.toByteArray()), ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray())),
|
||||||
|
platform = when (msg.platform) {
|
||||||
|
RegistrationProvisionMessage.Platform.ANDROID -> NetworkController.ProvisioningMessage.Platform.ANDROID
|
||||||
|
RegistrationProvisionMessage.Platform.IOS -> NetworkController.ProvisioningMessage.Platform.IOS
|
||||||
|
},
|
||||||
|
tier = when (msg.tier) {
|
||||||
|
RegistrationProvisionMessage.Tier.FREE -> NetworkController.ProvisioningMessage.Tier.FREE
|
||||||
|
RegistrationProvisionMessage.Tier.PAID -> NetworkController.ProvisioningMessage.Tier.PAID
|
||||||
|
null -> null
|
||||||
|
},
|
||||||
|
backupTimestampMs = msg.backupTimestampMs,
|
||||||
|
backupSizeBytes = msg.backupSizeBytes,
|
||||||
|
restoreMethodToken = msg.restoreMethodToken,
|
||||||
|
backupVersion = msg.backupVersion
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
channel.close()
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "[startProvisioning] Failed to decrypt provisioning message")
|
||||||
|
trySend(ProvisioningEvent.Error(IOException("Failed to decrypt provisioning message")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized(socketHandles) {
|
||||||
|
socketHandles += handle
|
||||||
|
if (socketHandles.size > 2) {
|
||||||
|
socketHandles.removeAt(0).close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startSocket()
|
||||||
|
|
||||||
|
val rotationJob = launch {
|
||||||
|
var count = 0
|
||||||
|
while (count < 5 && isActive) {
|
||||||
|
delay(ProvisioningSocket.LIFESPAN / 2)
|
||||||
|
if (isActive) {
|
||||||
|
startSocket()
|
||||||
|
count++
|
||||||
|
Log.d(TAG, "[startProvisioning] Rotated socket, count: $count")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
awaitClose {
|
||||||
|
rotationJob.cancel()
|
||||||
|
synchronized(socketHandles) {
|
||||||
|
socketHandles.forEach { it.close() }
|
||||||
|
socketHandles.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun restoreMasterKeyFromSvr(
|
override suspend fun restoreMasterKeyFromSvr(
|
||||||
svrCredentials: NetworkController.SvrCredentials,
|
svrCredentials: NetworkController.SvrCredentials,
|
||||||
pin: String
|
pin: String
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ class DemoStorageController(context: Context) : StorageController {
|
|||||||
RegistrationPreferences.pinAlphanumeric = isAlphanumeric
|
RegistrationPreferences.pinAlphanumeric = isAlphanumeric
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun saveProvisioningData(provisioningMessage: NetworkController.ProvisioningMessage) = withContext(Dispatchers.IO) {
|
||||||
|
RegistrationPreferences.saveProvisioningData(provisioningMessage)
|
||||||
|
}
|
||||||
|
|
||||||
private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) {
|
private fun storeKeyMaterial(keyMaterial: KeyMaterial, profileKey: ProfileKey) {
|
||||||
// Clear existing data
|
// Clear existing data
|
||||||
RegistrationPreferences.clearKeyMaterial()
|
RegistrationPreferences.clearKeyMaterial()
|
||||||
|
|||||||
@@ -5,13 +5,16 @@
|
|||||||
|
|
||||||
package org.signal.registration.sample.screens.main
|
package org.signal.registration.sample.screens.main
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -93,6 +96,26 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.existingRegistrationState != null) {
|
if (state.existingRegistrationState != null) {
|
||||||
|
if (state.registrationExpired) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No longer registered. Your credentials are no longer valid on the server.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
RegistrationInfo(state.existingRegistrationState)
|
RegistrationInfo(state.existingRegistrationState)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
@@ -104,6 +127,13 @@ fun MainScreen(
|
|||||||
Text("Re-register")
|
Text("Re-register")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onEvent(MainScreenEvents.TransferAccount) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Transfer to New Device")
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { onEvent(MainScreenEvents.OpenPinSettings) },
|
onClick = { onEvent(MainScreenEvents.OpenPinSettings) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package org.signal.registration.sample.screens.main
|
|||||||
|
|
||||||
sealed interface MainScreenEvents {
|
sealed interface MainScreenEvents {
|
||||||
data object LaunchRegistration : MainScreenEvents
|
data object LaunchRegistration : MainScreenEvents
|
||||||
|
data object TransferAccount : MainScreenEvents
|
||||||
data object OpenPinSettings : MainScreenEvents
|
data object OpenPinSettings : MainScreenEvents
|
||||||
data object ClearAllData : MainScreenEvents
|
data object ClearAllData : MainScreenEvents
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
package org.signal.registration.sample.screens.main
|
package org.signal.registration.sample.screens.main
|
||||||
|
|
||||||
data class MainScreenState(
|
data class MainScreenState(
|
||||||
val existingRegistrationState: ExistingRegistrationState? = null
|
val existingRegistrationState: ExistingRegistrationState? = null,
|
||||||
|
val registrationExpired: Boolean = false
|
||||||
) {
|
) {
|
||||||
data class ExistingRegistrationState(
|
data class ExistingRegistrationState(
|
||||||
val phoneNumber: String,
|
val phoneNumber: String,
|
||||||
|
|||||||
@@ -13,15 +13,23 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.signal.core.util.Base64
|
import org.signal.core.util.Base64
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
|
import org.signal.registration.NetworkController
|
||||||
import org.signal.registration.StorageController
|
import org.signal.registration.StorageController
|
||||||
import org.signal.registration.sample.storage.RegistrationPreferences
|
import org.signal.registration.sample.storage.RegistrationPreferences
|
||||||
|
|
||||||
class MainScreenViewModel(
|
class MainScreenViewModel(
|
||||||
private val storageController: StorageController,
|
private val storageController: StorageController,
|
||||||
|
private val networkController: NetworkController,
|
||||||
private val onLaunchRegistration: () -> Unit,
|
private val onLaunchRegistration: () -> Unit,
|
||||||
|
private val onTransferAccount: () -> Unit,
|
||||||
private val onOpenPinSettings: () -> Unit
|
private val onOpenPinSettings: () -> Unit
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = Log.tag(MainScreenViewModel::class)
|
||||||
|
}
|
||||||
|
|
||||||
private val _state = MutableStateFlow(MainScreenState())
|
private val _state = MutableStateFlow(MainScreenState())
|
||||||
val state: StateFlow<MainScreenState> = _state.asStateFlow()
|
val state: StateFlow<MainScreenState> = _state.asStateFlow()
|
||||||
|
|
||||||
@@ -37,6 +45,7 @@ class MainScreenViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (event) {
|
when (event) {
|
||||||
MainScreenEvents.LaunchRegistration -> onLaunchRegistration()
|
MainScreenEvents.LaunchRegistration -> onLaunchRegistration()
|
||||||
|
MainScreenEvents.TransferAccount -> onTransferAccount()
|
||||||
MainScreenEvents.OpenPinSettings -> onOpenPinSettings()
|
MainScreenEvents.OpenPinSettings -> onOpenPinSettings()
|
||||||
MainScreenEvents.ClearAllData -> {
|
MainScreenEvents.ClearAllData -> {
|
||||||
storageController.clearAllData()
|
storageController.clearAllData()
|
||||||
@@ -65,18 +74,51 @@ class MainScreenViewModel(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
},
|
||||||
|
registrationExpired = false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (existingData != null) {
|
||||||
|
checkRegistrationStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkRegistrationStatus() {
|
||||||
|
when (val result = networkController.getSvrCredentials()) {
|
||||||
|
is NetworkController.RegistrationNetworkResult.Success -> {
|
||||||
|
Log.d(TAG, "[CheckRegistration] Still registered.")
|
||||||
|
}
|
||||||
|
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||||
|
when (result.error) {
|
||||||
|
NetworkController.GetSvrCredentialsError.Unauthorized -> {
|
||||||
|
Log.w(TAG, "[CheckRegistration] No longer registered (401).")
|
||||||
|
_state.value = _state.value.copy(registrationExpired = true)
|
||||||
|
}
|
||||||
|
NetworkController.GetSvrCredentialsError.NoServiceCredentialsAvailable -> {
|
||||||
|
Log.w(TAG, "[CheckRegistration] No credentials available locally.")
|
||||||
|
_state.value = _state.value.copy(registrationExpired = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||||
|
Log.w(TAG, "[CheckRegistration] Network error, can't verify status.", result.exception)
|
||||||
|
}
|
||||||
|
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||||
|
Log.w(TAG, "[CheckRegistration] Application error, can't verify status.", result.exception)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Factory(
|
class Factory(
|
||||||
private val storageController: StorageController,
|
private val storageController: StorageController,
|
||||||
|
private val networkController: NetworkController,
|
||||||
private val onLaunchRegistration: () -> Unit,
|
private val onLaunchRegistration: () -> Unit,
|
||||||
|
private val onTransferAccount: () -> Unit,
|
||||||
private val onOpenPinSettings: () -> Unit
|
private val onOpenPinSettings: () -> Unit
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
return MainScreenViewModel(storageController, onLaunchRegistration, onOpenPinSettings) as T
|
return MainScreenViewModel(storageController, networkController, onLaunchRegistration, onTransferAccount, onOpenPinSettings) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ object RegistrationPreferences {
|
|||||||
private const val KEY_PIN_ALPHANUMERIC = "pin_alphanumeric"
|
private const val KEY_PIN_ALPHANUMERIC = "pin_alphanumeric"
|
||||||
private const val KEY_PINS_OPTED_OUT = "pins_opted_out"
|
private const val KEY_PINS_OPTED_OUT = "pins_opted_out"
|
||||||
private const val KEY_SVR2_CREDENTIALS = "svr2_credentials"
|
private const val KEY_SVR2_CREDENTIALS = "svr2_credentials"
|
||||||
|
private const val KEY_RESTORE_METHOD_TOKEN = "restore_method_token"
|
||||||
|
private const val KEY_BACKUP_TIER = "backup_tier"
|
||||||
|
private const val KEY_BACKUP_TIMESTAMP_MS = "backup_timestamp_ms"
|
||||||
|
private const val KEY_BACKUP_SIZE_BYTES = "backup_size_bytes"
|
||||||
|
private const val KEY_OTHER_DEVICE_PLATFORM = "other_device_platform"
|
||||||
|
private const val KEY_BACKUP_VERSION = "backup_version"
|
||||||
|
|
||||||
fun init(context: Application) {
|
fun init(context: Application) {
|
||||||
this.context = context
|
this.context = context
|
||||||
@@ -169,6 +175,17 @@ object RegistrationPreferences {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveProvisioningData(message: NetworkController.ProvisioningMessage) {
|
||||||
|
prefs.edit {
|
||||||
|
putString(KEY_RESTORE_METHOD_TOKEN, message.restoreMethodToken)
|
||||||
|
putString(KEY_BACKUP_TIER, message.tier?.name)
|
||||||
|
message.backupTimestampMs?.let { putLong(KEY_BACKUP_TIMESTAMP_MS, it) }
|
||||||
|
message.backupSizeBytes?.let { putLong(KEY_BACKUP_SIZE_BYTES, it) }
|
||||||
|
putString(KEY_OTHER_DEVICE_PLATFORM, message.platform.name)
|
||||||
|
putLong(KEY_BACKUP_VERSION, message.backupVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun clearKeyMaterial() {
|
fun clearKeyMaterial() {
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
remove(KEY_PROFILE_KEY)
|
remove(KEY_PROFILE_KEY)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ android {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
package org.signal.registration
|
package org.signal.registration
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.signal.core.models.MasterKey
|
import org.signal.core.models.MasterKey
|
||||||
import org.signal.core.util.serialization.ByteArrayToBase64Serializer
|
import org.signal.core.util.serialization.ByteArrayToBase64Serializer
|
||||||
import org.signal.libsignal.protocol.IdentityKey
|
import org.signal.libsignal.protocol.IdentityKey
|
||||||
|
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -179,6 +181,19 @@ interface NetworkController {
|
|||||||
*/
|
*/
|
||||||
suspend fun setAccountAttributes(attributes: AccountAttributes): RegistrationNetworkResult<Unit, SetAccountAttributesError>
|
suspend fun setAccountAttributes(attributes: AccountAttributes): RegistrationNetworkResult<Unit, SetAccountAttributesError>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a provisioning session for QR-based quick restore.
|
||||||
|
*
|
||||||
|
* The returned flow emits [ProvisioningEvent]s:
|
||||||
|
* - [ProvisioningEvent.QrCodeReady] whenever a new QR code URL is available (e.g. due to socket rotation).
|
||||||
|
* - [ProvisioningEvent.MessageReceived] when the old device scans the QR code and sends provisioning data.
|
||||||
|
* - [ProvisioningEvent.Error] if the provisioning session encounters an unrecoverable error.
|
||||||
|
*
|
||||||
|
* The flow will manage socket lifecycle (rotation, keep-alive) internally.
|
||||||
|
* Cancel the collecting coroutine to stop provisioning.
|
||||||
|
*/
|
||||||
|
fun startProvisioning(): Flow<ProvisioningEvent>
|
||||||
|
|
||||||
// /**
|
// /**
|
||||||
// * Set [RestoreMethod] enum on the server for use by the old device to update UX.
|
// * Set [RestoreMethod] enum on the server for use by the old device to update UX.
|
||||||
// */
|
// */
|
||||||
@@ -431,4 +446,38 @@ interface NetworkController {
|
|||||||
enum class VerificationCodeTransport {
|
enum class VerificationCodeTransport {
|
||||||
SMS, VOICE
|
SMS, VOICE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data received from the old device during QR-based provisioning.
|
||||||
|
*/
|
||||||
|
data class ProvisioningMessage(
|
||||||
|
val accountEntropyPool: String,
|
||||||
|
val e164: String,
|
||||||
|
val pin: String?,
|
||||||
|
val aciIdentityKeyPair: IdentityKeyPair,
|
||||||
|
val pniIdentityKeyPair: IdentityKeyPair,
|
||||||
|
val platform: Platform,
|
||||||
|
val tier: Tier?,
|
||||||
|
val backupTimestampMs: Long?,
|
||||||
|
val backupSizeBytes: Long?,
|
||||||
|
val restoreMethodToken: String,
|
||||||
|
val backupVersion: Long
|
||||||
|
) {
|
||||||
|
enum class Platform { ANDROID, IOS }
|
||||||
|
enum class Tier { FREE, PAID }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events emitted during a provisioning session.
|
||||||
|
*/
|
||||||
|
sealed interface ProvisioningEvent {
|
||||||
|
/** A new QR code URL is available for display. */
|
||||||
|
data class QrCodeReady(val url: String) : ProvisioningEvent
|
||||||
|
|
||||||
|
/** The old device has scanned the QR code and sent provisioning data. */
|
||||||
|
data class MessageReceived(val message: ProvisioningMessage) : ProvisioningEvent
|
||||||
|
|
||||||
|
/** The provisioning session encountered an error. */
|
||||||
|
data class Error(val cause: Throwable?) : ProvisioningEvent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ package org.signal.registration
|
|||||||
|
|
||||||
import org.signal.core.models.AccountEntropyPool
|
import org.signal.core.models.AccountEntropyPool
|
||||||
import org.signal.core.models.MasterKey
|
import org.signal.core.models.MasterKey
|
||||||
|
import org.signal.registration.util.DebugLoggable
|
||||||
|
|
||||||
sealed interface RegistrationFlowEvent {
|
sealed interface RegistrationFlowEvent : DebugLoggable {
|
||||||
/** Navigate to a specific screen. */
|
/** Navigate to a specific screen. */
|
||||||
data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent
|
data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import kotlinx.parcelize.TypeParceler
|
|||||||
import org.signal.core.models.AccountEntropyPool
|
import org.signal.core.models.AccountEntropyPool
|
||||||
import org.signal.core.models.MasterKey
|
import org.signal.core.models.MasterKey
|
||||||
import org.signal.registration.util.AccountEntropyPoolParceler
|
import org.signal.registration.util.AccountEntropyPoolParceler
|
||||||
|
import org.signal.registration.util.DebugLoggable
|
||||||
import org.signal.registration.util.MasterKeyParceler
|
import org.signal.registration.util.MasterKeyParceler
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
@@ -37,4 +38,4 @@ data class RegistrationFlowState(
|
|||||||
|
|
||||||
/** If true, do not attempt any flows where we generate RRP's. Create a session instead. */
|
/** If true, do not attempt any flows where we generate RRP's. Create a session instead. */
|
||||||
val doNotAttemptRecoveryPassword: Boolean = false
|
val doNotAttemptRecoveryPassword: Boolean = false
|
||||||
) : Parcelable
|
) : Parcelable, DebugLoggable
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import org.signal.core.ui.navigation.TransitionSpecs
|
|||||||
import org.signal.registration.screens.accountlocked.AccountLockedScreen
|
import org.signal.registration.screens.accountlocked.AccountLockedScreen
|
||||||
import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents
|
import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents
|
||||||
import org.signal.registration.screens.accountlocked.AccountLockedState
|
import org.signal.registration.screens.accountlocked.AccountLockedState
|
||||||
|
// TODO [regV5] Uncomment when restore selection flow is ready
|
||||||
|
// import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionScreen
|
||||||
|
// import org.signal.registration.screens.restoreselection.ArchiveRestoreSelectionViewModel
|
||||||
import org.signal.registration.screens.captcha.CaptchaScreen
|
import org.signal.registration.screens.captcha.CaptchaScreen
|
||||||
import org.signal.registration.screens.captcha.CaptchaScreenEvents
|
import org.signal.registration.screens.captcha.CaptchaScreenEvents
|
||||||
import org.signal.registration.screens.captcha.CaptchaState
|
import org.signal.registration.screens.captcha.CaptchaState
|
||||||
@@ -47,9 +50,8 @@ import org.signal.registration.screens.pinentry.PinEntryForRegistrationLockViewM
|
|||||||
import org.signal.registration.screens.pinentry.PinEntryForSmsBypassViewModel
|
import org.signal.registration.screens.pinentry.PinEntryForSmsBypassViewModel
|
||||||
import org.signal.registration.screens.pinentry.PinEntryForSvrRestoreViewModel
|
import org.signal.registration.screens.pinentry.PinEntryForSvrRestoreViewModel
|
||||||
import org.signal.registration.screens.pinentry.PinEntryScreen
|
import org.signal.registration.screens.pinentry.PinEntryScreen
|
||||||
import org.signal.registration.screens.restore.RestoreViaQrScreen
|
import org.signal.registration.screens.quickrestore.QuickRestoreQrScreen
|
||||||
import org.signal.registration.screens.restore.RestoreViaQrScreenEvents
|
import org.signal.registration.screens.quickrestore.QuickRestoreQrViewModel
|
||||||
import org.signal.registration.screens.restore.RestoreViaQrState
|
|
||||||
import org.signal.registration.screens.util.navigateBack
|
import org.signal.registration.screens.util.navigateBack
|
||||||
import org.signal.registration.screens.util.navigateTo
|
import org.signal.registration.screens.util.navigateTo
|
||||||
import org.signal.registration.screens.verificationcode.VerificationCodeScreen
|
import org.signal.registration.screens.verificationcode.VerificationCodeScreen
|
||||||
@@ -99,6 +101,10 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object PinCreate : RegistrationRoute
|
data object PinCreate : RegistrationRoute
|
||||||
|
|
||||||
|
// TODO [regV5] Uncomment when restore selection flow is ready
|
||||||
|
// @Serializable
|
||||||
|
// data object ArchiveRestoreSelection : RegistrationRoute
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object ChooseRestoreOptionBeforeRegistration : RegistrationRoute
|
data object ChooseRestoreOptionBeforeRegistration : RegistrationRoute
|
||||||
|
|
||||||
@@ -398,29 +404,39 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO [regV5] Uncomment when restore selection flow is ready
|
||||||
|
// entry<RegistrationRoute.ArchiveRestoreSelection> {
|
||||||
|
// val viewModel: ArchiveRestoreSelectionViewModel = viewModel(
|
||||||
|
// factory = ArchiveRestoreSelectionViewModel.Factory(
|
||||||
|
// repository = registrationRepository,
|
||||||
|
// parentState = registrationViewModel.state,
|
||||||
|
// parentEventEmitter = registrationViewModel::onEvent
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
//
|
||||||
|
// ArchiveRestoreSelectionScreen(
|
||||||
|
// state = state,
|
||||||
|
// onEvent = { viewModel.onEvent(it) }
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
entry<RegistrationRoute.ChooseRestoreOptionAfterRegistration> {
|
entry<RegistrationRoute.ChooseRestoreOptionAfterRegistration> {
|
||||||
// TODO: Implement RestoreScreen
|
// TODO: Implement RestoreScreen
|
||||||
}
|
}
|
||||||
|
|
||||||
entry<RegistrationRoute.QuickRestoreQrScan> {
|
entry<RegistrationRoute.QuickRestoreQrScan> {
|
||||||
RestoreViaQrScreen(
|
val viewModel: QuickRestoreQrViewModel = viewModel(
|
||||||
state = RestoreViaQrState(),
|
factory = QuickRestoreQrViewModel.Factory(
|
||||||
onEvent = { event ->
|
repository = registrationRepository,
|
||||||
when (event) {
|
parentEventEmitter = registrationViewModel::onEvent
|
||||||
RestoreViaQrScreenEvents.RetryQrCode -> {
|
)
|
||||||
// TODO: Retry QR code generation
|
)
|
||||||
}
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
RestoreViaQrScreenEvents.Cancel -> {
|
|
||||||
parentEventEmitter.navigateBack()
|
QuickRestoreQrScreen(
|
||||||
}
|
state = state,
|
||||||
RestoreViaQrScreenEvents.UseProxy -> {
|
onEvent = { viewModel.onEvent(it) }
|
||||||
// TODO: Navigate to proxy settings
|
|
||||||
}
|
|
||||||
RestoreViaQrScreenEvents.DismissError -> {
|
|
||||||
// TODO: Clear error state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,19 @@ package org.signal.registration
|
|||||||
import android.app.backup.BackupManager
|
import android.app.backup.BackupManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.signal.core.models.AccountEntropyPool
|
||||||
import org.signal.core.models.MasterKey
|
import org.signal.core.models.MasterKey
|
||||||
import org.signal.core.models.ServiceId.ACI
|
import org.signal.core.models.ServiceId.ACI
|
||||||
import org.signal.core.models.ServiceId.PNI
|
import org.signal.core.models.ServiceId.PNI
|
||||||
import org.signal.core.util.logging.Log
|
import org.signal.core.util.logging.Log
|
||||||
|
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||||
import org.signal.registration.NetworkController.AccountAttributes
|
import org.signal.registration.NetworkController.AccountAttributes
|
||||||
import org.signal.registration.NetworkController.CreateSessionError
|
import org.signal.registration.NetworkController.CreateSessionError
|
||||||
import org.signal.registration.NetworkController.MasterKeyResponse
|
import org.signal.registration.NetworkController.MasterKeyResponse
|
||||||
import org.signal.registration.NetworkController.PreKeyCollection
|
import org.signal.registration.NetworkController.PreKeyCollection
|
||||||
|
import org.signal.registration.NetworkController.ProvisioningEvent
|
||||||
import org.signal.registration.NetworkController.RegisterAccountError
|
import org.signal.registration.NetworkController.RegisterAccountError
|
||||||
import org.signal.registration.NetworkController.RegisterAccountResponse
|
import org.signal.registration.NetworkController.RegisterAccountResponse
|
||||||
import org.signal.registration.NetworkController.RegistrationNetworkResult
|
import org.signal.registration.NetworkController.RegistrationNetworkResult
|
||||||
@@ -155,7 +159,16 @@ class RegistrationRepository(val context: Context, val networkController: Networ
|
|||||||
skipDeviceTransfer: Boolean = true,
|
skipDeviceTransfer: Boolean = true,
|
||||||
preExistingRegistrationData: PreExistingRegistrationData? = null
|
preExistingRegistrationData: PreExistingRegistrationData? = null
|
||||||
): RegistrationNetworkResult<Pair<RegisterAccountResponse, KeyMaterial>, RegisterAccountError> = withContext(Dispatchers.IO) {
|
): RegistrationNetworkResult<Pair<RegisterAccountResponse, KeyMaterial>, RegisterAccountError> = withContext(Dispatchers.IO) {
|
||||||
registerAccount(e164, sessionId = null, recoveryPassword, registrationLock, skipDeviceTransfer, preExistingRegistrationData)
|
registerAccount(
|
||||||
|
e164 = e164,
|
||||||
|
sessionId = null,
|
||||||
|
recoveryPassword = recoveryPassword,
|
||||||
|
registrationLock = registrationLock,
|
||||||
|
skipDeviceTransfer = skipDeviceTransfer,
|
||||||
|
existingAccountEntropyPool = preExistingRegistrationData?.aep,
|
||||||
|
existingAciIdentityKeyPair = preExistingRegistrationData?.aciIdentityKeyPair,
|
||||||
|
existingPniIdentityKeyPair = preExistingRegistrationData?.pniIdentityKeyPair
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,6 +195,42 @@ class RegistrationRepository(val context: Context, val networkController: Networ
|
|||||||
registerAccount(e164, sessionId, recoveryPassword = null, registrationLock, skipDeviceTransfer)
|
registerAccount(e164, sessionId, recoveryPassword = null, registrationLock, skipDeviceTransfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a provisioning session for QR-based quick restore.
|
||||||
|
* See [NetworkController.startProvisioning].
|
||||||
|
*/
|
||||||
|
fun startProvisioning(): Flow<ProvisioningEvent> {
|
||||||
|
return networkController.startProvisioning()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers an account using data received from the old device via QR provisioning.
|
||||||
|
*
|
||||||
|
* This method:
|
||||||
|
* 1. Saves provisioning metadata (restore token, backup info) to storage
|
||||||
|
* 2. Re-uses the identity key pairs and AEP from the old device
|
||||||
|
* 3. Derives the recovery password from the provisioned AEP
|
||||||
|
* 4. Registers the account
|
||||||
|
*/
|
||||||
|
suspend fun registerAccountWithProvisioningData(
|
||||||
|
provisioningMessage: NetworkController.ProvisioningMessage
|
||||||
|
): RegistrationNetworkResult<Pair<RegisterAccountResponse, KeyMaterial>, RegisterAccountError> = withContext(Dispatchers.IO) {
|
||||||
|
storageController.saveProvisioningData(provisioningMessage)
|
||||||
|
|
||||||
|
val aep = AccountEntropyPool(provisioningMessage.accountEntropyPool)
|
||||||
|
val recoveryPassword = aep.deriveMasterKey().deriveRegistrationRecoveryPassword()
|
||||||
|
|
||||||
|
registerAccount(
|
||||||
|
e164 = provisioningMessage.e164,
|
||||||
|
sessionId = null,
|
||||||
|
recoveryPassword = recoveryPassword,
|
||||||
|
skipDeviceTransfer = true,
|
||||||
|
existingAccountEntropyPool = aep,
|
||||||
|
existingAciIdentityKeyPair = provisioningMessage.aciIdentityKeyPair,
|
||||||
|
existingPniIdentityKeyPair = provisioningMessage.pniIdentityKeyPair
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a new account.
|
* Registers a new account.
|
||||||
*
|
*
|
||||||
@@ -205,17 +254,19 @@ class RegistrationRepository(val context: Context, val networkController: Networ
|
|||||||
recoveryPassword: String?,
|
recoveryPassword: String?,
|
||||||
registrationLock: String? = null,
|
registrationLock: String? = null,
|
||||||
skipDeviceTransfer: Boolean = true,
|
skipDeviceTransfer: Boolean = true,
|
||||||
preExistingRegistrationData: PreExistingRegistrationData? = null
|
existingAccountEntropyPool: AccountEntropyPool? = null,
|
||||||
|
existingAciIdentityKeyPair: IdentityKeyPair? = null,
|
||||||
|
existingPniIdentityKeyPair: IdentityKeyPair? = null
|
||||||
): RegistrationNetworkResult<Pair<RegisterAccountResponse, KeyMaterial>, RegisterAccountError> = withContext(Dispatchers.IO) {
|
): RegistrationNetworkResult<Pair<RegisterAccountResponse, KeyMaterial>, RegisterAccountError> = withContext(Dispatchers.IO) {
|
||||||
check(sessionId != null || recoveryPassword != null) { "Either sessionId or recoveryPassword must be provided" }
|
check(sessionId != null || recoveryPassword != null) { "Either sessionId or recoveryPassword must be provided" }
|
||||||
check(sessionId == null || recoveryPassword == null) { "Either sessionId or recoveryPassword must be provided, but not both" }
|
check(sessionId == null || recoveryPassword == null) { "Either sessionId or recoveryPassword must be provided, but not both" }
|
||||||
|
|
||||||
Log.i(TAG, "[registerAccount] Starting registration for $e164. sessionId: ${sessionId != null}, recoveryPassword: ${recoveryPassword != null}, registrationLock: ${registrationLock != null}, skipDeviceTransfer: $skipDeviceTransfer, preExistingRegistrationData: ${preExistingRegistrationData != null}")
|
Log.i(TAG, "[registerAccount] Starting registration for $e164. sessionId: ${sessionId != null}, recoveryPassword: ${recoveryPassword != null}, registrationLock: ${registrationLock != null}, skipDeviceTransfer: $skipDeviceTransfer, existingAep: ${existingAccountEntropyPool != null}")
|
||||||
|
|
||||||
val keyMaterial = storageController.generateAndStoreKeyMaterial(
|
val keyMaterial = storageController.generateAndStoreKeyMaterial(
|
||||||
existingAccountEntropyPool = preExistingRegistrationData?.aep,
|
existingAccountEntropyPool = existingAccountEntropyPool,
|
||||||
existingAciIdentityKeyPair = preExistingRegistrationData?.aciIdentityKeyPair,
|
existingAciIdentityKeyPair = existingAciIdentityKeyPair,
|
||||||
existingPniIdentityKeyPair = preExistingRegistrationData?.pniIdentityKeyPair
|
existingPniIdentityKeyPair = existingPniIdentityKeyPair
|
||||||
)
|
)
|
||||||
val fcmToken = networkController.getFcmToken()
|
val fcmToken = networkController.getFcmToken()
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onEvent(event: RegistrationFlowEvent) {
|
fun onEvent(event: RegistrationFlowEvent) {
|
||||||
|
Log.d(TAG, "[Event] $event")
|
||||||
_state.value = applyEvent(_state.value, event)
|
_state.value = applyEvent(_state.value, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ interface StorageController {
|
|||||||
*/
|
*/
|
||||||
suspend fun saveNewlyCreatedPin(pin: String, isAlphanumeric: Boolean)
|
suspend fun saveNewlyCreatedPin(pin: String, isAlphanumeric: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves metadata from a provisioning message received during QR-based restore.
|
||||||
|
*
|
||||||
|
* This includes the restore method token, backup tier, backup timestamps, and
|
||||||
|
* platform information from the old device. Called before registering with
|
||||||
|
* the provisioned data.
|
||||||
|
*/
|
||||||
|
suspend fun saveProvisioningData(provisioningMessage: NetworkController.ProvisioningMessage)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears all stored registration data, including key material and account information.
|
* Clears all stored registration data, including key material and account information.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.signal.core.ui.compose.DayNightPreviews
|
import org.signal.core.ui.compose.AllDevicePreviews
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,7 +86,7 @@ fun AccountLockedScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun AccountLockedScreenPreview() {
|
private fun AccountLockedScreenPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.accountlocked
|
package org.signal.registration.screens.accountlocked
|
||||||
|
|
||||||
sealed class AccountLockedScreenEvents {
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
|
|
||||||
|
sealed class AccountLockedScreenEvents : DebugLoggableModel() {
|
||||||
data object Next : AccountLockedScreenEvents()
|
data object Next : AccountLockedScreenEvents()
|
||||||
data object LearnMore : AccountLockedScreenEvents()
|
data object LearnMore : AccountLockedScreenEvents()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.accountlocked
|
package org.signal.registration.screens.accountlocked
|
||||||
|
|
||||||
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
|
|
||||||
data class AccountLockedState(
|
data class AccountLockedState(
|
||||||
val daysRemaining: Int = 10
|
val daysRemaining: Int = 10
|
||||||
)
|
) : DebugLoggableModel()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import org.signal.core.ui.compose.DayNightPreviews
|
import org.signal.core.ui.compose.AllDevicePreviews
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,7 +133,7 @@ fun CaptchaScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun CaptchaScreenLoadingPreview() {
|
private fun CaptchaScreenLoadingPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
@@ -147,7 +147,7 @@ private fun CaptchaScreenLoadingPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun CaptchaScreenErrorPreview() {
|
private fun CaptchaScreenErrorPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
|
|||||||
@@ -5,7 +5,14 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.captcha
|
package org.signal.registration.screens.captcha
|
||||||
|
|
||||||
sealed class CaptchaScreenEvents {
|
import org.signal.core.util.censor
|
||||||
data class CaptchaCompleted(val token: String) : CaptchaScreenEvents()
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
|
|
||||||
|
sealed class CaptchaScreenEvents : DebugLoggableModel() {
|
||||||
|
data class CaptchaCompleted(val token: String) : CaptchaScreenEvents() {
|
||||||
|
override fun toSafeString(): String {
|
||||||
|
return "CaptchaCompleted(token=${token.censor()})"
|
||||||
|
}
|
||||||
|
}
|
||||||
data object Cancel : CaptchaScreenEvents()
|
data object Cancel : CaptchaScreenEvents()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.captcha
|
package org.signal.registration.screens.captcha
|
||||||
|
|
||||||
sealed class CaptchaLoadState {
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
|
|
||||||
|
sealed class CaptchaLoadState : DebugLoggableModel() {
|
||||||
data object Loading : CaptchaLoadState()
|
data object Loading : CaptchaLoadState()
|
||||||
data object Loaded : CaptchaLoadState()
|
data object Loaded : CaptchaLoadState()
|
||||||
data object Error : CaptchaLoadState()
|
data object Error : CaptchaLoadState()
|
||||||
@@ -15,4 +17,4 @@ data class CaptchaState(
|
|||||||
val captchaUrl: String,
|
val captchaUrl: String,
|
||||||
val captchaScheme: String = "signalcaptcha://",
|
val captchaScheme: String = "signalcaptcha://",
|
||||||
val loadState: CaptchaLoadState = CaptchaLoadState.Loading
|
val loadState: CaptchaLoadState = CaptchaLoadState.Loading
|
||||||
)
|
) : DebugLoggableModel()
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import androidx.compose.ui.text.input.VisualTransformation
|
|||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.signal.core.ui.compose.DayNightPreviews
|
import org.signal.core.ui.compose.AllDevicePreviews
|
||||||
import org.signal.core.ui.compose.Dividers
|
import org.signal.core.ui.compose.Dividers
|
||||||
import org.signal.core.ui.compose.IconButtons.IconButton
|
import org.signal.core.ui.compose.IconButtons.IconButton
|
||||||
import org.signal.core.ui.compose.LargeFontPreviews
|
import org.signal.core.ui.compose.LargeFontPreviews
|
||||||
@@ -284,7 +284,7 @@ private fun SearchBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScreenPreview() {
|
private fun ScreenPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
@@ -305,7 +305,7 @@ private fun ScreenPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoadingScreenPreview() {
|
private fun LoadingScreenPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.countrycode
|
package org.signal.registration.screens.countrycode
|
||||||
|
|
||||||
sealed interface CountryCodePickerScreenEvents {
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
data class Search(val query: String) : CountryCodePickerScreenEvents
|
|
||||||
data class CountrySelected(val country: Country) : CountryCodePickerScreenEvents
|
sealed class CountryCodePickerScreenEvents : DebugLoggableModel() {
|
||||||
data object Dismissed : CountryCodePickerScreenEvents
|
data class Search(val query: String) : CountryCodePickerScreenEvents()
|
||||||
|
data class CountrySelected(val country: Country) : CountryCodePickerScreenEvents()
|
||||||
|
data object Dismissed : CountryCodePickerScreenEvents()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.signal.core.ui.navigation.ResultEventBus
|
import org.signal.core.ui.navigation.ResultEventBus
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
import org.signal.registration.RegistrationFlowEvent
|
import org.signal.registration.RegistrationFlowEvent
|
||||||
import org.signal.registration.screens.util.navigateBack
|
import org.signal.registration.screens.util.navigateBack
|
||||||
|
|
||||||
@@ -29,6 +30,10 @@ class CountryCodePickerViewModel(
|
|||||||
initialCountry: Country? = null
|
initialCountry: Country? = null
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = Log.tag(CountryCodePickerViewModel::class)
|
||||||
|
}
|
||||||
|
|
||||||
private val _state = MutableStateFlow(CountryCodeState())
|
private val _state = MutableStateFlow(CountryCodeState())
|
||||||
val state: StateFlow<CountryCodeState> = _state.asStateFlow()
|
val state: StateFlow<CountryCodeState> = _state.asStateFlow()
|
||||||
|
|
||||||
@@ -37,6 +42,7 @@ class CountryCodePickerViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onEvent(event: CountryCodePickerScreenEvents) {
|
fun onEvent(event: CountryCodePickerScreenEvents) {
|
||||||
|
Log.d(TAG, "[Event] $event")
|
||||||
when (event) {
|
when (event) {
|
||||||
is CountryCodePickerScreenEvents.Search -> applySearchEvent(event.query)
|
is CountryCodePickerScreenEvents.Search -> applySearchEvent(event.query)
|
||||||
is CountryCodePickerScreenEvents.CountrySelected -> {
|
is CountryCodePickerScreenEvents.CountrySelected -> {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.countrycode
|
package org.signal.registration.screens.countrycode
|
||||||
|
|
||||||
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State managed by [CountryCodePickerViewModel]. Includes country list and allows for searching
|
* State managed by [CountryCodePickerViewModel]. Includes country list and allows for searching
|
||||||
*/
|
*/
|
||||||
@@ -14,4 +16,4 @@ data class CountryCodeState(
|
|||||||
val commonCountryList: List<Country> = emptyList(),
|
val commonCountryList: List<Country> = emptyList(),
|
||||||
val filteredList: List<Country> = emptyList(),
|
val filteredList: List<Country> = emptyList(),
|
||||||
val startingIndex: Int = 0
|
val startingIndex: Int = 0
|
||||||
)
|
) : DebugLoggableModel()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||||
import org.signal.core.ui.compose.Buttons
|
import org.signal.core.ui.compose.Buttons
|
||||||
import org.signal.core.ui.compose.DayNightPreviews
|
import org.signal.core.ui.compose.AllDevicePreviews
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
import org.signal.core.ui.compose.horizontalGutters
|
import org.signal.core.ui.compose.horizontalGutters
|
||||||
import org.signal.registration.R
|
import org.signal.registration.R
|
||||||
@@ -207,7 +207,7 @@ private fun PermissionRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun PermissionsScreenPreview() {
|
private fun PermissionsScreenPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import org.signal.core.ui.compose.Buttons
|
import org.signal.core.ui.compose.Buttons
|
||||||
import org.signal.core.ui.compose.CircularProgressWrapper
|
import org.signal.core.ui.compose.CircularProgressWrapper
|
||||||
import org.signal.core.ui.compose.DayNightPreviews
|
import org.signal.core.ui.compose.AllDevicePreviews
|
||||||
import org.signal.core.ui.compose.Dialogs
|
import org.signal.core.ui.compose.Dialogs
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
import org.signal.registration.R
|
import org.signal.registration.R
|
||||||
@@ -359,7 +359,7 @@ private fun DropdownTriangle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhoneNumberScreenPreview() {
|
private fun PhoneNumberScreenPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
@@ -370,7 +370,7 @@ private fun PhoneNumberScreenPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhoneNumberScreenSpinnerPreview() {
|
private fun PhoneNumberScreenSpinnerPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
|
|||||||
@@ -5,12 +5,18 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.phonenumber
|
package org.signal.registration.screens.phonenumber
|
||||||
|
|
||||||
sealed interface PhoneNumberEntryScreenEvents {
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents
|
|
||||||
data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents
|
sealed class PhoneNumberEntryScreenEvents : DebugLoggableModel() {
|
||||||
data class CountrySelected(val countryCode: Int, val regionCode: String, val countryName: String, val countryEmoji: String) : PhoneNumberEntryScreenEvents
|
data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents()
|
||||||
data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents
|
data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents()
|
||||||
data object CountryPicker : PhoneNumberEntryScreenEvents
|
data class CountrySelected(val countryCode: Int, val regionCode: String, val countryName: String, val countryEmoji: String) : PhoneNumberEntryScreenEvents()
|
||||||
data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents
|
data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents()
|
||||||
data object ConsumeOneTimeEvent : PhoneNumberEntryScreenEvents
|
data object CountryPicker : PhoneNumberEntryScreenEvents()
|
||||||
|
data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents() {
|
||||||
|
override fun toSafeString(): String {
|
||||||
|
return "CaptchaCompleted(token=***)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data object ConsumeOneTimeEvent : PhoneNumberEntryScreenEvents()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ package org.signal.registration.screens.phonenumber
|
|||||||
import org.signal.registration.NetworkController
|
import org.signal.registration.NetworkController
|
||||||
import org.signal.registration.NetworkController.SessionMetadata
|
import org.signal.registration.NetworkController.SessionMetadata
|
||||||
import org.signal.registration.PreExistingRegistrationData
|
import org.signal.registration.PreExistingRegistrationData
|
||||||
|
import org.signal.registration.util.DebugLoggable
|
||||||
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class PhoneNumberEntryState(
|
data class PhoneNumberEntryState(
|
||||||
@@ -23,8 +25,8 @@ data class PhoneNumberEntryState(
|
|||||||
val oneTimeEvent: OneTimeEvent? = null,
|
val oneTimeEvent: OneTimeEvent? = null,
|
||||||
val preExistingRegistrationData: PreExistingRegistrationData? = null,
|
val preExistingRegistrationData: PreExistingRegistrationData? = null,
|
||||||
val restoredSvrCredentials: List<NetworkController.SvrCredentials> = emptyList()
|
val restoredSvrCredentials: List<NetworkController.SvrCredentials> = emptyList()
|
||||||
) {
|
) : DebugLoggableModel() {
|
||||||
sealed interface OneTimeEvent {
|
sealed interface OneTimeEvent : DebugLoggable {
|
||||||
data object NetworkError : OneTimeEvent
|
data object NetworkError : OneTimeEvent
|
||||||
data object UnknownError : OneTimeEvent
|
data object UnknownError : OneTimeEvent
|
||||||
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
|
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class PhoneNumberEntryViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onEvent(event: PhoneNumberEntryScreenEvents) {
|
fun onEvent(event: PhoneNumberEntryScreenEvents) {
|
||||||
|
Log.d(TAG, "[Event] $event")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val stateEmitter: (PhoneNumberEntryState) -> Unit = { state ->
|
val stateEmitter: (PhoneNumberEntryState) -> Unit = { state ->
|
||||||
_state.value = state
|
_state.value = state
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.signal.core.ui.compose.DayNightPreviews
|
import org.signal.core.ui.compose.AllDevicePreviews
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
import org.signal.core.ui.compose.SignalIcons
|
import org.signal.core.ui.compose.SignalIcons
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ fun PinCreationScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun PinCreationScreenPreview() {
|
private fun PinCreationScreenPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
@@ -193,7 +193,7 @@ private fun PinCreationScreenPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun PinCreationScreenAlphanumericPreview() {
|
private fun PinCreationScreenAlphanumericPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.pincreation
|
package org.signal.registration.screens.pincreation
|
||||||
|
|
||||||
sealed class PinCreationScreenEvents {
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
|
|
||||||
|
sealed class PinCreationScreenEvents : DebugLoggableModel() {
|
||||||
data class PinSubmitted(val pin: String) : PinCreationScreenEvents()
|
data class PinSubmitted(val pin: String) : PinCreationScreenEvents()
|
||||||
data object ToggleKeyboard : PinCreationScreenEvents()
|
data object ToggleKeyboard : PinCreationScreenEvents()
|
||||||
data object LearnMore : PinCreationScreenEvents()
|
data object LearnMore : PinCreationScreenEvents()
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
package org.signal.registration.screens.pincreation
|
package org.signal.registration.screens.pincreation
|
||||||
|
|
||||||
import org.signal.core.models.AccountEntropyPool
|
import org.signal.core.models.AccountEntropyPool
|
||||||
|
import org.signal.registration.BuildConfig
|
||||||
|
import org.signal.registration.util.DebugLoggable
|
||||||
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
|
|
||||||
data class PinCreationState(
|
data class PinCreationState(
|
||||||
val isAlphanumericKeyboard: Boolean = false,
|
val isAlphanumericKeyboard: Boolean = false,
|
||||||
val inputLabel: String? = null,
|
val inputLabel: String? = null,
|
||||||
val isConfirmEnabled: Boolean = false,
|
val isConfirmEnabled: Boolean = false,
|
||||||
val accountEntropyPool: AccountEntropyPool? = null
|
val accountEntropyPool: AccountEntropyPool? = null
|
||||||
)
|
) : DebugLoggableModel()
|
||||||
@@ -51,6 +51,7 @@ class PinCreationViewModel(
|
|||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinCreationState(inputLabel = "PIN must be at least 4 digits"))
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinCreationState(inputLabel = "PIN must be at least 4 digits"))
|
||||||
|
|
||||||
fun onEvent(event: PinCreationScreenEvents) {
|
fun onEvent(event: PinCreationScreenEvents) {
|
||||||
|
Log.d(TAG, "[Event] $event")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
applyEvent(state.value, event)
|
applyEvent(state.value, event)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class PinEntryForRegistrationLockViewModel(
|
|||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
|
||||||
|
|
||||||
fun onEvent(event: PinEntryScreenEvents) {
|
fun onEvent(event: PinEntryScreenEvents) {
|
||||||
|
Log.d(TAG, "[Event] $event")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val stateEmitter: (PinEntryState) -> Unit = { state ->
|
val stateEmitter: (PinEntryState) -> Unit = { state ->
|
||||||
_state.value = state
|
_state.value = state
|
||||||
@@ -140,7 +141,10 @@ class PinEntryForRegistrationLockViewModel(
|
|||||||
val (response, keyMaterial) = registerResult.data
|
val (response, keyMaterial) = registerResult.data
|
||||||
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
|
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
|
||||||
// TODO storage service restore + profile screen
|
// TODO storage service restore + profile screen
|
||||||
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
|
when {
|
||||||
|
response.reregistration -> parentEventEmitter.navigateTo(RegistrationRoute.ChooseRestoreOptionAfterRegistration)
|
||||||
|
else -> parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
|
||||||
|
}
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
is NetworkController.RegistrationNetworkResult.Failure -> {
|
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class PinEntryForSmsBypassViewModel(
|
|||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
|
||||||
|
|
||||||
fun onEvent(event: PinEntryScreenEvents) {
|
fun onEvent(event: PinEntryScreenEvents) {
|
||||||
|
Log.d(TAG, "[Event] $event")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val stateEmitter: (PinEntryState) -> Unit = { _state.value = it }
|
val stateEmitter: (PinEntryState) -> Unit = { _state.value = it }
|
||||||
applyEvent(state.value, event, stateEmitter, parentEventEmitter)
|
applyEvent(state.value, event, stateEmitter, parentEventEmitter)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class PinEntryForSvrRestoreViewModel(
|
|||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
|
||||||
|
|
||||||
fun onEvent(event: PinEntryScreenEvents) {
|
fun onEvent(event: PinEntryScreenEvents) {
|
||||||
|
Log.d(TAG, "[Event] $event")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val stateEmitter: (PinEntryState) -> Unit = { state ->
|
val stateEmitter: (PinEntryState) -> Unit = { state ->
|
||||||
_state.value = state
|
_state.value = state
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import androidx.compose.ui.text.input.ImeAction
|
|||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.signal.core.ui.compose.DayNightPreviews
|
import org.signal.core.ui.compose.AllDevicePreviews
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
import org.signal.core.ui.compose.SignalIcons
|
import org.signal.core.ui.compose.SignalIcons
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ fun PinEntryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun PinEntryScreenPreview() {
|
private fun PinEntryScreenPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
@@ -207,7 +207,7 @@ private fun PinEntryScreenPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun PinEntryScreenWithErrorPreview() {
|
private fun PinEntryScreenWithErrorPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.pinentry
|
package org.signal.registration.screens.pinentry
|
||||||
|
|
||||||
sealed class PinEntryScreenEvents {
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
|
|
||||||
|
sealed class PinEntryScreenEvents : DebugLoggableModel() {
|
||||||
data class PinEntered(val pin: String) : PinEntryScreenEvents()
|
data class PinEntered(val pin: String) : PinEntryScreenEvents()
|
||||||
data object ToggleKeyboard : PinEntryScreenEvents()
|
data object ToggleKeyboard : PinEntryScreenEvents()
|
||||||
data object NeedHelp : PinEntryScreenEvents()
|
data object NeedHelp : PinEntryScreenEvents()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.pinentry
|
package org.signal.registration.screens.pinentry
|
||||||
|
|
||||||
|
import org.signal.registration.util.DebugLoggable
|
||||||
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class PinEntryState(
|
data class PinEntryState(
|
||||||
@@ -15,14 +17,14 @@ data class PinEntryState(
|
|||||||
val mode: Mode = Mode.SvrRestore,
|
val mode: Mode = Mode.SvrRestore,
|
||||||
val oneTimeEvent: OneTimeEvent? = null,
|
val oneTimeEvent: OneTimeEvent? = null,
|
||||||
val e164: String? = null
|
val e164: String? = null
|
||||||
) {
|
) : DebugLoggableModel() {
|
||||||
enum class Mode {
|
enum class Mode {
|
||||||
RegistrationLock,
|
RegistrationLock,
|
||||||
SmsBypass,
|
SmsBypass,
|
||||||
SvrRestore
|
SvrRestore
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface OneTimeEvent {
|
sealed interface OneTimeEvent : DebugLoggable {
|
||||||
data object NetworkError : OneTimeEvent
|
data object NetworkError : OneTimeEvent
|
||||||
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
|
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
|
||||||
data object SvrDataMissing : OneTimeEvent
|
data object SvrDataMissing : OneTimeEvent
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.signal.registration.screens.quickrestore
|
||||||
|
|
||||||
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
|
|
||||||
|
sealed class QuickRestoreQrEvents : DebugLoggableModel() {
|
||||||
|
data object RetryQrCode : QuickRestoreQrEvents()
|
||||||
|
data object Cancel : QuickRestoreQrEvents()
|
||||||
|
data object UseProxy : QuickRestoreQrEvents()
|
||||||
|
data object DismissError : QuickRestoreQrEvents()
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.signal.registration.screens.restore
|
package org.signal.registration.screens.quickrestore
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -38,7 +38,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.signal.core.ui.compose.DayNightPreviews
|
import org.signal.core.ui.compose.AllDevicePreviews
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
import org.signal.core.ui.compose.QrCode
|
import org.signal.core.ui.compose.QrCode
|
||||||
import org.signal.core.ui.compose.QrCodeData
|
import org.signal.core.ui.compose.QrCodeData
|
||||||
@@ -49,9 +49,9 @@ import org.signal.core.ui.compose.SignalIcons
|
|||||||
* The old device scans this QR code to initiate the transfer.
|
* The old device scans this QR code to initiate the transfer.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun RestoreViaQrScreen(
|
fun QuickRestoreQrScreen(
|
||||||
state: RestoreViaQrState,
|
state: QuickRestoreQrState,
|
||||||
onEvent: (RestoreViaQrScreenEvents) -> Unit,
|
onEvent: (QuickRestoreQrEvents) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
@@ -123,7 +123,7 @@ fun RestoreViaQrScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) {
|
Button(onClick = { onEvent(QuickRestoreQrEvents.RetryQrCode) }) {
|
||||||
Text("Retry")
|
Text("Retry")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,7 @@ fun RestoreViaQrScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) {
|
Button(onClick = { onEvent(QuickRestoreQrEvents.RetryQrCode) }) {
|
||||||
Text("Retry")
|
Text("Retry")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@ fun RestoreViaQrScreen(
|
|||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { onEvent(RestoreViaQrScreenEvents.Cancel) }
|
onClick = { onEvent(QuickRestoreQrEvents.Cancel) }
|
||||||
) {
|
) {
|
||||||
Text("Cancel")
|
Text("Cancel")
|
||||||
}
|
}
|
||||||
@@ -208,9 +208,9 @@ fun RestoreViaQrScreen(
|
|||||||
// Error dialog
|
// Error dialog
|
||||||
if (state.showRegistrationError) {
|
if (state.showRegistrationError) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { onEvent(RestoreViaQrScreenEvents.DismissError) },
|
onDismissRequest = { onEvent(QuickRestoreQrEvents.DismissError) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = { onEvent(RestoreViaQrScreenEvents.DismissError) }) {
|
TextButton(onClick = { onEvent(QuickRestoreQrEvents.DismissError) }) {
|
||||||
Text("OK")
|
Text("OK")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -246,23 +246,23 @@ private fun InstructionRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun RestoreViaQrScreenLoadingPreview() {
|
private fun QuickRestoreQrScreenLoadingPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
RestoreViaQrScreen(
|
QuickRestoreQrScreen(
|
||||||
state = RestoreViaQrState(qrState = QrState.Loading),
|
state = QuickRestoreQrState(qrState = QrState.Loading),
|
||||||
onEvent = {}
|
onEvent = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun RestoreViaQrScreenLoadedPreview() {
|
private fun QuickRestoreQrScreenLoadedPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
RestoreViaQrScreen(
|
QuickRestoreQrScreen(
|
||||||
state = RestoreViaQrState(
|
state = QuickRestoreQrState(
|
||||||
qrState = QrState.Loaded(QrCodeData.forData("sgnl://rereg?uuid=test&pub_key=test", false))
|
qrState = QrState.Loaded(QrCodeData.forData("sgnl://rereg?uuid=test&pub_key=test", false))
|
||||||
),
|
),
|
||||||
onEvent = {}
|
onEvent = {}
|
||||||
@@ -270,23 +270,23 @@ private fun RestoreViaQrScreenLoadedPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun RestoreViaQrScreenFailedPreview() {
|
private fun QuickRestoreQrScreenFailedPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
RestoreViaQrScreen(
|
QuickRestoreQrScreen(
|
||||||
state = RestoreViaQrState(qrState = QrState.Failed),
|
state = QuickRestoreQrState(qrState = QrState.Failed),
|
||||||
onEvent = {}
|
onEvent = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun RestoreViaQrScreenRegisteringPreview() {
|
private fun QuickRestoreQrScreenRegisteringPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
RestoreViaQrScreen(
|
QuickRestoreQrScreen(
|
||||||
state = RestoreViaQrState(
|
state = QuickRestoreQrState(
|
||||||
qrState = QrState.Scanned,
|
qrState = QrState.Scanned,
|
||||||
isRegistering = true
|
isRegistering = true
|
||||||
),
|
),
|
||||||
@@ -3,20 +3,26 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.signal.registration.screens.restore
|
package org.signal.registration.screens.quickrestore
|
||||||
|
|
||||||
import org.signal.core.ui.compose.QrCodeData
|
import org.signal.core.ui.compose.QrCodeData
|
||||||
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
|
|
||||||
sealed class QrState {
|
data class QuickRestoreQrState(
|
||||||
data object Loading : QrState()
|
|
||||||
data class Loaded(val qrCodeData: QrCodeData) : QrState()
|
|
||||||
data object Scanned : QrState()
|
|
||||||
data object Failed : QrState()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class RestoreViaQrState(
|
|
||||||
val qrState: QrState = QrState.Loading,
|
val qrState: QrState = QrState.Loading,
|
||||||
val isRegistering: Boolean = false,
|
val isRegistering: Boolean = false,
|
||||||
val showRegistrationError: Boolean = false,
|
val showRegistrationError: Boolean = false,
|
||||||
val errorMessage: String? = null
|
val errorMessage: String? = null
|
||||||
)
|
) : DebugLoggableModel()
|
||||||
|
|
||||||
|
sealed class QrState : DebugLoggableModel() {
|
||||||
|
data object Loading : QrState()
|
||||||
|
data class Loaded(val qrCodeData: QrCodeData) : QrState() {
|
||||||
|
override fun toSafeString(): String {
|
||||||
|
return "Loaded(qrCodeData=***)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data object Scanned : QrState()
|
||||||
|
data object Failed : QrState()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.signal.registration.screens.quickrestore
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.signal.core.ui.compose.QrCodeData
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
|
import org.signal.registration.NetworkController
|
||||||
|
import org.signal.registration.RegistrationFlowEvent
|
||||||
|
import org.signal.registration.RegistrationRepository
|
||||||
|
import org.signal.registration.RegistrationRoute
|
||||||
|
import org.signal.registration.screens.util.navigateBack
|
||||||
|
import org.signal.registration.screens.util.navigateTo
|
||||||
|
|
||||||
|
class QuickRestoreQrViewModel(
|
||||||
|
private val repository: RegistrationRepository,
|
||||||
|
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = Log.tag(QuickRestoreQrViewModel::class)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _localState = MutableStateFlow(QuickRestoreQrState())
|
||||||
|
val state: StateFlow<QuickRestoreQrState> = _localState.asStateFlow()
|
||||||
|
|
||||||
|
private var provisioningJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
startProvisioning()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: QuickRestoreQrEvents) {
|
||||||
|
Log.d(TAG, "[Event] $event")
|
||||||
|
viewModelScope.launch {
|
||||||
|
val stateEmitter: (QuickRestoreQrState) -> Unit = { newState ->
|
||||||
|
_localState.value = newState
|
||||||
|
}
|
||||||
|
applyEvent(state.value, event, stateEmitter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
suspend fun applyEvent(state: QuickRestoreQrState, event: QuickRestoreQrEvents, stateEmitter: (QuickRestoreQrState) -> Unit) {
|
||||||
|
val result = when (event) {
|
||||||
|
is QuickRestoreQrEvents.RetryQrCode -> {
|
||||||
|
startProvisioning()
|
||||||
|
state.copy(qrState = QrState.Loading, showRegistrationError = false, errorMessage = null)
|
||||||
|
}
|
||||||
|
is QuickRestoreQrEvents.Cancel -> {
|
||||||
|
parentEventEmitter.navigateBack()
|
||||||
|
state
|
||||||
|
}
|
||||||
|
is QuickRestoreQrEvents.UseProxy -> {
|
||||||
|
// TODO [registration] - Navigate to proxy settings
|
||||||
|
state
|
||||||
|
}
|
||||||
|
is QuickRestoreQrEvents.DismissError -> {
|
||||||
|
startProvisioning()
|
||||||
|
state.copy(showRegistrationError = false, errorMessage = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stateEmitter(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startProvisioning() {
|
||||||
|
provisioningJob?.cancel()
|
||||||
|
provisioningJob = viewModelScope.launch {
|
||||||
|
repository.startProvisioning().collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is NetworkController.ProvisioningEvent.QrCodeReady -> {
|
||||||
|
Log.d(TAG, "[Provisioning] QR code ready")
|
||||||
|
_localState.value = _localState.value.copy(
|
||||||
|
qrState = QrState.Loaded(
|
||||||
|
qrCodeData = QrCodeData.forData(data = event.url, supportIconOverlay = false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is NetworkController.ProvisioningEvent.MessageReceived -> {
|
||||||
|
Log.i(TAG, "[Provisioning] Message received from old device (platform: ${event.message.platform}, tier: ${event.message.tier})")
|
||||||
|
handleProvisioningMessage(event.message)
|
||||||
|
}
|
||||||
|
is NetworkController.ProvisioningEvent.Error -> {
|
||||||
|
Log.w(TAG, "[Provisioning] Error", event.cause)
|
||||||
|
_localState.value = _localState.value.copy(qrState = QrState.Failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleProvisioningMessage(message: NetworkController.ProvisioningMessage) {
|
||||||
|
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.ChooseRestoreOptionBeforeRegistration)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_localState.value = _localState.value.copy(isRegistering = true, qrState = QrState.Scanned)
|
||||||
|
|
||||||
|
val registerResult = repository.registerAccountWithProvisioningData(message)
|
||||||
|
|
||||||
|
when (registerResult) {
|
||||||
|
is NetworkController.RegistrationNetworkResult.Success -> {
|
||||||
|
val (response, keyMaterial) = registerResult.data
|
||||||
|
Log.i(TAG, "[Register] Success! reregistration: ${response.reregistration}")
|
||||||
|
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
|
||||||
|
parentEventEmitter.navigateTo(RegistrationRoute.ChooseRestoreOptionAfterRegistration)
|
||||||
|
}
|
||||||
|
is NetworkController.RegistrationNetworkResult.Failure -> {
|
||||||
|
when (registerResult.error) {
|
||||||
|
is NetworkController.RegisterAccountError.RateLimited -> {
|
||||||
|
Log.w(TAG, "[Register] Rate limited (retryAfter: ${registerResult.error.retryAfter}).")
|
||||||
|
_localState.value = _localState.value.copy(
|
||||||
|
isRegistering = false,
|
||||||
|
showRegistrationError = true,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is NetworkController.RegisterAccountError.RegistrationRecoveryPasswordIncorrect -> {
|
||||||
|
Log.w(TAG, "[Register] Recovery password incorrect: ${registerResult.error.message}")
|
||||||
|
_localState.value = _localState.value.copy(
|
||||||
|
isRegistering = false,
|
||||||
|
showRegistrationError = true,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is NetworkController.RegisterAccountError.RegistrationLock -> {
|
||||||
|
Log.w(TAG, "[Register] Registration locked.")
|
||||||
|
parentEventEmitter.navigateTo(
|
||||||
|
RegistrationRoute.PinEntryForRegistrationLock(
|
||||||
|
timeRemaining = registerResult.error.data.timeRemaining,
|
||||||
|
svrCredentials = registerResult.error.data.svr2Credentials
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is NetworkController.RegisterAccountError.SessionNotFoundOrNotVerified -> {
|
||||||
|
Log.w(TAG, "[Register] Session not found or not verified: ${registerResult.error.message}")
|
||||||
|
_localState.value = _localState.value.copy(
|
||||||
|
isRegistering = false,
|
||||||
|
showRegistrationError = true,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is NetworkController.RegisterAccountError.DeviceTransferPossible -> {
|
||||||
|
Log.w(TAG, "[Register] Device transfer possible. Resetting.")
|
||||||
|
parentEventEmitter(RegistrationFlowEvent.ResetState)
|
||||||
|
}
|
||||||
|
is NetworkController.RegisterAccountError.InvalidRequest -> {
|
||||||
|
Log.w(TAG, "[Register] Invalid request: ${registerResult.error.message}")
|
||||||
|
_localState.value = _localState.value.copy(
|
||||||
|
isRegistering = false,
|
||||||
|
showRegistrationError = true,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NetworkController.RegistrationNetworkResult.NetworkError -> {
|
||||||
|
Log.w(TAG, "[Register] Network error.", registerResult.exception)
|
||||||
|
_localState.value = _localState.value.copy(
|
||||||
|
isRegistering = false,
|
||||||
|
showRegistrationError = true,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
|
||||||
|
Log.w(TAG, "[Register] Application error.", registerResult.exception)
|
||||||
|
_localState.value = _localState.value.copy(
|
||||||
|
isRegistering = false,
|
||||||
|
showRegistrationError = true,
|
||||||
|
errorMessage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
provisioningJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory(
|
||||||
|
private val repository: RegistrationRepository,
|
||||||
|
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return QuickRestoreQrViewModel(repository, parentEventEmitter) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2025 Signal Messenger, LLC
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.signal.registration.screens.restore
|
|
||||||
|
|
||||||
sealed class RestoreViaQrScreenEvents {
|
|
||||||
data object RetryQrCode : RestoreViaQrScreenEvents()
|
|
||||||
data object Cancel : RestoreViaQrScreenEvents()
|
|
||||||
data object UseProxy : RestoreViaQrScreenEvents()
|
|
||||||
data object DismissError : RestoreViaQrScreenEvents()
|
|
||||||
}
|
|
||||||
@@ -50,7 +50,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.signal.core.ui.compose.DayNightPreviews
|
import org.signal.core.ui.compose.AllDevicePreviews
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
import org.signal.registration.R
|
import org.signal.registration.R
|
||||||
import org.signal.registration.test.TestTags
|
import org.signal.registration.test.TestTags
|
||||||
@@ -438,7 +438,7 @@ private fun DigitField(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun VerificationCodeScreenPreview() {
|
private fun VerificationCodeScreenPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
@@ -451,7 +451,7 @@ private fun VerificationCodeScreenPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun VerificationCodeScreenWithCountdownPreview() {
|
private fun VerificationCodeScreenWithCountdownPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
@@ -468,7 +468,7 @@ private fun VerificationCodeScreenWithCountdownPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun VerificationCodeScreenSubmittingPreview() {
|
private fun VerificationCodeScreenSubmittingPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.verificationcode
|
package org.signal.registration.screens.verificationcode
|
||||||
|
|
||||||
sealed class VerificationCodeScreenEvents {
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
|
|
||||||
|
sealed class VerificationCodeScreenEvents : DebugLoggableModel() {
|
||||||
data class CodeEntered(val code: String) : VerificationCodeScreenEvents()
|
data class CodeEntered(val code: String) : VerificationCodeScreenEvents()
|
||||||
data object WrongNumber : VerificationCodeScreenEvents()
|
data object WrongNumber : VerificationCodeScreenEvents()
|
||||||
data object ResendSms : VerificationCodeScreenEvents()
|
data object ResendSms : VerificationCodeScreenEvents()
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
package org.signal.registration.screens.verificationcode
|
package org.signal.registration.screens.verificationcode
|
||||||
|
|
||||||
import org.signal.registration.NetworkController.SessionMetadata
|
import org.signal.registration.NetworkController.SessionMetadata
|
||||||
|
import org.signal.registration.util.DebugLoggable
|
||||||
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@@ -16,8 +18,8 @@ data class VerificationCodeState(
|
|||||||
val rateLimits: SmsAndCallRateLimits = SmsAndCallRateLimits(),
|
val rateLimits: SmsAndCallRateLimits = SmsAndCallRateLimits(),
|
||||||
val incorrectCodeAttempts: Int = 0,
|
val incorrectCodeAttempts: Int = 0,
|
||||||
val oneTimeEvent: OneTimeEvent? = null
|
val oneTimeEvent: OneTimeEvent? = null
|
||||||
) {
|
) : DebugLoggableModel() {
|
||||||
sealed interface OneTimeEvent {
|
sealed interface OneTimeEvent : DebugLoggable {
|
||||||
data object NetworkError : OneTimeEvent
|
data object NetworkError : OneTimeEvent
|
||||||
data object UnknownError : OneTimeEvent
|
data object UnknownError : OneTimeEvent
|
||||||
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
|
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
|
||||||
@@ -50,4 +52,4 @@ data class VerificationCodeState(
|
|||||||
data class SmsAndCallRateLimits(
|
data class SmsAndCallRateLimits(
|
||||||
val smsResendTimeRemaining: Duration = 0.seconds,
|
val smsResendTimeRemaining: Duration = 0.seconds,
|
||||||
val callRequestTimeRemaining: Duration = 0.seconds
|
val callRequestTimeRemaining: Duration = 0.seconds
|
||||||
)
|
) : DebugLoggableModel()
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class VerificationCodeViewModel(
|
|||||||
private var nextCallAvailableAt: Duration = 0.seconds
|
private var nextCallAvailableAt: Duration = 0.seconds
|
||||||
|
|
||||||
fun onEvent(event: VerificationCodeScreenEvents) {
|
fun onEvent(event: VerificationCodeScreenEvents) {
|
||||||
|
Log.d(TAG, "[Event] $event")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val stateEmitter: (VerificationCodeState) -> Unit = { newState ->
|
val stateEmitter: (VerificationCodeState) -> Unit = { newState ->
|
||||||
_localState.value = newState
|
_localState.value = newState
|
||||||
@@ -179,10 +180,10 @@ class VerificationCodeViewModel(
|
|||||||
|
|
||||||
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
|
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
|
||||||
|
|
||||||
if (response.storageCapable) {
|
when {
|
||||||
parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore)
|
// response.reregistration -> parentEventEmitter.navigateTo(RegistrationRoute.ChooseRestoreOptionAfterRegistration)
|
||||||
} else {
|
response.storageCapable -> parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore)
|
||||||
parentEventEmitter.navigateTo(RegistrationRoute.PinCreate)
|
else -> parentEventEmitter.navigateTo(RegistrationRoute.PinCreate)
|
||||||
}
|
}
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import org.signal.core.ui.compose.BottomSheets
|
import org.signal.core.ui.compose.BottomSheets
|
||||||
import org.signal.core.ui.compose.Buttons
|
import org.signal.core.ui.compose.Buttons
|
||||||
import org.signal.core.ui.compose.DayNightPreviews
|
import org.signal.core.ui.compose.AllDevicePreviews
|
||||||
import org.signal.core.ui.compose.Previews
|
import org.signal.core.ui.compose.Previews
|
||||||
import org.signal.core.ui.compose.SignalIcons
|
import org.signal.core.ui.compose.SignalIcons
|
||||||
import org.signal.core.ui.compose.dismissWithAnimation
|
import org.signal.core.ui.compose.dismissWithAnimation
|
||||||
@@ -255,7 +255,7 @@ private fun RestoreActionRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun WelcomeScreenPreview() {
|
private fun WelcomeScreenPreview() {
|
||||||
Previews.Preview {
|
Previews.Preview {
|
||||||
@@ -263,7 +263,7 @@ private fun WelcomeScreenPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DayNightPreviews
|
@AllDevicePreviews
|
||||||
@Composable
|
@Composable
|
||||||
private fun RestoreOrTransferBottomSheetPreview() {
|
private fun RestoreOrTransferBottomSheetPreview() {
|
||||||
Previews.BottomSheetPreview {
|
Previews.BottomSheetPreview {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
package org.signal.registration.screens.welcome
|
package org.signal.registration.screens.welcome
|
||||||
|
|
||||||
sealed class WelcomeScreenEvents {
|
import org.signal.registration.util.DebugLoggableModel
|
||||||
|
|
||||||
|
sealed class WelcomeScreenEvents : DebugLoggableModel() {
|
||||||
data object Continue : WelcomeScreenEvents()
|
data object Continue : WelcomeScreenEvents()
|
||||||
data object HasOldPhone : WelcomeScreenEvents()
|
data object HasOldPhone : WelcomeScreenEvents()
|
||||||
data object DoesNotHaveOldPhone : WelcomeScreenEvents()
|
data object DoesNotHaveOldPhone : WelcomeScreenEvents()
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2025 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.signal.registration.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for objects that can provide a debug-friendly string representation.
|
||||||
|
*/
|
||||||
|
interface DebugLoggable {
|
||||||
|
fun toDebugString(): String = toString()
|
||||||
|
fun toSafeString(): String = toString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.signal.registration.util
|
||||||
|
|
||||||
|
import org.signal.registration.BuildConfig
|
||||||
|
|
||||||
|
open class DebugLoggableModel : DebugLoggable {
|
||||||
|
override fun toString(): String {
|
||||||
|
return if (BuildConfig.DEBUG) {
|
||||||
|
toDebugString()
|
||||||
|
} else {
|
||||||
|
toSafeString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user