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:
Greyson Parrelli
2026-03-12 13:42:15 -04:00
committed by Michelle Tang
parent 889ebcadd4
commit 39de824bf0
59 changed files with 800 additions and 143 deletions

View File

@@ -10,6 +10,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
testOptions {

View File

@@ -6,12 +6,14 @@
package org.signal.registration
import android.os.Parcelable
import kotlinx.coroutines.flow.Flow
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.signal.core.models.MasterKey
import org.signal.core.util.serialization.ByteArrayToBase64Serializer
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.SignedPreKeyRecord
import java.io.IOException
@@ -179,6 +181,19 @@ interface NetworkController {
*/
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.
// */
@@ -431,4 +446,38 @@ interface NetworkController {
enum class VerificationCodeTransport {
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
}
}

View File

@@ -7,8 +7,9 @@ package org.signal.registration
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.registration.util.DebugLoggable
sealed interface RegistrationFlowEvent {
sealed interface RegistrationFlowEvent : DebugLoggable {
/** Navigate to a specific screen. */
data class NavigateToScreen(val route: RegistrationRoute) : RegistrationFlowEvent

View File

@@ -11,6 +11,7 @@ import kotlinx.parcelize.TypeParceler
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.registration.util.AccountEntropyPoolParceler
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.MasterKeyParceler
@Parcelize
@@ -37,4 +38,4 @@ data class RegistrationFlowState(
/** If true, do not attempt any flows where we generate RRP's. Create a session instead. */
val doNotAttemptRecoveryPassword: Boolean = false
) : Parcelable
) : Parcelable, DebugLoggable

View File

@@ -30,6 +30,9 @@ import org.signal.core.ui.navigation.TransitionSpecs
import org.signal.registration.screens.accountlocked.AccountLockedScreen
import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents
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.CaptchaScreenEvents
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.PinEntryForSvrRestoreViewModel
import org.signal.registration.screens.pinentry.PinEntryScreen
import org.signal.registration.screens.restore.RestoreViaQrScreen
import org.signal.registration.screens.restore.RestoreViaQrScreenEvents
import org.signal.registration.screens.restore.RestoreViaQrState
import org.signal.registration.screens.quickrestore.QuickRestoreQrScreen
import org.signal.registration.screens.quickrestore.QuickRestoreQrViewModel
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
import org.signal.registration.screens.verificationcode.VerificationCodeScreen
@@ -99,6 +101,10 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
@Serializable
data object PinCreate : RegistrationRoute
// TODO [regV5] Uncomment when restore selection flow is ready
// @Serializable
// data object ArchiveRestoreSelection : RegistrationRoute
@Serializable
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> {
// TODO: Implement RestoreScreen
}
entry<RegistrationRoute.QuickRestoreQrScan> {
RestoreViaQrScreen(
state = RestoreViaQrState(),
onEvent = { event ->
when (event) {
RestoreViaQrScreenEvents.RetryQrCode -> {
// TODO: Retry QR code generation
}
RestoreViaQrScreenEvents.Cancel -> {
parentEventEmitter.navigateBack()
}
RestoreViaQrScreenEvents.UseProxy -> {
// TODO: Navigate to proxy settings
}
RestoreViaQrScreenEvents.DismissError -> {
// TODO: Clear error state
}
}
}
val viewModel: QuickRestoreQrViewModel = viewModel(
factory = QuickRestoreQrViewModel.Factory(
repository = registrationRepository,
parentEventEmitter = registrationViewModel::onEvent
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
QuickRestoreQrScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}

View File

@@ -8,15 +8,19 @@ package org.signal.registration
import android.app.backup.BackupManager
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.MasterKeyResponse
import org.signal.registration.NetworkController.PreKeyCollection
import org.signal.registration.NetworkController.ProvisioningEvent
import org.signal.registration.NetworkController.RegisterAccountError
import org.signal.registration.NetworkController.RegisterAccountResponse
import org.signal.registration.NetworkController.RegistrationNetworkResult
@@ -155,7 +159,16 @@ class RegistrationRepository(val context: Context, val networkController: Networ
skipDeviceTransfer: Boolean = true,
preExistingRegistrationData: PreExistingRegistrationData? = null
): 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)
}
/**
* 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.
*
@@ -205,17 +254,19 @@ class RegistrationRepository(val context: Context, val networkController: Networ
recoveryPassword: String?,
registrationLock: String? = null,
skipDeviceTransfer: Boolean = true,
preExistingRegistrationData: PreExistingRegistrationData? = null
existingAccountEntropyPool: AccountEntropyPool? = null,
existingAciIdentityKeyPair: IdentityKeyPair? = null,
existingPniIdentityKeyPair: IdentityKeyPair? = null
): 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, 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(
existingAccountEntropyPool = preExistingRegistrationData?.aep,
existingAciIdentityKeyPair = preExistingRegistrationData?.aciIdentityKeyPair,
existingPniIdentityKeyPair = preExistingRegistrationData?.pniIdentityKeyPair
existingAccountEntropyPool = existingAccountEntropyPool,
existingAciIdentityKeyPair = existingAciIdentityKeyPair,
existingPniIdentityKeyPair = existingPniIdentityKeyPair
)
val fcmToken = networkController.getFcmToken()

View File

@@ -45,6 +45,7 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
}
fun onEvent(event: RegistrationFlowEvent) {
Log.d(TAG, "[Event] $event")
_state.value = applyEvent(_state.value, event)
}

View File

@@ -85,6 +85,15 @@ interface StorageController {
*/
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.
*/

View File

@@ -21,7 +21,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
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
/**
@@ -86,7 +86,7 @@ fun AccountLockedScreen(
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun AccountLockedScreenPreview() {
Previews.Preview {

View File

@@ -5,7 +5,9 @@
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 LearnMore : AccountLockedScreenEvents()
}

View File

@@ -5,6 +5,8 @@
package org.signal.registration.screens.accountlocked
import org.signal.registration.util.DebugLoggableModel
data class AccountLockedState(
val daysRemaining: Int = 10
)
) : DebugLoggableModel()

View File

@@ -28,7 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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
/**
@@ -133,7 +133,7 @@ fun CaptchaScreen(
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun CaptchaScreenLoadingPreview() {
Previews.Preview {
@@ -147,7 +147,7 @@ private fun CaptchaScreenLoadingPreview() {
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun CaptchaScreenErrorPreview() {
Previews.Preview {

View File

@@ -5,7 +5,14 @@
package org.signal.registration.screens.captcha
sealed class CaptchaScreenEvents {
data class CaptchaCompleted(val token: String) : CaptchaScreenEvents()
import org.signal.core.util.censor
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()
}

View File

@@ -5,7 +5,9 @@
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 Loaded : CaptchaLoadState()
data object Error : CaptchaLoadState()
@@ -15,4 +17,4 @@ data class CaptchaState(
val captchaUrl: String,
val captchaScheme: String = "signalcaptcha://",
val loadState: CaptchaLoadState = CaptchaLoadState.Loading
)
) : DebugLoggableModel()

View File

@@ -49,7 +49,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
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.IconButtons.IconButton
import org.signal.core.ui.compose.LargeFontPreviews
@@ -284,7 +284,7 @@ private fun SearchBar(
)
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun ScreenPreview() {
Previews.Preview {
@@ -305,7 +305,7 @@ private fun ScreenPreview() {
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun LoadingScreenPreview() {
Previews.Preview {

View File

@@ -5,8 +5,10 @@
package org.signal.registration.screens.countrycode
sealed interface CountryCodePickerScreenEvents {
data class Search(val query: String) : CountryCodePickerScreenEvents
data class CountrySelected(val country: Country) : CountryCodePickerScreenEvents
data object Dismissed : CountryCodePickerScreenEvents
import org.signal.registration.util.DebugLoggableModel
sealed class CountryCodePickerScreenEvents : DebugLoggableModel() {
data class Search(val query: String) : CountryCodePickerScreenEvents()
data class CountrySelected(val country: Country) : CountryCodePickerScreenEvents()
data object Dismissed : CountryCodePickerScreenEvents()
}

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.ui.navigation.ResultEventBus
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.screens.util.navigateBack
@@ -29,6 +30,10 @@ class CountryCodePickerViewModel(
initialCountry: Country? = null
) : ViewModel() {
companion object {
private val TAG = Log.tag(CountryCodePickerViewModel::class)
}
private val _state = MutableStateFlow(CountryCodeState())
val state: StateFlow<CountryCodeState> = _state.asStateFlow()
@@ -37,6 +42,7 @@ class CountryCodePickerViewModel(
}
fun onEvent(event: CountryCodePickerScreenEvents) {
Log.d(TAG, "[Event] $event")
when (event) {
is CountryCodePickerScreenEvents.Search -> applySearchEvent(event.query)
is CountryCodePickerScreenEvents.CountrySelected -> {

View File

@@ -5,6 +5,8 @@
package org.signal.registration.screens.countrycode
import org.signal.registration.util.DebugLoggableModel
/**
* State managed by [CountryCodePickerViewModel]. Includes country list and allows for searching
*/
@@ -14,4 +16,4 @@ data class CountryCodeState(
val commonCountryList: List<Country> = emptyList(),
val filteredList: List<Country> = emptyList(),
val startingIndex: Int = 0
)
) : DebugLoggableModel()

View File

@@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
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.horizontalGutters
import org.signal.registration.R
@@ -207,7 +207,7 @@ private fun PermissionRow(
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun PermissionsScreenPreview() {
Previews.Preview {

View File

@@ -48,7 +48,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.Buttons
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.Previews
import org.signal.registration.R
@@ -359,7 +359,7 @@ private fun DropdownTriangle(
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun PhoneNumberScreenPreview() {
Previews.Preview {
@@ -370,7 +370,7 @@ private fun PhoneNumberScreenPreview() {
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun PhoneNumberScreenSpinnerPreview() {
Previews.Preview {

View File

@@ -5,12 +5,18 @@
package org.signal.registration.screens.phonenumber
sealed interface PhoneNumberEntryScreenEvents {
data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents
data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents
data class CountrySelected(val countryCode: Int, val regionCode: String, val countryName: String, val countryEmoji: String) : PhoneNumberEntryScreenEvents
data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents
data object CountryPicker : PhoneNumberEntryScreenEvents
data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents
data object ConsumeOneTimeEvent : PhoneNumberEntryScreenEvents
import org.signal.registration.util.DebugLoggableModel
sealed class PhoneNumberEntryScreenEvents : DebugLoggableModel() {
data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents()
data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents()
data class CountrySelected(val countryCode: Int, val regionCode: String, val countryName: String, val countryEmoji: String) : PhoneNumberEntryScreenEvents()
data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents()
data object CountryPicker : PhoneNumberEntryScreenEvents()
data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents() {
override fun toSafeString(): String {
return "CaptchaCompleted(token=***)"
}
}
data object ConsumeOneTimeEvent : PhoneNumberEntryScreenEvents()
}

View File

@@ -8,6 +8,8 @@ package org.signal.registration.screens.phonenumber
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.PreExistingRegistrationData
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.DebugLoggableModel
import kotlin.time.Duration
data class PhoneNumberEntryState(
@@ -23,8 +25,8 @@ data class PhoneNumberEntryState(
val oneTimeEvent: OneTimeEvent? = null,
val preExistingRegistrationData: PreExistingRegistrationData? = null,
val restoredSvrCredentials: List<NetworkController.SvrCredentials> = emptyList()
) {
sealed interface OneTimeEvent {
) : DebugLoggableModel() {
sealed interface OneTimeEvent : DebugLoggable {
data object NetworkError : OneTimeEvent
data object UnknownError : OneTimeEvent
data class RateLimited(val retryAfter: Duration) : OneTimeEvent

View File

@@ -57,6 +57,7 @@ class PhoneNumberEntryViewModel(
}
fun onEvent(event: PhoneNumberEntryScreenEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch {
val stateEmitter: (PhoneNumberEntryState) -> Unit = { state ->
_state.value = state

View File

@@ -42,7 +42,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
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.SignalIcons
@@ -180,7 +180,7 @@ fun PinCreationScreen(
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun PinCreationScreenPreview() {
Previews.Preview {
@@ -193,7 +193,7 @@ private fun PinCreationScreenPreview() {
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun PinCreationScreenAlphanumericPreview() {
Previews.Preview {

View File

@@ -5,7 +5,9 @@
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 object ToggleKeyboard : PinCreationScreenEvents()
data object LearnMore : PinCreationScreenEvents()

View File

@@ -6,10 +6,13 @@
package org.signal.registration.screens.pincreation
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(
val isAlphanumericKeyboard: Boolean = false,
val inputLabel: String? = null,
val isConfirmEnabled: Boolean = false,
val accountEntropyPool: AccountEntropyPool? = null
)
) : DebugLoggableModel()

View File

@@ -51,6 +51,7 @@ class PinCreationViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinCreationState(inputLabel = "PIN must be at least 4 digits"))
fun onEvent(event: PinCreationScreenEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch {
applyEvent(state.value, event)
}

View File

@@ -53,6 +53,7 @@ class PinEntryForRegistrationLockViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
fun onEvent(event: PinEntryScreenEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch {
val stateEmitter: (PinEntryState) -> Unit = { state ->
_state.value = state
@@ -140,7 +141,10 @@ class PinEntryForRegistrationLockViewModel(
val (response, keyMaterial) = registerResult.data
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
// TODO storage service restore + profile screen
parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
when {
response.reregistration -> parentEventEmitter.navigateTo(RegistrationRoute.ChooseRestoreOptionAfterRegistration)
else -> parentEventEmitter.navigateTo(RegistrationRoute.FullyComplete)
}
state
}
is NetworkController.RegistrationNetworkResult.Failure -> {

View File

@@ -57,6 +57,7 @@ class PinEntryForSmsBypassViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
fun onEvent(event: PinEntryScreenEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch {
val stateEmitter: (PinEntryState) -> Unit = { _state.value = it }
applyEvent(state.value, event, stateEmitter, parentEventEmitter)

View File

@@ -50,6 +50,7 @@ class PinEntryForSvrRestoreViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PinEntryState(showNeedHelp = true))
fun onEvent(event: PinEntryScreenEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch {
val stateEmitter: (PinEntryState) -> Unit = { state ->
_state.value = state

View File

@@ -39,7 +39,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
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.SignalIcons
@@ -196,7 +196,7 @@ fun PinEntryScreen(
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun PinEntryScreenPreview() {
Previews.Preview {
@@ -207,7 +207,7 @@ private fun PinEntryScreenPreview() {
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun PinEntryScreenWithErrorPreview() {
Previews.Preview {

View File

@@ -5,7 +5,9 @@
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 object ToggleKeyboard : PinEntryScreenEvents()
data object NeedHelp : PinEntryScreenEvents()

View File

@@ -5,6 +5,8 @@
package org.signal.registration.screens.pinentry
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.DebugLoggableModel
import kotlin.time.Duration
data class PinEntryState(
@@ -15,14 +17,14 @@ data class PinEntryState(
val mode: Mode = Mode.SvrRestore,
val oneTimeEvent: OneTimeEvent? = null,
val e164: String? = null
) {
) : DebugLoggableModel() {
enum class Mode {
RegistrationLock,
SmsBypass,
SvrRestore
}
sealed interface OneTimeEvent {
sealed interface OneTimeEvent : DebugLoggable {
data object NetworkError : OneTimeEvent
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
data object SvrDataMissing : OneTimeEvent

View File

@@ -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()
}

View File

@@ -3,7 +3,7 @@
* 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.foundation.background
@@ -38,7 +38,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.style.TextAlign
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.QrCode
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.
*/
@Composable
fun RestoreViaQrScreen(
state: RestoreViaQrState,
onEvent: (RestoreViaQrScreenEvents) -> Unit,
fun QuickRestoreQrScreen(
state: QuickRestoreQrState,
onEvent: (QuickRestoreQrEvents) -> Unit,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
@@ -123,7 +123,7 @@ fun RestoreViaQrScreen(
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) {
Button(onClick = { onEvent(QuickRestoreQrEvents.RetryQrCode) }) {
Text("Retry")
}
}
@@ -143,7 +143,7 @@ fun RestoreViaQrScreen(
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { onEvent(RestoreViaQrScreenEvents.RetryQrCode) }) {
Button(onClick = { onEvent(QuickRestoreQrEvents.RetryQrCode) }) {
Text("Retry")
}
}
@@ -178,7 +178,7 @@ fun RestoreViaQrScreen(
Spacer(modifier = Modifier.weight(1f))
TextButton(
onClick = { onEvent(RestoreViaQrScreenEvents.Cancel) }
onClick = { onEvent(QuickRestoreQrEvents.Cancel) }
) {
Text("Cancel")
}
@@ -208,9 +208,9 @@ fun RestoreViaQrScreen(
// Error dialog
if (state.showRegistrationError) {
AlertDialog(
onDismissRequest = { onEvent(RestoreViaQrScreenEvents.DismissError) },
onDismissRequest = { onEvent(QuickRestoreQrEvents.DismissError) },
confirmButton = {
TextButton(onClick = { onEvent(RestoreViaQrScreenEvents.DismissError) }) {
TextButton(onClick = { onEvent(QuickRestoreQrEvents.DismissError) }) {
Text("OK")
}
},
@@ -246,23 +246,23 @@ private fun InstructionRow(
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun RestoreViaQrScreenLoadingPreview() {
private fun QuickRestoreQrScreenLoadingPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrState(qrState = QrState.Loading),
QuickRestoreQrScreen(
state = QuickRestoreQrState(qrState = QrState.Loading),
onEvent = {}
)
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun RestoreViaQrScreenLoadedPreview() {
private fun QuickRestoreQrScreenLoadedPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrState(
QuickRestoreQrScreen(
state = QuickRestoreQrState(
qrState = QrState.Loaded(QrCodeData.forData("sgnl://rereg?uuid=test&pub_key=test", false))
),
onEvent = {}
@@ -270,23 +270,23 @@ private fun RestoreViaQrScreenLoadedPreview() {
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun RestoreViaQrScreenFailedPreview() {
private fun QuickRestoreQrScreenFailedPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrState(qrState = QrState.Failed),
QuickRestoreQrScreen(
state = QuickRestoreQrState(qrState = QrState.Failed),
onEvent = {}
)
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun RestoreViaQrScreenRegisteringPreview() {
private fun QuickRestoreQrScreenRegisteringPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrState(
QuickRestoreQrScreen(
state = QuickRestoreQrState(
qrState = QrState.Scanned,
isRegistering = true
),

View File

@@ -3,20 +3,26 @@
* 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.registration.util.DebugLoggableModel
sealed class QrState {
data object Loading : QrState()
data class Loaded(val qrCodeData: QrCodeData) : QrState()
data object Scanned : QrState()
data object Failed : QrState()
}
data class RestoreViaQrState(
data class QuickRestoreQrState(
val qrState: QrState = QrState.Loading,
val isRegistering: Boolean = false,
val showRegistrationError: Boolean = false,
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()
}

View File

@@ -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
}
}
}

View File

@@ -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()
}

View File

@@ -50,7 +50,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
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.registration.R
import org.signal.registration.test.TestTags
@@ -438,7 +438,7 @@ private fun DigitField(
)
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun VerificationCodeScreenPreview() {
Previews.Preview {
@@ -451,7 +451,7 @@ private fun VerificationCodeScreenPreview() {
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun VerificationCodeScreenWithCountdownPreview() {
Previews.Preview {
@@ -468,7 +468,7 @@ private fun VerificationCodeScreenWithCountdownPreview() {
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun VerificationCodeScreenSubmittingPreview() {
Previews.Preview {

View File

@@ -5,7 +5,9 @@
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 object WrongNumber : VerificationCodeScreenEvents()
data object ResendSms : VerificationCodeScreenEvents()

View File

@@ -6,6 +6,8 @@
package org.signal.registration.screens.verificationcode
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.Companion.seconds
@@ -16,8 +18,8 @@ data class VerificationCodeState(
val rateLimits: SmsAndCallRateLimits = SmsAndCallRateLimits(),
val incorrectCodeAttempts: Int = 0,
val oneTimeEvent: OneTimeEvent? = null
) {
sealed interface OneTimeEvent {
) : DebugLoggableModel() {
sealed interface OneTimeEvent : DebugLoggable {
data object NetworkError : OneTimeEvent
data object UnknownError : OneTimeEvent
data class RateLimited(val retryAfter: Duration) : OneTimeEvent
@@ -50,4 +52,4 @@ data class VerificationCodeState(
data class SmsAndCallRateLimits(
val smsResendTimeRemaining: Duration = 0.seconds,
val callRequestTimeRemaining: Duration = 0.seconds
)
) : DebugLoggableModel()

View File

@@ -49,6 +49,7 @@ class VerificationCodeViewModel(
private var nextCallAvailableAt: Duration = 0.seconds
fun onEvent(event: VerificationCodeScreenEvents) {
Log.d(TAG, "[Event] $event")
viewModelScope.launch {
val stateEmitter: (VerificationCodeState) -> Unit = { newState ->
_localState.value = newState
@@ -179,10 +180,10 @@ class VerificationCodeViewModel(
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
if (response.storageCapable) {
parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore)
} else {
parentEventEmitter.navigateTo(RegistrationRoute.PinCreate)
when {
// response.reregistration -> parentEventEmitter.navigateTo(RegistrationRoute.ChooseRestoreOptionAfterRegistration)
response.storageCapable -> parentEventEmitter.navigateTo(RegistrationRoute.PinEntryForSvrRestore)
else -> parentEventEmitter.navigateTo(RegistrationRoute.PinCreate)
}
state
}

View File

@@ -46,7 +46,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import org.signal.core.ui.compose.BottomSheets
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.SignalIcons
import org.signal.core.ui.compose.dismissWithAnimation
@@ -255,7 +255,7 @@ private fun RestoreActionRow(
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun WelcomeScreenPreview() {
Previews.Preview {
@@ -263,7 +263,7 @@ private fun WelcomeScreenPreview() {
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun RestoreOrTransferBottomSheetPreview() {
Previews.BottomSheetPreview {

View File

@@ -5,7 +5,9 @@
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 HasOldPhone : WelcomeScreenEvents()
data object DoesNotHaveOldPhone : WelcomeScreenEvents()

View File

@@ -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()
}

View File

@@ -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()
}
}
}