Attempt to restore AccountRecord in regV5.

This commit is contained in:
Greyson Parrelli
2026-06-09 17:21:47 -04:00
committed by Cody Henthorne
parent 754dd15c94
commit 118231a328
45 changed files with 1618 additions and 49 deletions
@@ -37,9 +37,11 @@ import org.signal.registration.NetworkController.RegisterAccountError
import org.signal.registration.NetworkController.RegisterAccountResponse
import org.signal.registration.NetworkController.RegistrationLockResponse
import org.signal.registration.NetworkController.RequestVerificationCodeError
import org.signal.registration.NetworkController.RestoreAccountRecordError
import org.signal.registration.NetworkController.RestoreMasterKeyError
import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.NetworkController.SetAccountAttributesError
import org.signal.registration.NetworkController.SetProfileError
import org.signal.registration.NetworkController.SetRegistrationLockError
import org.signal.registration.NetworkController.SetRestoreMethodError
import org.signal.registration.NetworkController.SubmitVerificationCodeError
@@ -49,15 +51,24 @@ import org.signal.registration.NetworkController.UpdateSessionError
import org.signal.registration.NetworkController.VerificationCodeTransport
import org.signal.registration.proto.RegistrationProvisionMessage
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.gcm.FcmUtil
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
@@ -602,6 +613,60 @@ class AppRegistrationNetworkController(
AppDependencies.jobManager.add(RefreshAttributesJob())
}
override suspend fun setProfile(
givenName: String,
familyName: String,
avatar: ByteArray?,
discoverableByPhoneNumber: Boolean
): RequestResult<Unit, SetProfileError> = withContext(Dispatchers.IO) {
if (!SignalStore.account.isRegistered) {
Log.w(TAG, "[setProfile] Not registered.")
return@withContext RequestResult.NonSuccess(SetProfileError.NotRegistered)
}
val profileName = ProfileName.fromParts(givenName, familyName)
SignalDatabase.recipients.setProfileName(Recipient.self().id, profileName)
if (avatar != null) {
try {
AvatarHelper.setAvatar(context, Recipient.self().id, java.io.ByteArrayInputStream(avatar))
} catch (e: IOException) {
Log.w(TAG, "[setProfile] Failed to write avatar.", e)
return@withContext RequestResult.NonSuccess(SetProfileError.IOError(e))
}
SignalStore.misc.hasEverHadAnAvatar = true
}
SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (discoverableByPhoneNumber) {
org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE
} else {
org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE
}
AppDependencies.jobManager
.startChain(ProfileUploadJob())
.then(listOf(MultiDeviceProfileKeyUpdateJob(), MultiDeviceProfileContentUpdateJob()))
.enqueue()
RegistrationUtil.maybeMarkRegistrationComplete()
RequestResult.Success(Unit)
}
override suspend fun restoreAccountRecord(
timeout: Duration
): RequestResult<Unit, RestoreAccountRecordError> = withContext(Dispatchers.IO) {
Log.i(TAG, "[restoreAccountRecord] Enqueuing StorageAccountRestoreJob (timeout=${timeout.inWholeSeconds}s).")
val state = AppDependencies.jobManager.runSynchronously(StorageAccountRestoreJob(), timeout.inWholeMilliseconds)
if (state.isPresent) {
Log.i(TAG, "[restoreAccountRecord] Completed within timeout: ${state.get()}")
RequestResult.Success(Unit)
} else {
Log.w(TAG, "[restoreAccountRecord] Timed out. Job continues in background.")
RequestResult.NonSuccess(RestoreAccountRecordError.Timeout)
}
}
override suspend fun setRestoreMethod(token: String, method: NetworkController.RestoreMethod): RequestResult<Unit, SetRestoreMethodError> = withContext(Dispatchers.IO) {
val serviceMethod = when (method) {
NetworkController.RestoreMethod.REMOTE_BACKUP -> ServiceRestoreMethod.REMOTE_BACKUP
@@ -23,9 +23,11 @@ import org.greenrobot.eventbus.ThreadMode
import org.signal.archive.LocalBackupRestoreProgress
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.util.StreamUtil
import org.signal.core.util.logging.Log
import org.signal.registration.PreExistingRegistrationData
import org.signal.registration.StorageController
import org.signal.registration.StoredProfileData
import org.signal.registration.proto.RegistrationData
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress
@@ -41,6 +43,8 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import java.io.File
import java.io.IOException
@@ -91,6 +95,39 @@ class AppRegistrationStorageController(private val context: Context) : StorageCo
Unit
}
override suspend fun getStoredProfileData(): StoredProfileData = withContext(Dispatchers.IO) {
if (!SignalStore.account.isRegistered) {
return@withContext StoredProfileData()
}
val self = Recipient.self()
val profileName = self.profileName
val avatar: ByteArray? = if (AvatarHelper.hasAvatar(context, self.id)) {
try {
AvatarHelper.getAvatar(context, self.id)?.use { StreamUtil.readFully(it) }
} catch (e: IOException) {
Log.w(TAG, "[getStoredProfileData] Failed to read self avatar.", e)
null
}
} else {
null
}
val discoverable: Boolean? = when (SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode) {
org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE -> true
org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE -> false
org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.UNDECIDED -> null
}
StoredProfileData(
givenName = profileName.givenName,
familyName = profileName.familyName,
avatar = avatar,
discoverableByPhoneNumber = discoverable
)
}
override suspend fun readInProgressRegistrationData(): RegistrationData = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, TEMP_PROTO_FILENAME)
if (file.exists()) {
+1
View File
@@ -60,6 +60,7 @@ dependencies {
implementation(project(":core:util"))
implementation(project(":core:models-jvm"))
implementation(project(":lib:libsignal-service"))
implementation(project(":lib:network"))
implementation(project(":lib:qr"))
// libsignal-protocol for PreKeyCollection types
@@ -26,10 +26,12 @@ import org.signal.registration.NetworkController.ProvisioningEvent
import org.signal.registration.NetworkController.RegisterAccountError
import org.signal.registration.NetworkController.RegisterAccountResponse
import org.signal.registration.NetworkController.RequestVerificationCodeError
import org.signal.registration.NetworkController.RestoreAccountRecordError
import org.signal.registration.NetworkController.RestoreMasterKeyError
import org.signal.registration.NetworkController.RestoreMethod
import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.NetworkController.SetAccountAttributesError
import org.signal.registration.NetworkController.SetProfileError
import org.signal.registration.NetworkController.SetRegistrationLockError
import org.signal.registration.NetworkController.SetRestoreMethodError
import org.signal.registration.NetworkController.SubmitVerificationCodeError
@@ -203,6 +205,27 @@ class DebugNetworkController(
delegate.enqueueAccountAttributesSyncJob()
}
override suspend fun setProfile(
givenName: String,
familyName: String,
avatar: ByteArray?,
discoverableByPhoneNumber: Boolean
): RequestResult<Unit, SetProfileError> {
NetworkDebugState.getOverride<RequestResult<Unit, SetProfileError>>("setProfile")?.let {
Log.d(TAG, "[setProfile] Returning debug override")
return it
}
return delegate.setProfile(givenName, familyName, avatar, discoverableByPhoneNumber)
}
override suspend fun restoreAccountRecord(timeout: kotlin.time.Duration): RequestResult<Unit, RestoreAccountRecordError> {
NetworkDebugState.getOverride<RequestResult<Unit, RestoreAccountRecordError>>("restoreAccountRecord")?.let {
Log.d(TAG, "[restoreAccountRecord] Returning debug override")
return it
}
return delegate.restoreAccountRecord(timeout)
}
override suspend fun setRestoreMethod(token: String, method: RestoreMethod): RequestResult<Unit, SetRestoreMethodError> {
NetworkDebugState.getOverride<RequestResult<Unit, SetRestoreMethodError>>("setRestoreMethod")?.let {
Log.d(TAG, "[setRestoreMethod] Returning debug override")
@@ -8,6 +8,7 @@ package org.signal.registration.sample.dependencies
import android.app.PendingIntent
import android.content.Intent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
@@ -37,6 +39,7 @@ import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.zkgroup.GenericServerPublicParams
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext
import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse
import org.signal.network.service.StorageServiceService
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.CheckSvrCredentialsRequest
@@ -61,6 +64,7 @@ import org.signal.registration.sample.fcm.FcmUtil
import org.signal.registration.sample.fcm.PushChallengeReceiver
import org.signal.registration.sample.storage.RegistrationPreferences
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket
import org.whispersystems.signalservice.api.storage.StorageServiceApi
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.RestoreResponse
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV2
@@ -895,6 +899,126 @@ class DemoNetworkController(
}
}
override suspend fun setProfile(
givenName: String,
familyName: String,
avatar: ByteArray?,
discoverableByPhoneNumber: Boolean
): RequestResult<Unit, NetworkController.SetProfileError> = withContext(Dispatchers.IO) {
val aci = RegistrationPreferences.aci
if (aci == null) {
Log.w(TAG, "[setProfile] Not registered.")
return@withContext RequestResult.NonSuccess(NetworkController.SetProfileError.NotRegistered)
}
Log.i(TAG, "[setProfile] Pretending to upload profile (givenName=${givenName.length} chars, familyName=${familyName.length} chars, avatar=${avatar?.size ?: 0} bytes, discoverable=$discoverableByPhoneNumber).")
RegistrationPreferences.profileGivenName = givenName
RegistrationPreferences.profileFamilyName = familyName
RegistrationPreferences.profileAvatar = avatar
RegistrationPreferences.profileDiscoverableByPhoneNumber = discoverableByPhoneNumber
RequestResult.Success(Unit)
}
override suspend fun restoreAccountRecord(
timeout: kotlin.time.Duration
): RequestResult<Unit, NetworkController.RestoreAccountRecordError> = withContext(Dispatchers.IO) {
val aci = RegistrationPreferences.aci
val pni = RegistrationPreferences.pni
val e164 = RegistrationPreferences.e164
val password = RegistrationPreferences.servicePassword
val masterKey = RegistrationPreferences.temporaryMasterKey ?: RegistrationPreferences.masterKey
if (aci == null || pni == null || e164 == null || password == null || masterKey == null) {
Log.w(TAG, "[restoreAccountRecord] Missing credentials or master key.")
return@withContext RequestResult.Success(Unit)
}
val storageKey = masterKey.deriveStorageServiceKey()
val network = Network(Network.Environment.STAGING, "Signal-Android-Registration-Sample", emptyMap(), Network.BuildVariant.PRODUCTION)
val credentialsProvider = StaticCredentialsProvider(aci, pni, e164, 1, password)
val healthMonitor = object : HealthMonitor {
override fun onKeepAliveResponse(sentTimestamp: Long, isIdentifiedWebSocket: Boolean) {}
override fun onMessageError(status: Int, isIdentifiedWebSocket: Boolean) {}
override fun onReceivedAlerts(alerts: Array<out String>, isIdentifiedWebSocket: Boolean) {}
}
val libSignalConnection = LibSignalChatConnection(
name = "Storage-Restore",
network = network,
credentialsProvider = credentialsProvider,
receiveStories = false,
healthMonitor = healthMonitor
)
val authWebSocket = SignalWebSocket.AuthenticatedWebSocket(
connectionFactory = { libSignalConnection },
canConnect = { true },
sleepTimer = { millis -> Thread.sleep(millis) },
disconnectTimeoutMs = 60.seconds.inWholeMilliseconds
)
try {
withTimeout(timeout) {
authWebSocket.connect()
val storageService = StorageServiceService(StorageServiceApi(authWebSocket, pushServiceSocket))
Log.i(TAG, "[restoreAccountRecord] Retrieving manifest...")
val manifest = when (val result = storageService.getStorageManifest(storageKey)) {
is StorageServiceService.ManifestResult.Success -> result.manifest
is StorageServiceService.ManifestResult.NotFoundError -> {
Log.w(TAG, "[restoreAccountRecord] No manifest found.")
return@withTimeout RequestResult.Success(Unit)
}
is StorageServiceService.ManifestResult.DecryptionError -> {
Log.w(TAG, "[restoreAccountRecord] Manifest decryption failed.", result.exception)
return@withTimeout RequestResult.Success(Unit)
}
is StorageServiceService.ManifestResult.NetworkError -> return@withTimeout RequestResult.NonSuccess(NetworkController.RestoreAccountRecordError.IOError(result.exception))
is StorageServiceService.ManifestResult.StatusCodeError -> return@withTimeout RequestResult.ApplicationError(result.exception)
}
val accountId = manifest.accountStorageId
if (!accountId.isPresent) {
Log.w(TAG, "[restoreAccountRecord] Manifest had no account record.")
return@withTimeout RequestResult.Success(Unit)
}
Log.i(TAG, "[restoreAccountRecord] Retrieving account record...")
val records = when (val result = storageService.readStorageRecords(storageKey, manifest.recordIkm, listOf(accountId.get()))) {
is StorageServiceService.StorageRecordResult.Success -> result.records
is StorageServiceService.StorageRecordResult.DecryptionError -> {
Log.w(TAG, "[restoreAccountRecord] Account record decryption failed.", result.exception)
return@withTimeout RequestResult.Success(Unit)
}
is StorageServiceService.StorageRecordResult.NetworkError -> return@withTimeout RequestResult.NonSuccess(NetworkController.RestoreAccountRecordError.IOError(result.exception))
is StorageServiceService.StorageRecordResult.StatusCodeError -> return@withTimeout RequestResult.ApplicationError(result.exception)
}
val account = records.firstOrNull()?.proto?.account
if (account == null) {
Log.w(TAG, "[restoreAccountRecord] Storage record did not contain an account.")
return@withTimeout RequestResult.Success(Unit)
}
RegistrationPreferences.profileGivenName = account.givenName
RegistrationPreferences.profileFamilyName = account.familyName
RegistrationPreferences.profileDiscoverableByPhoneNumber = !account.unlistedPhoneNumber
Log.i(TAG, "[restoreAccountRecord] Restored profile: givenName=${account.givenName.length} chars, familyName=${account.familyName.length} chars, discoverable=${!account.unlistedPhoneNumber}, avatarUrlPath='${account.avatarUrlPath}' (not downloaded in demo).")
RequestResult.Success(Unit)
}
} catch (e: TimeoutCancellationException) {
Log.w(TAG, "[restoreAccountRecord] Timed out.")
RequestResult.NonSuccess(NetworkController.RestoreAccountRecordError.Timeout)
} catch (e: IOException) {
Log.w(TAG, "[restoreAccountRecord] IOException", e)
RequestResult.NonSuccess(NetworkController.RestoreAccountRecordError.IOError(e))
} catch (e: Exception) {
Log.w(TAG, "[restoreAccountRecord] Exception", e)
RequestResult.ApplicationError(e)
} finally {
authWebSocket.disconnect()
}
}
override suspend fun setRestoreMethod(token: String, method: NetworkController.RestoreMethod): RequestResult<Unit, NetworkController.SetRestoreMethodError> = withContext(Dispatchers.IO) {
try {
val baseUrl = serviceConfiguration.signalServiceUrls[0].url
@@ -28,6 +28,7 @@ import org.signal.registration.NetworkController
import org.signal.registration.NewRegistrationData
import org.signal.registration.PreExistingRegistrationData
import org.signal.registration.StorageController
import org.signal.registration.StoredProfileData
import org.signal.registration.proto.ProvisioningData
import org.signal.registration.proto.RegistrationData
import org.signal.registration.sample.storage.RegistrationDatabase
@@ -57,6 +58,15 @@ class DemoStorageController(private val context: Context) : StorageController {
RegistrationPreferences.getPreExistingRegistrationData()
}
override suspend fun getStoredProfileData(): StoredProfileData = withContext(Dispatchers.IO) {
StoredProfileData(
givenName = RegistrationPreferences.profileGivenName,
familyName = RegistrationPreferences.profileFamilyName,
avatar = RegistrationPreferences.profileAvatar,
discoverableByPhoneNumber = RegistrationPreferences.profileDiscoverableByPhoneNumber
)
}
override suspend fun clearAllData() = withContext(Dispatchers.IO) {
File(context.filesDir, TEMP_PROTO_FILENAME).takeIf { it.exists() }?.delete()
RegistrationPreferences.clearAll()
@@ -151,6 +151,11 @@ fun MainScreen(
RegistrationInfo(state.existingRegistrationState)
if (state.profileState != null) {
Spacer(modifier = Modifier.height(16.dp))
ProfileInfo(state.profileState)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
@@ -230,6 +235,43 @@ private fun RegistrationInfo(data: MainScreenState.ExistingRegistrationState) {
}
}
@Composable
private fun ProfileInfo(profile: MainScreenState.ProfileState) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Profile",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
RegistrationField(label = "Given Name", value = profile.givenName.ifEmpty { "(not set)" })
RegistrationField(label = "Family Name", value = profile.familyName.ifEmpty { "(not set)" })
RegistrationField(
label = "Avatar",
value = profile.avatarSizeBytes?.let { "$it bytes" } ?: "(not set)"
)
RegistrationField(
label = "Discoverable by Phone Number",
value = when (profile.discoverableByPhoneNumber) {
true -> "Yes"
false -> "No"
null -> "(undecided)"
}
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RegistrationField(label: String, value: String) {
@@ -336,6 +378,12 @@ private fun MainScreenWithRegistrationPreview() {
registrationLockEnabled = true,
pinsOptedOut = false,
temporaryMasterKey = null
),
profileState = MainScreenState.ProfileState(
givenName = "Ada",
familyName = "Lovelace",
avatarSizeBytes = 12_345,
discoverableByPhoneNumber = true
)
),
onEvent = {}
@@ -8,7 +8,8 @@ package org.signal.registration.sample.screens.main
data class MainScreenState(
val existingRegistrationState: ExistingRegistrationState? = null,
val registrationExpired: Boolean = false,
val pendingFlowState: PendingFlowState? = null
val pendingFlowState: PendingFlowState? = null,
val profileState: ProfileState? = null
) {
data class PendingFlowState(
val e164: String?,
@@ -28,4 +29,11 @@ data class MainScreenState(
val pinsOptedOut: Boolean,
val temporaryMasterKey: String?
)
data class ProfileState(
val givenName: String,
val familyName: String,
val avatarSizeBytes: Int?,
val discoverableByPhoneNumber: Boolean?
)
}
@@ -61,6 +61,7 @@ class MainScreenViewModel(
private fun loadRegistrationData() {
viewModelScope.launch {
val existingData = storageController.getPreExistingRegistrationData()
val storedProfile = if (existingData != null) storageController.getStoredProfileData() else null
_state.value = _state.value.copy(
existingRegistrationState = if (existingData != null) {
MainScreenState.ExistingRegistrationState(
@@ -78,6 +79,14 @@ class MainScreenViewModel(
} else {
null
},
profileState = storedProfile?.let {
MainScreenState.ProfileState(
givenName = it.givenName,
familyName = it.familyName,
avatarSizeBytes = it.avatar?.size,
discoverableByPhoneNumber = it.discoverableByPhoneNumber
)
},
pendingFlowState = loadPendingFlowState(),
registrationExpired = false
)
@@ -53,6 +53,10 @@ object RegistrationPreferences {
private const val KEY_OTHER_DEVICE_PLATFORM = "other_device_platform"
private const val KEY_FETCHES_MESSAGES = "fetches_messages"
private const val KEY_BACKUP_VERSION = "backup_version"
private const val KEY_PROFILE_GIVEN_NAME = "profile_given_name"
private const val KEY_PROFILE_FAMILY_NAME = "profile_family_name"
private const val KEY_PROFILE_AVATAR = "profile_avatar"
private const val KEY_PROFILE_DISCOVERABLE = "profile_discoverable"
fun init(context: Application) {
this.context = context
@@ -132,6 +136,24 @@ object RegistrationPreferences {
get() = prefs.getBoolean(KEY_FETCHES_MESSAGES, true)
set(value) = prefs.edit { putBoolean(KEY_FETCHES_MESSAGES, value) }
var profileGivenName: String
get() = prefs.getString(KEY_PROFILE_GIVEN_NAME, "") ?: ""
set(value) = prefs.edit { putString(KEY_PROFILE_GIVEN_NAME, value) }
var profileFamilyName: String
get() = prefs.getString(KEY_PROFILE_FAMILY_NAME, "") ?: ""
set(value) = prefs.edit { putString(KEY_PROFILE_FAMILY_NAME, value) }
var profileAvatar: ByteArray?
get() = prefs.getString(KEY_PROFILE_AVATAR, null)?.let { Base64.decode(it) }
set(value) = prefs.edit { putString(KEY_PROFILE_AVATAR, value?.let { Base64.encodeWithPadding(it) }) }
var profileDiscoverableByPhoneNumber: Boolean?
get() = if (prefs.contains(KEY_PROFILE_DISCOVERABLE)) prefs.getBoolean(KEY_PROFILE_DISCOVERABLE, false) else null
set(value) = prefs.edit {
if (value == null) remove(KEY_PROFILE_DISCOVERABLE) else putBoolean(KEY_PROFILE_DISCOVERABLE, value)
}
var restoredSvr2Credentials: List<NetworkController.SvrCredentials>
get() = prefs.getStringSet(KEY_SVR2_CREDENTIALS, emptySet())?.mapNotNull { parseCredential(it) } ?: emptyList()
set(value) = prefs.edit { putStringSet(KEY_SVR2_CREDENTIALS, value.map { serializeCredential(it) }.toSet()) }
@@ -250,6 +250,39 @@ interface NetworkController {
*/
suspend fun setRestoreMethod(token: String, method: RestoreMethod): RequestResult<Unit, SetRestoreMethodError>
/**
* Best-effort restore of the AccountRecord from the storage service. Implementations should
* always kick off the restore (typically via a durable job) so that work continues in the
* background, but this call must return within [timeout]. A timeout is reported as a non-success
* result, but the underlying restore may still complete shortly after.
*
* Intended to be invoked once the user has set/verified their PIN, so that subsequent screens
* (e.g. the create-profile screen) can pre-seed themselves from any data that was restored.
*/
suspend fun restoreAccountRecord(timeout: Duration): RequestResult<Unit, RestoreAccountRecordError>
/**
* Persists the user's chosen profile name (and optional avatar) for the freshly-registered account
* and arranges for it to be synced to the service. Implementations may save the data locally and
* enqueue a durable job to perform the actual upload, since profile sync is allowed to happen in
* the background.
*
* Also persists [discoverableByPhoneNumber] as the user's choice for whether other users can find
* them on Signal by their phone number.
*
* @param givenName The user's given/first name. Must be non-blank.
* @param familyName The user's family/last name. May be blank.
* @param avatar Raw avatar bytes, or null to leave the avatar unchanged/cleared.
* @param discoverableByPhoneNumber If true, anyone who has the user's phone number can find them
* on Signal; if false, the user is only reachable via existing chats.
*/
suspend fun setProfile(
givenName: String,
familyName: String,
avatar: ByteArray?,
discoverableByPhoneNumber: Boolean
): RequestResult<Unit, SetProfileError>
// /**
// * Registers a device as a linked device on a pre-existing account.
// *
@@ -343,6 +376,17 @@ interface NetworkController {
data class RateLimited(val retryAfter: Duration) : SetRestoreMethodError()
}
sealed class SetProfileError : BadRequestError {
data object NotRegistered : SetProfileError()
data class IOError(val cause: Throwable) : SetProfileError()
data class InvalidRequest(val message: String) : SetProfileError()
}
sealed class RestoreAccountRecordError : BadRequestError {
data object Timeout : RestoreAccountRecordError()
data class IOError(val cause: Throwable) : RestoreAccountRecordError()
}
sealed class GetBackupInfoError : BadRequestError {
data class BadArguments(val body: String? = null) : GetBackupInfoError()
data class BadAuthCredential(val body: String? = null) : GetBackupInfoError()
@@ -58,6 +58,9 @@ import org.signal.registration.screens.countrycode.Country
import org.signal.registration.screens.countrycode.CountryCodePickerRepository
import org.signal.registration.screens.countrycode.CountryCodePickerScreen
import org.signal.registration.screens.countrycode.CountryCodePickerViewModel
import org.signal.registration.screens.createprofile.CreateProfileScreen
import org.signal.registration.screens.createprofile.CreateProfileScreenEvents
import org.signal.registration.screens.createprofile.CreateProfileViewModel
import org.signal.registration.screens.devicetransfer.complete.DeviceTransferCompleteScreen
import org.signal.registration.screens.devicetransfer.complete.DeviceTransferCompleteViewModel
import org.signal.registration.screens.devicetransfer.instructions.DeviceTransferInstructionsScreen
@@ -66,6 +69,8 @@ import org.signal.registration.screens.devicetransfer.progress.DeviceTransferPro
import org.signal.registration.screens.devicetransfer.progress.DeviceTransferProgressViewModel
import org.signal.registration.screens.devicetransfer.setup.DeviceTransferSetupScreen
import org.signal.registration.screens.devicetransfer.setup.DeviceTransferSetupViewModel
import org.signal.registration.screens.discoverability.PhoneNumberDiscoverabilityScreen
import org.signal.registration.screens.discoverability.PhoneNumberDiscoverabilityViewModel
import org.signal.registration.screens.linkaccount.LinkAccountScreen
import org.signal.registration.screens.linkaccount.LinkAccountScreenEvent
import org.signal.registration.screens.linkaccount.LinkAccountViewModel
@@ -233,6 +238,9 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
@Serializable
data object Profile : RegistrationRoute
@Serializable
data class PhoneNumberDiscoverability(val initialDiscoverable: Boolean) : RegistrationRoute
@Serializable
data object FullyComplete : RegistrationRoute
}
@@ -241,6 +249,7 @@ private const val CAPTCHA_RESULT = "captcha_token"
private const val COUNTRY_CODE_RESULT = "country_code_result"
private const val BACKUP_CREDENTIAL_RESULT = "backup_credential_result"
private const val LOCAL_BACKUP_RESTORE_RESULT = "local_backup_restore_result"
private const val PHONE_NUMBER_DISCOVERABILITY_RESULT = "phone_number_discoverability_result"
/**
* Sets up the navigation graph for the registration flow using Navigation 3.
@@ -840,7 +849,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
// -- Device Transfer: Complete
entry<RegistrationRoute.DeviceTransferComplete> {
val viewModel: DeviceTransferCompleteViewModel = viewModel(
factory = DeviceTransferCompleteViewModel.Factory(parentEventEmitter)
factory = DeviceTransferCompleteViewModel.Factory(registrationRepository, parentEventEmitter)
)
val state by viewModel.state.collectAsStateWithLifecycle()
DeviceTransferCompleteScreen(
@@ -850,7 +859,39 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
}
entry<RegistrationRoute.Profile> {
// TODO: Implement ProfileScreen
val viewModel: CreateProfileViewModel = viewModel(
factory = CreateProfileViewModel.Factory(
repository = registrationRepository,
parentEventEmitter = registrationViewModel::onEvent
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
ResultEffect<Boolean>(registrationViewModel.resultBus, PHONE_NUMBER_DISCOVERABILITY_RESULT) { discoverable ->
viewModel.onEvent(CreateProfileScreenEvents.DiscoverabilityChanged(discoverable))
}
CreateProfileScreen(
state = state,
onEvent = viewModel::onEvent
)
}
entry<RegistrationRoute.PhoneNumberDiscoverability> { key ->
val viewModel: PhoneNumberDiscoverabilityViewModel = viewModel(
factory = PhoneNumberDiscoverabilityViewModel.Factory(
initialDiscoverable = key.initialDiscoverable,
parentEventEmitter = registrationViewModel::onEvent,
resultBus = registrationViewModel.resultBus,
resultKey = PHONE_NUMBER_DISCOVERABILITY_RESULT
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
PhoneNumberDiscoverabilityScreen(
state = state,
onEvent = viewModel::onEvent
)
}
entry<RegistrationRoute.FullyComplete> {
@@ -50,6 +50,8 @@ import java.util.Locale
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
class RegistrationRepository(val context: Context, val networkController: NetworkController, val storageController: StorageController, val isLinkAndSyncAvailable: Boolean) {
@@ -426,6 +428,70 @@ class RegistrationRepository(val context: Context, val networkController: Networ
result.map { it to keyMaterial }
}
/**
* Reads any locally-cached profile data (given/family name, avatar) so the create-profile screen
* can pre-seed itself or skip outright when the user is re-registering with profile data still on
* disk. See [StorageController.getStoredProfileData].
*/
suspend fun getStoredProfileData(): StoredProfileData = withContext(Dispatchers.IO) {
storageController.getStoredProfileData()
}
/**
* Best-effort restore of the AccountRecord from the storage service, with a timeout for the UI.
* The work continues in the background even if [timeout] elapses. See [NetworkController.restoreAccountRecord].
*/
suspend fun restoreAccountRecord(
timeout: Duration
): RequestResult<Unit, NetworkController.RestoreAccountRecordError> = withContext(Dispatchers.IO) {
networkController.restoreAccountRecord(timeout)
}
/**
* Best-effort restore the AccountRecord (when local profile data is incomplete) and then signal
* registration completion on [parentEventEmitter]. The Profile screen is intentionally not
* routed to from here for now — even when the restore doesn't fully populate profile data, we
* emit [RegistrationFlowEvent.RegistrationComplete].
*
* Intended for any screen that, in the legacy flow, would have signalled "we're done". Pre-
* existing-data callers (re-registration, device transfer, backup restore) won't pay the
* restore-record cost.
*/
suspend fun finishRegistrationOrCreateProfile(
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
restoreTimeout: Duration = 10.seconds
) {
if (hasProfileNameAndAvatar()) {
Log.i(TAG, "[finishRegistrationOrCreateProfile] Profile name + avatar already on disk; finishing.")
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
return
}
Log.i(TAG, "[finishRegistrationOrCreateProfile] Profile data incomplete; attempting best-effort account-record restore (timeout=${restoreTimeout.inWholeSeconds}s).")
restoreAccountRecord(restoreTimeout)
Log.i(TAG, "[finishRegistrationOrCreateProfile] Account-record restore finished; finishing without routing to Profile screen.")
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
private suspend fun hasProfileNameAndAvatar(): Boolean {
val stored = getStoredProfileData()
return stored.givenName.isNotEmpty() && stored.avatar != null
}
/**
* Persists the freshly-created profile to local storage and arranges for it to be uploaded.
* See [NetworkController.setProfile].
*/
suspend fun setProfile(
givenName: String,
familyName: String,
avatar: ByteArray?,
discoverableByPhoneNumber: Boolean
): RequestResult<Unit, NetworkController.SetProfileError> = withContext(Dispatchers.IO) {
networkController.setProfile(givenName, familyName, avatar, discoverableByPhoneNumber)
}
suspend fun setNewlyCreatedPin(
pin: String,
isAlphanumeric: Boolean,
@@ -121,6 +121,54 @@ interface StorageController {
* @return A list of [LocalBackupInfo] sorted by date descending (most recent first).
*/
suspend fun scanLocalBackupFolder(folderUri: Uri): List<LocalBackupInfo>
/**
* Reads any profile data already on disk for the locally-registered account. May return data when
* the user is re-registering (the previous profile name/avatar are still on the device) or after a
* storage-service account record restore has populated them.
*
* Returned fields are individually populated — any subset may be empty/null. The caller decides
* what to do with partial data (typically: pre-seed the create-profile form, or skip the screen
* altogether if everything is already present).
*/
suspend fun getStoredProfileData(): StoredProfileData
}
/**
* Snapshot of profile data already present on the device — used to pre-seed (or auto-skip) the
* create-profile screen during registration.
*
* [discoverableByPhoneNumber] is null when the device has no opinion yet (UNDECIDED on Android), in
* which case callers should default to discoverable.
*/
data class StoredProfileData(
val givenName: String = "",
val familyName: String = "",
val avatar: ByteArray? = null,
val discoverableByPhoneNumber: Boolean? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is StoredProfileData) return false
if (givenName != other.givenName) return false
if (familyName != other.familyName) return false
if (avatar != null) {
if (other.avatar == null) return false
if (!avatar.contentEquals(other.avatar)) return false
} else if (other.avatar != null) {
return false
}
if (discoverableByPhoneNumber != other.discoverableByPhoneNumber) return false
return true
}
override fun hashCode(): Int {
var result = givenName.hashCode()
result = 31 * result + familyName.hashCode()
result = 31 * result + (avatar?.contentHashCode() ?: 0)
result = 31 * result + (discoverableByPhoneNumber?.hashCode() ?: 0)
return result
}
}
/**
@@ -0,0 +1,24 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.fonts
import android.graphics.Typeface
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
/**
* Special monospace font, primarily used for rendering AEPs.
*/
object MonoTypeface {
private var cached: Typeface? = null
@Composable
fun fontFamily(): FontFamily {
val context = LocalContext.current
return FontFamily(cached ?: Typeface.createFromAsset(context.assets, "fonts/MonoSpecial-Regular.otf").also { cached = it })
}
}
@@ -38,7 +38,6 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
@@ -51,6 +50,7 @@ import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.registration.R
import org.signal.registration.fonts.MonoTypeface
import org.signal.registration.screens.OnePaneRegistrationScaffold
import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.screens.TwoPaneRegistrationScaffold
@@ -213,7 +213,7 @@ private fun RecoveryKeyTextField(state: EnterAepState, onEvent: (EnterAepEvents)
},
label = { Text(stringResource(R.string.EnterAepScreen__recovery_key)) },
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FontFamily.Monospace,
fontFamily = MonoTypeface.fontFamily(),
lineHeight = 36.sp
),
colors = TextFieldDefaults.colors(
@@ -0,0 +1,400 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.createprofile
import android.graphics.BitmapFactory
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScaffold
/**
* Profile creation screen for the registration flow. Captures the user's given name, family name,
* avatar, and phone-number discoverability before completing registration.
*
* Dispatches to a per-[WindowBreakpoint] layout following the pattern in `WelcomeScreen`. All three
* breakpoints currently share the [CompactLayout] body — Medium/Large variants can be split out
* later without changing this entry point.
*/
@Composable
fun CreateProfileScreen(
state: CreateProfileState,
onEvent: (CreateProfileScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val pickAvatarLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()
if (bytes != null) {
onEvent(CreateProfileScreenEvents.AvatarSelected(bytes))
}
}
}
val onAvatarClick = {
pickAvatarLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
LaunchedEffect(state.oneTimeEvent) {
if (state.oneTimeEvent != null) {
onEvent(CreateProfileScreenEvents.ConsumeOneTimeEvent)
}
}
if (state.isLoading) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
when (rememberWindowBreakpoint()) {
is WindowBreakpoint.Small -> CompactLayout(state, onEvent, onAvatarClick, modifier)
is WindowBreakpoint.Medium -> MediumLayout(state, onEvent, onAvatarClick, modifier)
is WindowBreakpoint.Large -> LargeLayout(state, onEvent, onAvatarClick, modifier)
}
}
@Composable
private fun CompactLayout(
state: CreateProfileState,
onEvent: (CreateProfileScreenEvents) -> Unit,
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScaffold(
modifier = modifier.fillMaxSize(),
content = {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.CreateProfileScreen__set_up_your_profile),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.CreateProfileScreen__your_profile_is_end_to_end_encrypted),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
Avatar(avatarBytes = state.avatar, onClick = onAvatarClick)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = state.givenName,
onValueChange = { onEvent(CreateProfileScreenEvents.GivenNameChanged(it)) },
label = { Text(stringResource(R.string.CreateProfileScreen__first_name_required)) },
singleLine = true,
enabled = !state.isSubmitting,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = state.familyName,
onValueChange = { onEvent(CreateProfileScreenEvents.FamilyNameChanged(it)) },
label = { Text(stringResource(R.string.CreateProfileScreen__last_name_optional)) },
singleLine = true,
enabled = !state.isSubmitting,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Done
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
WhoCanFindMeRow(
discoverable = state.discoverableByPhoneNumber,
enabled = !state.isSubmitting,
onClick = { onEvent(CreateProfileScreenEvents.WhoCanFindMeClicked) }
)
}
},
footer = {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Buttons.LargeTonal(
onClick = { onEvent(CreateProfileScreenEvents.NextClicked) },
enabled = state.isFormValid && !state.isSubmitting,
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 320.dp)
) {
if (state.isSubmitting) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.onSecondaryContainer,
strokeWidth = 2.dp,
modifier = Modifier.size(20.dp)
)
} else {
Text(stringResource(R.string.CreateProfileScreen__next))
}
}
}
}
)
}
@Composable
private fun MediumLayout(
state: CreateProfileState,
onEvent: (CreateProfileScreenEvents) -> Unit,
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier
) {
// TODO [registration] dedicated medium-width layout. For now, reuse the compact body.
CompactLayout(state = state, onEvent = onEvent, onAvatarClick = onAvatarClick, modifier = modifier)
}
@Composable
private fun LargeLayout(
state: CreateProfileState,
onEvent: (CreateProfileScreenEvents) -> Unit,
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier
) {
// TODO [registration] dedicated large-width layout. For now, reuse the compact body.
CompactLayout(state = state, onEvent = onEvent, onAvatarClick = onAvatarClick, modifier = modifier)
}
@Composable
private fun WhoCanFindMeRow(
discoverable: Boolean,
enabled: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = if (discoverable) painterResource(R.drawable.symbol_group_24) else SignalIcons.Lock.painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.padding(horizontal = 8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.WhoCanSeeMyPhoneNumberFragment__who_can_find_me_by_number),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = stringResource(
if (discoverable) R.string.PhoneNumberPrivacy_everyone else R.string.PhoneNumberPrivacy_nobody
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
painter = SignalIcons.ChevronRight.painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun Avatar(
avatarBytes: ByteArray?,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val bitmap = remember(avatarBytes) {
avatarBytes?.let {
runCatching { BitmapFactory.decodeByteArray(it, 0, it.size) }.getOrNull()
}
}
Box(modifier = modifier.size(112.dp)) {
Box(
modifier = Modifier
.size(112.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (bitmap != null) {
androidx.compose.foundation.Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = stringResource(R.string.CreateProfileScreen__set_avatar_description),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
} else {
Icon(
painter = SignalIcons.Camera.painter,
contentDescription = stringResource(R.string.CreateProfileScreen__set_avatar_description),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(40.dp)
)
}
}
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = 4.dp, y = 4.dp)
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
painter = SignalIcons.Camera.painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(20.dp)
)
}
}
}
@AllDevicePreviews
@Composable
private fun CreateProfileScreenLoadingPreview() {
Previews.Preview {
CreateProfileScreen(
state = CreateProfileState(isLoading = true),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun CreateProfileScreenEmptyPreview() {
Previews.Preview {
CreateProfileScreen(
state = CreateProfileState(isLoading = false),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun CreateProfileScreenWithNamePreview() {
Previews.Preview {
CreateProfileScreen(
state = CreateProfileState(
givenName = "Alice",
familyName = "Anderson",
isLoading = false
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun CreateProfileScreenNobodyPreview() {
Previews.Preview {
CreateProfileScreen(
state = CreateProfileState(
givenName = "Alice",
familyName = "Anderson",
discoverableByPhoneNumber = false,
isLoading = false
),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun CreateProfileScreenSubmittingPreview() {
Previews.Preview {
CreateProfileScreen(
state = CreateProfileState(
givenName = "Alice",
familyName = "Anderson",
isLoading = false,
isSubmitting = true
),
onEvent = {}
)
}
}
@@ -0,0 +1,27 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.createprofile
import org.signal.registration.util.DebugLoggableModel
sealed class CreateProfileScreenEvents : DebugLoggableModel() {
data class GivenNameChanged(val value: String) : CreateProfileScreenEvents()
data class FamilyNameChanged(val value: String) : CreateProfileScreenEvents()
data class AvatarSelected(val bytes: ByteArray) : CreateProfileScreenEvents() {
override fun toSafeString(): String = "AvatarSelected(${bytes.size} bytes)"
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AvatarSelected) return false
return bytes.contentEquals(other.bytes)
}
override fun hashCode(): Int = bytes.contentHashCode()
}
data object AvatarCleared : CreateProfileScreenEvents()
data object WhoCanFindMeClicked : CreateProfileScreenEvents()
data class DiscoverabilityChanged(val discoverable: Boolean) : CreateProfileScreenEvents()
data object NextClicked : CreateProfileScreenEvents()
data object ConsumeOneTimeEvent : CreateProfileScreenEvents()
}
@@ -0,0 +1,62 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.createprofile
import org.signal.registration.util.DebugLoggable
import org.signal.registration.util.DebugLoggableModel
data class CreateProfileState(
val givenName: String = "",
val familyName: String = "",
val avatar: ByteArray? = null,
val discoverableByPhoneNumber: Boolean = true,
val isLoading: Boolean = true,
val isSubmitting: Boolean = false,
val oneTimeEvent: OneTimeEvent? = null
) : DebugLoggableModel() {
val isFormValid: Boolean
get() = givenName.trim().isNotEmpty()
override fun toSafeString(): String {
return "CreateProfileState(givenName=${givenName.length} chars, familyName=${familyName.length} chars, avatar=${avatar?.size ?: 0} bytes, discoverableByPhoneNumber=$discoverableByPhoneNumber, isLoading=$isLoading, isSubmitting=$isSubmitting, oneTimeEvent=$oneTimeEvent)"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is CreateProfileState) return false
if (givenName != other.givenName) return false
if (familyName != other.familyName) return false
if (avatar != null) {
if (other.avatar == null) return false
if (!avatar.contentEquals(other.avatar)) return false
} else if (other.avatar != null) {
return false
}
if (discoverableByPhoneNumber != other.discoverableByPhoneNumber) return false
if (isLoading != other.isLoading) return false
if (isSubmitting != other.isSubmitting) return false
if (oneTimeEvent != other.oneTimeEvent) return false
return true
}
override fun hashCode(): Int {
var result = givenName.hashCode()
result = 31 * result + familyName.hashCode()
result = 31 * result + (avatar?.contentHashCode() ?: 0)
result = 31 * result + discoverableByPhoneNumber.hashCode()
result = 31 * result + isLoading.hashCode()
result = 31 * result + isSubmitting.hashCode()
result = 31 * result + (oneTimeEvent?.hashCode() ?: 0)
return result
}
sealed interface OneTimeEvent : DebugLoggable {
data object UploadFailed : OneTimeEvent {
override fun toString(): String = "UploadFailed"
}
}
}
@@ -0,0 +1,151 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.createprofile
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import org.signal.registration.screens.EventDrivenViewModel
import org.signal.registration.screens.util.navigateTo
/**
* ViewModel for the registration profile-creation screen. Holds the user's typed name and selected
* avatar bytes and submits them to [RegistrationRepository.setProfile] when the user advances.
*/
class CreateProfileViewModel(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<CreateProfileScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(CreateProfileViewModel::class)
}
private val _state = MutableStateFlow(CreateProfileState())
val state: StateFlow<CreateProfileState> = _state
init {
viewModelScope.launch {
val stored = repository.getStoredProfileData()
Log.i(TAG, "[init] Loaded stored profile data. givenName=${stored.givenName.isNotEmpty()}, familyName=${stored.familyName.isNotEmpty()}, avatar=${stored.avatar != null}")
val seeded = _state.value.copy(
givenName = stored.givenName,
familyName = stored.familyName,
avatar = stored.avatar,
discoverableByPhoneNumber = stored.discoverableByPhoneNumber ?: true,
isLoading = false
)
if (stored.givenName.isNotEmpty() && stored.avatar != null) {
Log.i(TAG, "[init] Profile name + avatar already present. Auto-submitting and skipping screen.")
_state.value = seeded.copy(isSubmitting = true)
submitProfile(seeded)
} else {
_state.value = seeded
}
}
}
override suspend fun processEvent(event: CreateProfileScreenEvents) {
applyEvent(state.value, event, parentEventEmitter, repository) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(
state: CreateProfileState,
event: CreateProfileScreenEvents,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
repository: RegistrationRepository,
stateEmitter: (CreateProfileState) -> Unit
) {
when (event) {
is CreateProfileScreenEvents.GivenNameChanged -> {
stateEmitter(state.copy(givenName = event.value))
}
is CreateProfileScreenEvents.FamilyNameChanged -> {
stateEmitter(state.copy(familyName = event.value))
}
is CreateProfileScreenEvents.AvatarSelected -> {
stateEmitter(state.copy(avatar = event.bytes))
}
CreateProfileScreenEvents.AvatarCleared -> {
stateEmitter(state.copy(avatar = null))
}
CreateProfileScreenEvents.WhoCanFindMeClicked -> {
parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberDiscoverability(state.discoverableByPhoneNumber))
}
is CreateProfileScreenEvents.DiscoverabilityChanged -> {
stateEmitter(state.copy(discoverableByPhoneNumber = event.discoverable))
}
CreateProfileScreenEvents.NextClicked -> {
if (state.isSubmitting || !state.isFormValid) {
return
}
stateEmitter(state.copy(isSubmitting = true))
submitProfile(state, parentEventEmitter, repository, stateEmitter)
}
CreateProfileScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
}
}
}
private suspend fun submitProfile(state: CreateProfileState) {
submitProfile(state, parentEventEmitter, repository) { _state.value = it }
}
private suspend fun submitProfile(
state: CreateProfileState,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
repository: RegistrationRepository,
stateEmitter: (CreateProfileState) -> Unit
) {
val result = repository.setProfile(
givenName = state.givenName.trim(),
familyName = state.familyName.trim(),
avatar = state.avatar,
discoverableByPhoneNumber = state.discoverableByPhoneNumber
)
when (result) {
is RequestResult.Success -> {
Log.i(TAG, "[submitProfile] Profile saved.")
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
}
is RequestResult.NonSuccess -> {
Log.w(TAG, "[submitProfile] Profile save failed: ${result.error}")
stateEmitter(state.copy(isSubmitting = false, oneTimeEvent = CreateProfileState.OneTimeEvent.UploadFailed))
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "[submitProfile] Network error saving profile.", result.networkError)
stateEmitter(state.copy(isSubmitting = false, oneTimeEvent = CreateProfileState.OneTimeEvent.UploadFailed))
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "[submitProfile] Application error saving profile.", result.cause)
stateEmitter(state.copy(isSubmitting = false, oneTimeEvent = CreateProfileState.OneTimeEvent.UploadFailed))
}
}
}
class Factory(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return CreateProfileViewModel(repository, parentEventEmitter) as T
}
}
}
@@ -29,7 +29,7 @@ import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import org.signal.registration.screens.RegistrationScaffold
@Composable
fun DeviceTransferCompleteScreen(
@@ -39,7 +39,7 @@ fun DeviceTransferCompleteScreen(
) {
BackHandler(enabled = true) { /* no-op: the transfer is done, don't let the user back out */ }
RegistrationScreen(
RegistrationScaffold(
modifier = modifier.fillMaxSize(),
content = {
Column(
@@ -12,9 +12,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
import org.signal.registration.screens.EventDrivenViewModel
class DeviceTransferCompleteViewModel(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : EventDrivenViewModel<DeviceTransferCompleteScreenEvents>(TAG) {
@@ -26,7 +28,7 @@ class DeviceTransferCompleteViewModel(
val state: StateFlow<DeviceTransferCompleteState> = _state
override suspend fun processEvent(event: DeviceTransferCompleteScreenEvents) {
applyEvent(state.value, event, parentEventEmitter) { _state.value = it }
applyEvent(state.value, event, parentEventEmitter, repository) { _state.value = it }
}
@VisibleForTesting
@@ -34,11 +36,12 @@ class DeviceTransferCompleteViewModel(
state: DeviceTransferCompleteState,
event: DeviceTransferCompleteScreenEvents,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
repository: RegistrationRepository,
stateEmitter: (DeviceTransferCompleteState) -> Unit
) {
when (event) {
DeviceTransferCompleteScreenEvents.ContinueClicked -> {
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
repository.finishRegistrationOrCreateProfile(parentEventEmitter)
}
DeviceTransferCompleteScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
@@ -47,11 +50,12 @@ class DeviceTransferCompleteViewModel(
}
class Factory(
private val repository: RegistrationRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return DeviceTransferCompleteViewModel(parentEventEmitter) as T
return DeviceTransferCompleteViewModel(repository, parentEventEmitter) as T
}
}
}
@@ -28,7 +28,7 @@ import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import org.signal.registration.screens.RegistrationScaffold
@Composable
fun DeviceTransferInstructionsScreen(
@@ -36,7 +36,7 @@ fun DeviceTransferInstructionsScreen(
onEvent: (DeviceTransferInstructionsScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
RegistrationScaffold(
modifier = modifier.fillMaxSize(),
content = {
Column(
@@ -29,7 +29,7 @@ import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import org.signal.registration.screens.RegistrationScaffold
@Composable
fun DeviceTransferProgressScreen(
@@ -54,7 +54,7 @@ fun DeviceTransferProgressScreen(
)
}
RegistrationScreen(
RegistrationScaffold(
modifier = modifier.fillMaxSize(),
content = {
Column(
@@ -46,7 +46,7 @@ import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.devicetransfer.WifiDirect
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import org.signal.registration.screens.RegistrationScaffold
import java.util.Locale
@Composable
@@ -128,7 +128,7 @@ private fun DeviceTransferSetupScreen(
)
}
RegistrationScreen(
RegistrationScaffold(
modifier = modifier.fillMaxSize(),
content = {
Column(
@@ -316,7 +316,7 @@ private fun TroubleshootingStep(onTryAgain: () -> Unit) {
@Composable
private fun DeviceTransferSetupScreenVerifyPreview() {
Previews.Preview {
RegistrationScreen(
RegistrationScaffold(
content = {
Column(
modifier = Modifier
@@ -336,7 +336,7 @@ private fun DeviceTransferSetupScreenVerifyPreview() {
@Composable
private fun DeviceTransferSetupScreenProgressPreview() {
Previews.Preview {
RegistrationScreen(
RegistrationScaffold(
content = {
Column(
modifier = Modifier
@@ -0,0 +1,187 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.discoverability
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScaffold
@Composable
fun PhoneNumberDiscoverabilityScreen(
state: PhoneNumberDiscoverabilityState,
onEvent: (PhoneNumberDiscoverabilityScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScaffold(
modifier = modifier.fillMaxSize(),
topBar = {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { onEvent(PhoneNumberDiscoverabilityScreenEvents.BackClicked) }) {
Icon(
imageVector = SignalIcons.ArrowStart.imageVector,
contentDescription = stringResource(R.string.PhoneNumberDiscoverabilityScreen__back)
)
}
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
Text(
text = stringResource(R.string.WhoCanSeeMyPhoneNumberFragment__who_can_find_me_by_number),
style = MaterialTheme.typography.titleLarge
)
}
},
content = {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp)
) {
DiscoverabilityOption(
label = stringResource(R.string.PhoneNumberPrivacy_everyone),
selected = state.discoverable,
onClick = { onEvent(PhoneNumberDiscoverabilityScreenEvents.EveryoneSelected) }
)
DiscoverabilityOption(
label = stringResource(R.string.PhoneNumberPrivacy_nobody),
selected = !state.discoverable,
onClick = { onEvent(PhoneNumberDiscoverabilityScreenEvents.NobodySelected) }
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(
if (state.discoverable) {
R.string.WhoCanSeeMyPhoneNumberFragment__anyone_who_has_your
} else {
R.string.WhoCanSeeMyPhoneNumberFragment__nobody_will_be_able
}
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
},
footer = {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Buttons.LargeTonal(
onClick = { onEvent(PhoneNumberDiscoverabilityScreenEvents.SaveClicked) },
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 320.dp)
) {
Text(stringResource(R.string.PhoneNumberDiscoverabilityScreen__save))
}
}
}
)
if (state.showNobodyConfirmation) {
AlertDialog(
onDismissRequest = { onEvent(PhoneNumberDiscoverabilityScreenEvents.NobodyDismissed) },
title = { Text(stringResource(R.string.PhoneNumberPrivacySettingsFragment__nobody_can_find_me_warning_title)) },
text = { Text(stringResource(R.string.PhoneNumberPrivacySettingsFragment__nobody_can_find_me_warning_message)) },
dismissButton = {
TextButton(onClick = { onEvent(PhoneNumberDiscoverabilityScreenEvents.NobodyDismissed) }) {
Text(stringResource(R.string.PhoneNumberPrivacySettingsFragment__cancel))
}
},
confirmButton = {
TextButton(onClick = { onEvent(PhoneNumberDiscoverabilityScreenEvents.NobodyConfirmed) }) {
Text(stringResource(android.R.string.ok))
}
}
)
}
}
@Composable
private fun DiscoverabilityOption(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
RadioButton(selected = selected, onClick = onClick)
Spacer(modifier = Modifier.padding(horizontal = 8.dp))
Text(text = label, style = MaterialTheme.typography.bodyLarge)
}
}
@AllDevicePreviews
@Composable
private fun PhoneNumberDiscoverabilityScreenEveryonePreview() {
Previews.Preview {
PhoneNumberDiscoverabilityScreen(
state = PhoneNumberDiscoverabilityState(discoverable = true),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun PhoneNumberDiscoverabilityScreenNobodyPreview() {
Previews.Preview {
PhoneNumberDiscoverabilityScreen(
state = PhoneNumberDiscoverabilityState(discoverable = false),
onEvent = {}
)
}
}
@AllDevicePreviews
@Composable
private fun PhoneNumberDiscoverabilityScreenConfirmPreview() {
Previews.Preview {
PhoneNumberDiscoverabilityScreen(
state = PhoneNumberDiscoverabilityState(discoverable = true, showNobodyConfirmation = true),
onEvent = {}
)
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.discoverability
import org.signal.registration.util.DebugLoggableModel
sealed class PhoneNumberDiscoverabilityScreenEvents : DebugLoggableModel() {
data object EveryoneSelected : PhoneNumberDiscoverabilityScreenEvents()
data object NobodySelected : PhoneNumberDiscoverabilityScreenEvents()
data object NobodyConfirmed : PhoneNumberDiscoverabilityScreenEvents()
data object NobodyDismissed : PhoneNumberDiscoverabilityScreenEvents()
data object SaveClicked : PhoneNumberDiscoverabilityScreenEvents()
data object BackClicked : PhoneNumberDiscoverabilityScreenEvents()
}
@@ -0,0 +1,13 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.discoverability
import org.signal.registration.util.DebugLoggableModel
data class PhoneNumberDiscoverabilityState(
val discoverable: Boolean = true,
val showNobodyConfirmation: Boolean = false
) : DebugLoggableModel()
@@ -0,0 +1,80 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.discoverability
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.signal.core.ui.navigation.ResultEventBus
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.screens.EventDrivenViewModel
import org.signal.registration.screens.util.navigateBack
class PhoneNumberDiscoverabilityViewModel(
initialDiscoverable: Boolean,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val resultBus: ResultEventBus,
private val resultKey: String
) : EventDrivenViewModel<PhoneNumberDiscoverabilityScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(PhoneNumberDiscoverabilityViewModel::class)
}
private val _state = MutableStateFlow(PhoneNumberDiscoverabilityState(discoverable = initialDiscoverable))
val state: StateFlow<PhoneNumberDiscoverabilityState> = _state
override suspend fun processEvent(event: PhoneNumberDiscoverabilityScreenEvents) {
applyEvent(state.value, event, parentEventEmitter, resultBus, resultKey) { _state.value = it }
}
@VisibleForTesting
suspend fun applyEvent(
state: PhoneNumberDiscoverabilityState,
event: PhoneNumberDiscoverabilityScreenEvents,
parentEventEmitter: (RegistrationFlowEvent) -> Unit,
resultBus: ResultEventBus,
resultKey: String,
stateEmitter: (PhoneNumberDiscoverabilityState) -> Unit
) {
when (event) {
PhoneNumberDiscoverabilityScreenEvents.EveryoneSelected -> {
stateEmitter(state.copy(discoverable = true))
}
PhoneNumberDiscoverabilityScreenEvents.NobodySelected -> {
stateEmitter(state.copy(showNobodyConfirmation = true))
}
PhoneNumberDiscoverabilityScreenEvents.NobodyConfirmed -> {
stateEmitter(state.copy(discoverable = false, showNobodyConfirmation = false))
}
PhoneNumberDiscoverabilityScreenEvents.NobodyDismissed -> {
stateEmitter(state.copy(showNobodyConfirmation = false))
}
PhoneNumberDiscoverabilityScreenEvents.SaveClicked -> {
resultBus.sendResult(resultKey, state.discoverable)
parentEventEmitter.navigateBack()
}
PhoneNumberDiscoverabilityScreenEvents.BackClicked -> {
parentEventEmitter.navigateBack()
}
}
}
class Factory(
private val initialDiscoverable: Boolean,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val resultBus: ResultEventBus,
private val resultKey: String
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PhoneNumberDiscoverabilityViewModel(initialDiscoverable, parentEventEmitter, resultBus, resultKey) as T
}
}
}
@@ -109,7 +109,7 @@ class LocalBackupRestoreViewModel(
startRestore(backup, state.selectedFolderUri, credential, aep)
}
private fun onRestoreComplete(state: LocalBackupRestoreState) {
private suspend fun onRestoreComplete(state: LocalBackupRestoreState) {
if (state.aep != null) {
parentEventEmitter(RegistrationFlowEvent.UserSuppliedAepVerified(state.aep))
}
@@ -118,7 +118,7 @@ class LocalBackupRestoreViewModel(
resultBus.sendResult(resultKey, LocalBackupRestoreResult.Success(state.aep))
parentEventEmitter.navigateBack()
} else {
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
repository.finishRegistrationOrCreateProfile(parentEventEmitter)
}
}
@@ -90,8 +90,7 @@ class PinCreationViewModel(
return when (val result = repository.setNewlyCreatedPin(pin, state.isAlphanumericKeyboard, masterKey)) {
is RequestResult.Success -> {
Log.i(TAG, "[PinSubmitted] Successfully backed up master key to SVR.")
// TODO profile creation
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
repository.finishRegistrationOrCreateProfile(parentEventEmitter)
state
}
@@ -135,10 +135,9 @@ class PinEntryForRegistrationLockViewModel(
Log.i(TAG, "[PinEntered] Successfully registered!")
val (response, keyMaterial) = registerResult.result
parentEventEmitter(RegistrationFlowEvent.Registered(keyMaterial.accountEntropyPool))
// TODO storage service restore + profile screen
when {
response.reregistration -> parentEventEmitter.navigateTo(RegistrationRoute.ArchiveRestoreSelection.forPostRegister())
else -> parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
else -> repository.finishRegistrationOrCreateProfile(parentEventEmitter)
}
state
}
@@ -150,8 +150,8 @@ class PinEntryForSmsBypassViewModel(
return when (val result = repository.registerAccountWithRecoveryPassword(e164, recoveryPassword, registrationLock, skipDeviceTransfer = true)) {
is RequestResult.Success -> {
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
repository.enqueueSvrResetGuessCountJob()
repository.finishRegistrationOrCreateProfile(parentEventEmitter)
state
}
is RequestResult.RetryableNetworkError -> {
@@ -116,7 +116,7 @@ class PinEntryForSvrRestoreViewModel(
Log.i(TAG, "[PinEntered] Successfully restored master key from SVR.")
repository.enqueueSvrResetGuessCountJob()
parentEventEmitter(RegistrationFlowEvent.MasterKeyRestoredFromSvr(result.result.masterKey))
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
repository.finishRegistrationOrCreateProfile(parentEventEmitter)
state
}
is RequestResult.NonSuccess -> {
@@ -116,7 +116,7 @@ class RemoteBackupRestoreViewModel(
restoreProgress = null
)
parentEventEmitter(RegistrationFlowEvent.UserSuppliedAepVerified(aep))
parentEventEmitter(RegistrationFlowEvent.RegistrationComplete)
repository.finishRegistrationOrCreateProfile(parentEventEmitter)
}
is RemoteBackupRestoreProgress.NetworkError -> {
Log.w(TAG, "Remote restore failed with network error", progress.cause)
@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M13 7.15c0-2.13 1.43-4.03 3.5-4.03 2.07 0 3.5 1.9 3.5 4.03 0 1.08-0.35 2.1-0.95 2.88-0.6 0.78-1.5 1.35-2.55 1.35-1.06 0-1.95-0.57-2.55-1.35C13.35 9.26 13 8.23 13 7.15Zm3.5-2.28c-0.83 0-1.75 0.82-1.75 2.28 0 0.71 0.23 1.36 0.59 1.81 0.35 0.46 0.77 0.66 1.16 0.66 0.4 0 0.81-0.2 1.16-0.66 0.36-0.45 0.59-1.1 0.59-1.81 0-1.46-0.92-2.28-1.75-2.28Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M7.5 12.63c1.12 0 2.19 0.24 3.14 0.67-0.47 0.41-0.89 0.87-1.24 1.38-0.58-0.2-1.22-0.3-1.9-0.3-2.68 0-4.72 1.7-5.07 3.74h5.72l-0.03 0.63c0 0.39 0.03 0.76 0.1 1.13H1.75c-0.6 0-1.14-0.48-1.14-1.13 0-3.49 3.2-6.13 6.88-6.13Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M16.5 12.63c-3.68 0-6.88 2.63-6.88 6.12 0 0.65 0.54 1.13 1.14 1.13h11.48c0.6 0 1.14-0.48 1.14-1.13 0-3.49-3.2-6.13-6.88-6.13Zm0 1.74c2.68 0 4.72 1.71 5.07 3.76H11.43c0.35-2.05 2.4-3.75 5.07-3.75Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M7.5 3.13C5.43 3.13 4 5.01 4 7.14c0 1.08 0.35 2.1 0.95 2.88 0.6 0.78 1.5 1.35 2.55 1.35 1.06 0 1.95-0.57 2.55-1.35 0.6-0.77 0.95-1.8 0.95-2.88 0-2.13-1.43-4.03-3.5-4.03ZM5.75 7.14c0-1.46 0.92-2.28 1.75-2.28S9.25 5.7 9.25 7.15c0 0.71-0.24 1.36-0.59 1.81C8.31 9.42 7.9 9.62 7.5 9.62c-0.4 0-0.81-0.2-1.16-0.66-0.35-0.45-0.59-1.1-0.59-1.81Z"/>
</vector>
@@ -445,4 +445,40 @@
<string name="DeviceTransferComplete__transfer_complete">Transfer complete</string>
<string name="DeviceTransferComplete__your_account_is_now_on_this_device">Your account is now on this device.</string>
<string name="DeviceTransferComplete__continue_registration">Continue</string>
<!-- Create profile screen -->
<!-- Title displayed at the top of the create-profile screen during registration. -->
<string name="CreateProfileScreen__set_up_your_profile">Set up your profile</string>
<!-- Subtitle on create-profile screen explaining that the profile is end-to-end encrypted and visible to recipients. -->
<string name="CreateProfileScreen__your_profile_is_end_to_end_encrypted">Your profile and changes to it will be visible to people you message, contacts, and groups.</string>
<!-- Label/hint for the first-name field, indicating it is required. -->
<string name="CreateProfileScreen__first_name_required">First name (required)</string>
<!-- Label/hint for the last-name field, indicating it is optional. -->
<string name="CreateProfileScreen__last_name_optional">Last name (optional)</string>
<!-- Action button text to advance from create-profile screen. -->
<string name="CreateProfileScreen__next">Next</string>
<!-- Content description for the avatar selection control. -->
<string name="CreateProfileScreen__set_avatar_description">Set avatar</string>
<!-- Phone number discoverability picker -->
<!-- Title row label shown on the create-profile screen pointing at the discoverability picker. -->
<string name="WhoCanSeeMyPhoneNumberFragment__who_can_find_me_by_number">Who can find me by number?</string>
<!-- Description shown on the picker when "Everyone" is selected. -->
<string name="WhoCanSeeMyPhoneNumberFragment__anyone_who_has_your">Anyone who has your phone number will see you\'re on Signal and can start chats with you.</string>
<!-- Description shown on the picker when "Nobody" is selected. -->
<string name="WhoCanSeeMyPhoneNumberFragment__nobody_will_be_able">Nobody will be able to see you\'re on Signal unless you message them or have an existing chat with them.</string>
<!-- Radio option label for "Everyone can find me by number". -->
<string name="PhoneNumberPrivacy_everyone">Everyone</string>
<!-- Radio option label for "Nobody can find me by number". -->
<string name="PhoneNumberPrivacy_nobody">Nobody</string>
<!-- Title for the confirmation dialog shown when the user selects "Nobody". -->
<string name="PhoneNumberPrivacySettingsFragment__nobody_can_find_me_warning_title">Are you sure?</string>
<!-- Body for the confirmation dialog shown when the user selects "Nobody". -->
<string name="PhoneNumberPrivacySettingsFragment__nobody_can_find_me_warning_message">Setting \"Who can find me by number\" to \"Nobody\" will make it harder for people to find you on Signal.</string>
<!-- Cancel button on the "Nobody" confirmation dialog. -->
<string name="PhoneNumberPrivacySettingsFragment__cancel">Cancel</string>
<!-- Save button on the discoverability picker. -->
<string name="PhoneNumberDiscoverabilityScreen__save">Save</string>
<!-- Content description for the back arrow on the discoverability picker. -->
<string name="PhoneNumberDiscoverabilityScreen__back">Back</string>
</resources>
@@ -5,8 +5,8 @@
package org.signal.registration.screens.devicetransfer.complete
import assertk.assertThat
import assertk.assertions.containsExactly
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -17,12 +17,14 @@ import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceTransferCompleteViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var viewModel: DeviceTransferCompleteViewModel
private lateinit var mockRepository: RegistrationRepository
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var emittedStates: MutableList<DeviceTransferCompleteState>
@@ -31,11 +33,12 @@ class DeviceTransferCompleteViewModelTest {
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockRepository = mockk(relaxed = true)
emittedEvents = mutableListOf()
parentEventEmitter = { emittedEvents.add(it) }
emittedStates = mutableListOf()
stateEmitter = { emittedStates.add(it) }
viewModel = DeviceTransferCompleteViewModel(parentEventEmitter)
viewModel = DeviceTransferCompleteViewModel(mockRepository, parentEventEmitter)
testDispatcher.scheduler.advanceUntilIdle()
}
@@ -45,14 +48,15 @@ class DeviceTransferCompleteViewModelTest {
}
@Test
fun `ContinueClicked emits RegistrationComplete`() = runTest {
fun `ContinueClicked hands off to finishRegistrationOrCreateProfile`() = runTest {
viewModel.applyEvent(
DeviceTransferCompleteState(),
DeviceTransferCompleteScreenEvents.ContinueClicked,
parentEventEmitter,
mockRepository,
stateEmitter
)
assertThat(emittedEvents).containsExactly(RegistrationFlowEvent.RegistrationComplete)
coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) }
}
}
@@ -10,6 +10,7 @@ import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -62,7 +63,7 @@ class PinCreationViewModelTest {
// ==================== PinSubmitted Success Tests ====================
@Test
fun `PinSubmitted with valid AEP and successful SVR backup emits RegistrationComplete`() = runTest(testDispatcher) {
fun `PinSubmitted with valid AEP and successful SVR backup hands off to finishRegistrationOrCreateProfile`() = runTest(testDispatcher) {
val aep = AccountEntropyPool.generate()
val initialState = PinCreationState(accountEntropyPool = aep)
@@ -71,8 +72,7 @@ class PinCreationViewModelTest {
viewModel.applyEvent(initialState, PinCreationScreenEvents.PinSubmitted("123456"))
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents.first()).isEqualTo(RegistrationFlowEvent.RegistrationComplete)
coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) }
}
// ==================== PinSubmitted Missing AEP Test ====================
@@ -12,6 +12,7 @@ import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.prop
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@@ -80,10 +81,10 @@ class PinEntryForRegistrationLockViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(3)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isInstanceOf<RegistrationFlowEvent.Registered>()
assertThat(emittedParentEvents[2]).isInstanceOf<RegistrationFlowEvent.RegistrationComplete>()
coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) }
}
@Test
@@ -65,7 +65,7 @@ class PinEntryForSmsBypassViewModelTest {
// ==================== PinEntered - Restore Master Key Tests ====================
@Test
fun `PinEntered with correct PIN restores master key and registers successfully`() = runTest {
fun `PinEntered with correct PIN restores master key and hands off to finishRegistrationOrCreateProfile`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val initialState = PinEntryState(mode = PinEntryState.Mode.SmsBypass, e164 = "+15551234567")
@@ -76,9 +76,9 @@ class PinEntryForSmsBypassViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isInstanceOf<RegistrationFlowEvent.RegistrationComplete>()
coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) }
}
@Test
@@ -280,9 +280,9 @@ class PinEntryForSmsBypassViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isInstanceOf<RegistrationFlowEvent.RegistrationComplete>()
coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) }
}
@Test
@@ -11,6 +11,7 @@ import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.prop
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@@ -57,7 +58,7 @@ class PinEntryForSvrRestoreViewModelTest {
// ==================== PinEntered Success Tests ====================
@Test
fun `PinEntered with correct PIN restores master key and navigates to FullyComplete`() = runTest {
fun `PinEntered with correct PIN restores master key and hands off to finishRegistrationOrCreateProfile`() = runTest {
val masterKey = mockk<MasterKey>(relaxed = true)
val svrCredentials = NetworkController.SvrCredentials(
username = "test-username",
@@ -72,9 +73,9 @@ class PinEntryForSvrRestoreViewModelTest {
viewModel.applyEvent(initialState, PinEntryScreenEvents.PinEntered("123456"), parentEventEmitter, stateEmitter)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.MasterKeyRestoredFromSvr>()
assertThat(emittedParentEvents[1]).isInstanceOf<RegistrationFlowEvent.RegistrationComplete>()
coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) }
}
// ==================== GetSvrCredentials Error Tests ====================
@@ -157,7 +157,7 @@ class RemoteBackupRestoreViewModelTest {
// ==================== Restore Progress Tests ====================
@Test
fun `BackupRestoreBackup Complete progress emits RegistrationComplete and UserSuppliedAepVerified`() = runTest(testDispatcher) {
fun `BackupRestoreBackup Complete progress emits UserSuppliedAepVerified and hands off to finishRegistrationOrCreateProfile`() = runTest(testDispatcher) {
every { mockRepository.restoreRemoteBackup(any()) } returns flowOf(
RemoteBackupRestoreProgress.Complete
)
@@ -171,9 +171,9 @@ class RemoteBackupRestoreViewModelTest {
stateEmitter
)
assertThat(emittedParentEvents).hasSize(2)
assertThat(emittedParentEvents).hasSize(1)
assertThat(emittedParentEvents[0]).isInstanceOf<RegistrationFlowEvent.UserSuppliedAepVerified>()
assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.RegistrationComplete)
coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) }
}
@Test
@@ -169,7 +169,7 @@ import okio.Utf8;
*/
public class SignalServiceMessageSender {
private static final String TAG = SignalServiceMessageSender.class.getSimpleName();
private static final String TAG = SignalServiceMessageSender.class.getSimpleName().substring(0, 23);
private static final int RETRY_COUNT = 4;