mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-19 16:24:41 +01:00
Attempt to restore AccountRecord in regV5.
This commit is contained in:
committed by
Cody Henthorne
parent
754dd15c94
commit
118231a328
+65
@@ -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
|
||||
|
||||
+37
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
@@ -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")
|
||||
|
||||
+124
@@ -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
|
||||
|
||||
+10
@@ -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()
|
||||
|
||||
+48
@@ -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 = {}
|
||||
|
||||
+9
-1
@@ -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?
|
||||
)
|
||||
}
|
||||
|
||||
+9
@@ -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
|
||||
)
|
||||
|
||||
+22
@@ -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()) }
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
+43
-2
@@ -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 })
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+400
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+27
@@ -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()
|
||||
}
|
||||
+62
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+7
-3
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+187
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
@@ -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()
|
||||
}
|
||||
+13
@@ -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()
|
||||
+80
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 -> {
|
||||
|
||||
+1
-1
@@ -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 -> {
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+9
-5
@@ -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()) }
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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 ====================
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+4
-3
@@ -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 ====================
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user