diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt index f9da7f938e..9f266913f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt @@ -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 = 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 = 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 = withContext(Dispatchers.IO) { val serviceMethod = when (method) { NetworkController.RestoreMethod.REMOTE_BACKUP -> ServiceRestoreMethod.REMOTE_BACKUP diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt index 5b87770ca4..49daec4019 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt @@ -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()) { diff --git a/demo/registration/build.gradle.kts b/demo/registration/build.gradle.kts index dfc1250bff..241adc723a 100644 --- a/demo/registration/build.gradle.kts +++ b/demo/registration/build.gradle.kts @@ -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 diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt index 840e60dcc5..ae86bba340 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt @@ -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 { + NetworkDebugState.getOverride>("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 { + NetworkDebugState.getOverride>("restoreAccountRecord")?.let { + Log.d(TAG, "[restoreAccountRecord] Returning debug override") + return it + } + return delegate.restoreAccountRecord(timeout) + } + override suspend fun setRestoreMethod(token: String, method: RestoreMethod): RequestResult { NetworkDebugState.getOverride>("setRestoreMethod")?.let { Log.d(TAG, "[setRestoreMethod] Returning debug override") diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt index d4a6159277..095efa7184 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoNetworkController.kt @@ -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 = 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 = 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, 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 = withContext(Dispatchers.IO) { try { val baseUrl = serviceConfiguration.signalServiceUrls[0].url diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt index 4e3054d950..c0b92285e5 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt @@ -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() diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt index 492e1b065f..b90c147bef 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreen.kt @@ -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 = {} diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt index a7be11bbe7..9a5d9f25a1 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenState.kt @@ -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? + ) } diff --git a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt index 42eac2f5c5..2c0dce8132 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/screens/main/MainScreenViewModel.kt @@ -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 ) diff --git a/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt b/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt index 5fc939bb2d..a0c7106232 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/storage/RegistrationPreferences.kt @@ -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 get() = prefs.getStringSet(KEY_SVR2_CREDENTIALS, emptySet())?.mapNotNull { parseCredential(it) } ?: emptyList() set(value) = prefs.edit { putStringSet(KEY_SVR2_CREDENTIALS, value.map { serializeCredential(it) }.toSet()) } diff --git a/feature/registration/src/main/assets/fonts/MonoSpecial-Regular.otf b/feature/registration/src/main/assets/fonts/MonoSpecial-Regular.otf new file mode 100644 index 0000000000..314b20f5a8 Binary files /dev/null and b/feature/registration/src/main/assets/fonts/MonoSpecial-Regular.otf differ diff --git a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt index 2645e5ed6d..9dcff39fda 100644 --- a/feature/registration/src/main/java/org/signal/registration/NetworkController.kt +++ b/feature/registration/src/main/java/org/signal/registration/NetworkController.kt @@ -250,6 +250,39 @@ interface NetworkController { */ suspend fun setRestoreMethod(token: String, method: RestoreMethod): RequestResult + /** + * 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 + + /** + * 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 + // /** // * 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() diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index b92dee0400..9c6331466a 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -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.navigationEntries( // -- Device Transfer: Complete entry { 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.navigationEntries( } entry { - // TODO: Implement ProfileScreen + val viewModel: CreateProfileViewModel = viewModel( + factory = CreateProfileViewModel.Factory( + repository = registrationRepository, + parentEventEmitter = registrationViewModel::onEvent + ) + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + ResultEffect(registrationViewModel.resultBus, PHONE_NUMBER_DISCOVERABILITY_RESULT) { discoverable -> + viewModel.onEvent(CreateProfileScreenEvents.DiscoverabilityChanged(discoverable)) + } + + CreateProfileScreen( + state = state, + onEvent = viewModel::onEvent + ) + } + + entry { 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 { diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index 59b426a7eb..7c609f45a9 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -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 = 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 = withContext(Dispatchers.IO) { + networkController.setProfile(givenName, familyName, avatar, discoverableByPhoneNumber) + } + suspend fun setNewlyCreatedPin( pin: String, isAlphanumeric: Boolean, diff --git a/feature/registration/src/main/java/org/signal/registration/StorageController.kt b/feature/registration/src/main/java/org/signal/registration/StorageController.kt index 1388ab0fb6..e2383a7ea1 100644 --- a/feature/registration/src/main/java/org/signal/registration/StorageController.kt +++ b/feature/registration/src/main/java/org/signal/registration/StorageController.kt @@ -121,6 +121,54 @@ interface StorageController { * @return A list of [LocalBackupInfo] sorted by date descending (most recent first). */ suspend fun scanLocalBackupFolder(folderUri: Uri): List + + /** + * 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 + } } /** diff --git a/feature/registration/src/main/java/org/signal/registration/fonts/MonoTypeface.kt b/feature/registration/src/main/java/org/signal/registration/fonts/MonoTypeface.kt new file mode 100644 index 0000000000..c804442a28 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/fonts/MonoTypeface.kt @@ -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 }) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt index adc64eed3c..94ab7b5586 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/aepentry/EnterAepScreen.kt @@ -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( diff --git a/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreen.kt new file mode 100644 index 0000000000..974c9713d5 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreen.kt @@ -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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreenEvents.kt new file mode 100644 index 0000000000..13bb7f5e44 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileState.kt b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileState.kt new file mode 100644 index 0000000000..2f0157755c --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileState.kt @@ -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" + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileViewModel.kt new file mode 100644 index 0000000000..cd87fbf81f --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/createprofile/CreateProfileViewModel.kt @@ -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(TAG) { + + companion object { + private val TAG = Log.tag(CreateProfileViewModel::class) + } + + private val _state = MutableStateFlow(CreateProfileState()) + val state: StateFlow = _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 create(modelClass: Class): T { + return CreateProfileViewModel(repository, parentEventEmitter) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreen.kt index 7a948db9b1..0a07d17222 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteScreen.kt @@ -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( diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModel.kt index 5b4ec85df2..426662309d 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModel.kt @@ -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(TAG) { @@ -26,7 +28,7 @@ class DeviceTransferCompleteViewModel( val state: StateFlow = _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 create(modelClass: Class): T { - return DeviceTransferCompleteViewModel(parentEventEmitter) as T + return DeviceTransferCompleteViewModel(repository, parentEventEmitter) as T } } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreen.kt index 036eb4186b..ea50449a81 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/instructions/DeviceTransferInstructionsScreen.kt @@ -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( diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreen.kt index 2b6cc6e8cf..5bb69af315 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/progress/DeviceTransferProgressScreen.kt @@ -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( diff --git a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreen.kt index 140d3f9dc8..b7a999c193 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/devicetransfer/setup/DeviceTransferSetupScreen.kt @@ -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 diff --git a/feature/registration/src/main/java/org/signal/registration/screens/discoverability/PhoneNumberDiscoverabilityScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/discoverability/PhoneNumberDiscoverabilityScreen.kt new file mode 100644 index 0000000000..ade79c394e --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/discoverability/PhoneNumberDiscoverabilityScreen.kt @@ -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 = {} + ) + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/discoverability/PhoneNumberDiscoverabilityScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/discoverability/PhoneNumberDiscoverabilityScreenEvents.kt new file mode 100644 index 0000000000..3bcdad87b4 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/discoverability/PhoneNumberDiscoverabilityScreenEvents.kt @@ -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() +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/discoverability/PhoneNumberDiscoverabilityState.kt b/feature/registration/src/main/java/org/signal/registration/screens/discoverability/PhoneNumberDiscoverabilityState.kt new file mode 100644 index 0000000000..88496b4526 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/discoverability/PhoneNumberDiscoverabilityState.kt @@ -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() diff --git a/feature/registration/src/main/java/org/signal/registration/screens/discoverability/PhoneNumberDiscoverabilityViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/discoverability/PhoneNumberDiscoverabilityViewModel.kt new file mode 100644 index 0000000000..a4c8b122d0 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/discoverability/PhoneNumberDiscoverabilityViewModel.kt @@ -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(TAG) { + + companion object { + private val TAG = Log.tag(PhoneNumberDiscoverabilityViewModel::class) + } + + private val _state = MutableStateFlow(PhoneNumberDiscoverabilityState(discoverable = initialDiscoverable)) + val state: StateFlow = _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 create(modelClass: Class): T { + return PhoneNumberDiscoverabilityViewModel(initialDiscoverable, parentEventEmitter, resultBus, resultKey) as T + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt index 73f0b05eab..c4dfff7d5f 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt @@ -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) } } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt index cb31f04b95..928112e6c4 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt @@ -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 } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt index 29e5d9abbb..5648d01e9e 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModel.kt @@ -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 } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt index 8f719a1838..e1a8fb255d 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModel.kt @@ -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 -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt index 6f5351b982..a73a66c0b4 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModel.kt @@ -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 -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt index 29be78f105..7ce286ce88 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModel.kt @@ -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) diff --git a/feature/registration/src/main/res/drawable/symbol_group_24.xml b/feature/registration/src/main/res/drawable/symbol_group_24.xml new file mode 100644 index 0000000000..5e4419695c --- /dev/null +++ b/feature/registration/src/main/res/drawable/symbol_group_24.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index fb51882f1b..41b2dbf5a3 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -445,4 +445,40 @@ Transfer complete Your account is now on this device. Continue + + + + Set up your profile + + Your profile and changes to it will be visible to people you message, contacts, and groups. + + First name (required) + + Last name (optional) + + Next + + Set avatar + + + + Who can find me by number? + + Anyone who has your phone number will see you\'re on Signal and can start chats with you. + + Nobody will be able to see you\'re on Signal unless you message them or have an existing chat with them. + + Everyone + + Nobody + + Are you sure? + + Setting \"Who can find me by number\" to \"Nobody\" will make it harder for people to find you on Signal. + + Cancel + + Save + + Back diff --git a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModelTest.kt index 884ad8c1ce..f63d5fbead 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/devicetransfer/complete/DeviceTransferCompleteViewModelTest.kt @@ -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 private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit private lateinit var emittedStates: MutableList @@ -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()) } } } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt index 6220494e63..166f5c827b 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt @@ -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 ==================== diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt index 1158d27e80..a1fc4d389f 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForRegistrationLockViewModelTest.kt @@ -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() assertThat(emittedParentEvents[1]).isInstanceOf() - assertThat(emittedParentEvents[2]).isInstanceOf() + coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) } } @Test diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt index 0869a752d6..a61e71bc08 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSmsBypassViewModelTest.kt @@ -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(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() - assertThat(emittedParentEvents[1]).isInstanceOf() + 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() - assertThat(emittedParentEvents[1]).isInstanceOf() + coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) } } @Test diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt index 0b323c15e3..3efc688685 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pinentry/PinEntryForSvrRestoreViewModelTest.kt @@ -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(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() - assertThat(emittedParentEvents[1]).isInstanceOf() + coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) } } // ==================== GetSvrCredentials Error Tests ==================== diff --git a/feature/registration/src/test/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModelTest.kt index bc67bbd46a..9df23a9d9e 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/remotebackuprestore/RemoteBackupRestoreViewModelTest.kt @@ -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() - assertThat(emittedParentEvents[1]).isEqualTo(RegistrationFlowEvent.RegistrationComplete) + coVerify { mockRepository.finishRegistrationOrCreateProfile(parentEventEmitter, any()) } } @Test diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index 52f73b0b2c..3c20c01025 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -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;