Make regV5 resumable if the app closes.

This commit is contained in:
Greyson Parrelli
2026-03-18 16:53:50 -04:00
committed by Michelle Tang
parent c7ec3ab837
commit f09bf5b14c
15 changed files with 895 additions and 41 deletions

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration
import kotlinx.serialization.Serializable
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
/**
* A serializable snapshot of [RegistrationFlowState] fields that need to survive app kills.
*
* Fields like [RegistrationFlowState.accountEntropyPool] and [RegistrationFlowState.temporaryMasterKey]
* are reconstructed from dedicated proto fields, not from this JSON snapshot.
* [RegistrationFlowState.preExistingRegistrationData] is loaded from permanent storage.
*/
@Serializable
data class PersistedFlowState(
val backStack: List<RegistrationRoute>,
val sessionMetadata: NetworkController.SessionMetadata?,
val sessionE164: String?,
val doNotAttemptRecoveryPassword: Boolean
)
/**
* Extracts the persistable fields from a [RegistrationFlowState].
*/
fun RegistrationFlowState.toPersistedFlowState(): PersistedFlowState {
return PersistedFlowState(
backStack = backStack,
sessionMetadata = sessionMetadata,
sessionE164 = sessionE164,
doNotAttemptRecoveryPassword = doNotAttemptRecoveryPassword
)
}
/**
* Reconstructs a full [RegistrationFlowState] from persisted data and separately-stored fields.
*
* @param accountEntropyPool Restored from the proto's dedicated `accountEntropyPool` field.
* @param temporaryMasterKey Restored from the proto's dedicated `temporaryMasterKey` field.
* @param preExistingRegistrationData Loaded from permanent storage via [StorageController.getPreExistingRegistrationData].
*/
fun PersistedFlowState.toRegistrationFlowState(
accountEntropyPool: AccountEntropyPool?,
temporaryMasterKey: MasterKey?,
preExistingRegistrationData: PreExistingRegistrationData?
): RegistrationFlowState {
return RegistrationFlowState(
backStack = backStack,
sessionMetadata = sessionMetadata,
sessionE164 = sessionE164,
accountEntropyPool = accountEntropyPool,
temporaryMasterKey = temporaryMasterKey,
preExistingRegistrationData = preExistingRegistrationData,
doNotAttemptRecoveryPassword = doNotAttemptRecoveryPassword
)
}

View File

@@ -37,5 +37,8 @@ data class RegistrationFlowState(
val preExistingRegistrationData: PreExistingRegistrationData? = null,
/** If true, do not attempt any flows where we generate RRP's. Create a session instead. */
val doNotAttemptRecoveryPassword: Boolean = false
val doNotAttemptRecoveryPassword: Boolean = false,
/** If true, the ViewModel is still deciding whether to restore a previous flow or start fresh. */
val isRestoringNavigationState: Boolean = true
) : Parcelable, DebugLoggable

View File

