mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 08:23:00 +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
@@ -10,6 +10,7 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
package org.signal.registration.screens.accountlocked
|
||||
|
||||
import org.signal.registration.util.DebugLoggableModel
|
||||
|
||||
data class AccountLockedState(
|
||||
val daysRemaining: Int = 10
|
||||
)
|
||||
) : DebugLoggableModel()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
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
|
||||
),
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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