@@ -8,9 +8,13 @@
package org.signal.registration
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -62,6 +66,7 @@ import org.signal.registration.screens.welcome.WelcomeScreenEvents
* Navigation routes for the registration flow.
* Using @Serializable and NavKey for type-safe navigation with Navigation 3.
*/
@Serializable
@Parcelize
sealed interface RegistrationRoute : NavKey, Parcelable {
@Serializable
@@ -77,7 +82,7 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
data object CountryCodePicker : RegistrationRoute
@Serializable
data class VerificationCodeEntry(val session: NetworkController.SessionMetadata, val e164: String) : RegistrationRoute
data object VerificationCodeEntry : RegistrationRoute
@Serializable
data class Captcha(val session: NetworkController.SessionMetadata) : RegistrationRoute
@@ -150,6 +155,13 @@ fun RegistrationNavHost(
val registrationState by viewModel.state.collectAsStateWithLifecycle()
val permissions: MultiplePermissionsState = permissionsState ?: rememberMultiplePermissionsState(viewModel.getRequiredPermissions())
if (registrationState.isRestoringNavigationState) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
val entryProvider = entryProvider {
navigationEntries(
registrationRepository = registrationRepository,

View File

@@ -10,6 +10,7 @@ import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okio.ByteString.Companion.toByteString
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
@@ -410,6 +411,81 @@ class RegistrationRepository(val context: Context, val networkController: Networ
return storageController.getPreExistingRegistrationData()
}
/**
* Persists the current flow state as JSON in the in-progress registration data proto.
*/
suspend fun saveFlowState(state: RegistrationFlowState) = withContext(Dispatchers.IO) {
try {
val json = flowStateJson.encodeToString(PersistedFlowState.serializer(), state.toPersistedFlowState())
storageController.updateInProgressRegistrationData {
flowStateJson = json
}
} catch (e: Exception) {
Log.w(TAG, "Failed to save flow state", e)
}
}
/**
* Restores the flow state from disk. Returns null if no state is saved or deserialization fails.
* Reconstructs [RegistrationFlowState.accountEntropyPool] and [RegistrationFlowState.temporaryMasterKey]
* from their dedicated proto fields, and loads [RegistrationFlowState.preExistingRegistrationData]
* from permanent storage.
*/
suspend fun restoreFlowState(): RegistrationFlowState? = withContext(Dispatchers.IO) {
try {
val data = storageController.readInProgressRegistrationData()
if (data.flowStateJson.isEmpty()) return@withContext null
val persisted = flowStateJson.decodeFromString(PersistedFlowState.serializer(), data.flowStateJson)
val aep = data.accountEntropyPool.takeIf { it.isNotEmpty() }?.let { AccountEntropyPool(it) }
val masterKey = data.temporaryMasterKey.takeIf { it.size > 0 }?.let { MasterKey(it.toByteArray()) }
val preExisting = storageController.getPreExistingRegistrationData()
persisted.toRegistrationFlowState(
accountEntropyPool = aep,
temporaryMasterKey = masterKey,
preExistingRegistrationData = preExisting
)
} catch (e: Exception) {
Log.w(TAG, "Failed to restore flow state", e)
null
}
}
/**
* Clears any persisted flow state JSON from the in-progress registration data.
*/
suspend fun clearFlowState() = withContext(Dispatchers.IO) {
try {
storageController.updateInProgressRegistrationData {
flowStateJson = ""
}
} catch (e: Exception) {
Log.w(TAG, "Failed to clear flow state", e)
}
}
/**
* Validates a registration session by fetching its current status from the server.
* Returns fresh [SessionMetadata] on success, or null if the session is expired/invalid.
*/
suspend fun validateSession(sessionId: String): SessionMetadata? = withContext(Dispatchers.IO) {
when (val result = networkController.getSession(sessionId)) {
is RegistrationNetworkResult.Success -> result.data
else -> null
}
}
/**
* Checks whether the in-progress registration data indicates a completed registration
* (i.e. both ACI and PNI have been saved).
*/
suspend fun isRegistered(): Boolean = withContext(Dispatchers.IO) {
val data = storageController.readInProgressRegistrationData()
data.aci.isNotEmpty() && data.pni.isNotEmpty()
}
private fun generateKeyMaterial(
existingAccountEntropyPool: AccountEntropyPool? = null,
existingAciIdentityKeyPair: IdentityKeyPair? = null,
@@ -488,5 +564,6 @@ class RegistrationRepository(val context: Context, val networkController: Networ
companion object {
private val TAG = Log.tag(RegistrationRepository::class)
private val flowStateJson = Json { ignoreUnknownKeys = true }
}
}

View File

@@ -13,6 +13,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -37,9 +38,17 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
val resultBus = ResultEventBus()
init {
_state.value = _state.value.copy(isRestoringNavigationState = true)
viewModelScope.launch {
repository.getPreExistingRegistrationData()?.let {
_state.value = _state.value.copy(preExistingRegistrationData = it)
val restored = repository.restoreFlowState()
if (restored != null) {
Log.i(TAG, "[init] Restored flow state from disk. Backstack size: ${restored.backStack.size}, hasSession: ${restored.sessionMetadata != null}")
_state.value = validateRestoredState(restored).copy(isRestoringNavigationState = false)
} else {
_state.value = _state.value.copy(
preExistingRegistrationData = repository.getPreExistingRegistrationData(),
isRestoringNavigationState = false
)
}
}
}
@@ -47,11 +56,15 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
fun onEvent(event: RegistrationFlowEvent) {
Log.d(TAG, "[Event] $event")
_state.value = applyEvent(_state.value, event)
viewModelScope.launch(Dispatchers.IO) {
persistFlowState(event)
}
}
fun applyEvent(state: RegistrationFlowState, event: RegistrationFlowEvent): RegistrationFlowState {
return when (event) {
is RegistrationFlowEvent.ResetState -> RegistrationFlowState()
is RegistrationFlowEvent.ResetState -> RegistrationFlowState(isRestoringNavigationState = false)
is RegistrationFlowEvent.SessionUpdated -> state.copy(sessionMetadata = event.session)
is RegistrationFlowEvent.E164Chosen -> state.copy(sessionE164 = event.e164)
is RegistrationFlowEvent.Registered -> state.copy(accountEntropyPool = event.accountEntropyPool)
@@ -63,14 +76,43 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
}
private fun applyNavigationToScreenEvent(inputState: RegistrationFlowState, event: RegistrationFlowEvent.NavigateToScreen): RegistrationFlowState {
val state = inputState.copy(backStack = inputState.backStack + event.route)
return inputState.copy(backStack = inputState.backStack + event.route)
}
return when (event.route) {
is RegistrationRoute.VerificationCodeEntry -> {
state.copy(sessionMetadata = event.route.session, sessionE164 = event.route.e164)
}
else -> state
/**
* Validates a restored flow state by checking if the session is still valid.
*
* - If the session is still valid, updates session metadata with fresh data.
* - If the session is expired and the user is already registered, nulls out the session
* (post-registration screens like PinCreate don't need a session).
* - If the session is expired and the user is NOT registered, resets the backstack to
* PhoneNumberEntry with the phone number pre-filled so the user can re-submit.
*/
private suspend fun validateRestoredState(state: RegistrationFlowState): RegistrationFlowState {
val sessionMetadata = state.sessionMetadata ?: return state
val freshSession = repository.validateSession(sessionMetadata.id)
if (freshSession != null) {
Log.i(TAG, "[validateRestoredState] Session still valid.")
return state.copy(sessionMetadata = freshSession)
}
Log.i(TAG, "[validateRestoredState] Session expired/invalid.")
if (repository.isRegistered()) {
Log.i(TAG, "[validateRestoredState] User is registered, proceeding without session.")
return state.copy(sessionMetadata = null)
}
Log.i(TAG, "[validateRestoredState] User is NOT registered, resetting to PhoneNumberEntry.")
return state.copy(
backStack = listOf(
RegistrationRoute.Welcome,
RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry),
RegistrationRoute.PhoneNumberEntry
),
sessionMetadata = null
)
}
/**
@@ -101,6 +143,27 @@ class RegistrationViewModel(private val repository: RegistrationRepository, save
}
}
private suspend fun persistFlowState(event: RegistrationFlowEvent) {
when (event) {
is RegistrationFlowEvent.ResetState -> repository.clearFlowState()
is RegistrationFlowEvent.NavigateToScreen -> {
if (event.route is RegistrationRoute.FullyComplete) {
repository.clearFlowState()
} else {
repository.saveFlowState(_state.value)
}
}
is RegistrationFlowEvent.NavigateBack,
is RegistrationFlowEvent.SessionUpdated,
is RegistrationFlowEvent.E164Chosen,
is RegistrationFlowEvent.RecoveryPasswordInvalid -> repository.saveFlowState(_state.value)
// No need to persist anything new, fields accounted for in proto already
is RegistrationFlowEvent.Registered,
is RegistrationFlowEvent.MasterKeyRestoredFromSvr -> { }
}
}
class Factory(private val repository: RegistrationRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: KClass<T>, extras: CreationExtras): T {
return RegistrationViewModel(repository, extras.createSavedStateHandle()) as T

View File

@@ -276,14 +276,17 @@ class PhoneNumberEntryViewModel(
is NetworkController.RegistrationNetworkResult.Failure<NetworkController.CreateSessionError> -> {
return when (response.error) {
is NetworkController.CreateSessionError.InvalidRequest -> {
Log.w(TAG, "[CreateSession] Invalid request when creating session. Message: ${response.error.message}")
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
}
is NetworkController.CreateSessionError.RateLimited -> {
Log.w(TAG, "[CreateSession] Rate limited (retryAfter: ${response.error.retryAfter}).")
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(response.error.retryAfter))
}
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "[CreateSession] Network error.", response.exception)
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
@@ -304,23 +307,26 @@ class PhoneNumberEntryViewModel(
Log.d(TAG, "Received push challenge token, submitting...")
val updateResult = repository.submitPushChallengeToken(sessionMetadata.id, pushChallengeToken)
sessionMetadata = when (updateResult) {
is NetworkController.RegistrationNetworkResult.Success -> updateResult.data
is NetworkController.RegistrationNetworkResult.Success -> {
Log.d(TAG, "[SubmitPushChallengeToken] Successfully submitted push challenge token.")
updateResult.data
}
is NetworkController.RegistrationNetworkResult.Failure -> {
Log.w(TAG, "Failed to submit push challenge token: ${updateResult.error}")
Log.w(TAG, "[SubmitPushChallengeToken] Failed to submit push challenge token: ${updateResult.error}")
sessionMetadata
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "Network error submitting push challenge token", updateResult.exception)
Log.w(TAG, "[SubmitPushChallengeToken] Network error submitting push challenge token", updateResult.exception)
sessionMetadata
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "Application error submitting push challenge token", updateResult.exception)
Log.w(TAG, "[SubmitPushChallengeToken] Application error submitting push challenge token", updateResult.exception)
sessionMetadata
}
}
state = state.copy(sessionMetadata = sessionMetadata)
} else {
Log.d(TAG, "Push challenge token not received within timeout")
Log.d(TAG, "[SubmitPushChallengeToken] Push challenge token not received within timeout")
}
}
@@ -337,41 +343,49 @@ class PhoneNumberEntryViewModel(
sessionMetadata = when (verificationCodeResponse) {
is NetworkController.RegistrationNetworkResult.Success<NetworkController.SessionMetadata> -> {
Log.d(TAG, "[RequestVerificationCode] Successfully requested verification code.")
verificationCodeResponse.data
}
is NetworkController.RegistrationNetworkResult.Failure<NetworkController.RequestVerificationCodeError> -> {
return when (verificationCodeResponse.error) {
is NetworkController.RequestVerificationCodeError.InvalidRequest -> {
Log.w(TAG, "[RequestVerificationCode] Invalid request when requesting verification code. Message: ${verificationCodeResponse.error.message}")
state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
}
is NetworkController.RequestVerificationCodeError.RateLimited -> {
Log.w(TAG, "[RequestVerificationCode] Rate limited (retryAfter: ${verificationCodeResponse.error.retryAfter}).")
state.copy(oneTimeEvent = OneTimeEvent.RateLimited(verificationCodeResponse.error.retryAfter))
}
is NetworkController.RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport -> {
Log.w(TAG, "[RequestVerificationCode] Could not fulfill with requested transport.")
state.copy(oneTimeEvent = OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
}
is NetworkController.RequestVerificationCodeError.InvalidSessionId -> {
Log.w(TAG, "[RequestVerificationCode] Invalid session ID when requesting verification code.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
state
}
is NetworkController.RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified -> {
Log.w(TAG, "When requesting verification code, missing request information or already verified.")
Log.w(TAG, "[RequestVerificationCode] Missing request information or already verified.")
state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
}
is NetworkController.RequestVerificationCodeError.SessionNotFound -> {
Log.w(TAG, "[RequestVerificationCode] Session not found when requesting verification code.")
parentEventEmitter(RegistrationFlowEvent.ResetState)
state
}
is NetworkController.RequestVerificationCodeError.ThirdPartyServiceError -> {
Log.w(TAG, "[RequestVerificationCode] Third party service error.")
state.copy(oneTimeEvent = OneTimeEvent.ThirdPartyError)
}
}
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "[RequestVerificationCode] Network error.", verificationCodeResponse.exception)
return state.copy(oneTimeEvent = OneTimeEvent.NetworkError)
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "Unknown error when creating session.", verificationCodeResponse.exception)
Log.w(TAG, "[RequestVerificationCode] Unknown error when creating session.", verificationCodeResponse.exception)
return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)
}
}
@@ -383,7 +397,9 @@ class PhoneNumberEntryViewModel(
return state
}
parentEventEmitter.navigateTo(RegistrationRoute.VerificationCodeEntry(sessionMetadata, e164))
parentEventEmitter(RegistrationFlowEvent.SessionUpdated(sessionMetadata))
parentEventEmitter(RegistrationFlowEvent.E164Chosen(e164))
parentEventEmitter.navigateTo(RegistrationRoute.VerificationCodeEntry)
return state
}
@@ -470,9 +486,9 @@ class PhoneNumberEntryViewModel(
}
}
val e164 = "+${inputState.countryCode}${inputState.nationalNumber}"
parentEventEmitter.navigateTo(RegistrationRoute.VerificationCodeEntry(sessionMetadata, e164))
parentEventEmitter(RegistrationFlowEvent.SessionUpdated(sessionMetadata))
parentEventEmitter(RegistrationFlowEvent.E164Chosen("+${inputState.countryCode}${inputState.nationalNumber}"))
parentEventEmitter.navigateTo(RegistrationRoute.VerificationCodeEntry)
return state
}

View File

@@ -39,6 +39,9 @@ message RegistrationData {
// Provisioning data (from saveProvisioningData)
ProvisioningData provisioningData = 20;
// JSON-serialized flow state snapshot (from saveFlowState/restoreFlowState)
string flowStateJson = 21;
}
message SvrCredential {