From 03256a7ceb871409d6cda324632cff4d0e36e837 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 4 Sep 2026 15:11:16 -0400 Subject: [PATCH] Update TOTP/Passkey management UX. --- .../app/account/AccountSettingsFragment.kt | 20 +- .../app/account/AccountSettingsRepository.kt | 37 +- .../app/account/AccountSettingsViewModel.kt | 167 ++++++-- .../authenticator/TotpAppListFragment.kt | 75 ---- .../authenticator/TotpAppListViewModel.kt | 109 ----- .../authenticator/TotpNameEntryFragment.kt | 2 +- .../authenticator/TotpNameEntryViewModel.kt | 8 +- .../app/account/authenticator/TotpNavArgs.kt | 2 +- .../account/authenticator/TotpRepository.kt | 2 +- .../account/passkeys/AppPasskeysRepository.kt | 17 +- .../app/account/passkeys/PasskeysFragment.kt | 50 --- .../app_settings_with_change_number.xml | 33 +- .../account/AccountSettingsViewModelTest.kt | 236 +++++++++-- .../authenticator/TotpAppListViewModelTest.kt | 280 ------------ .../TotpNameEntryViewModelTest.kt | 6 +- .../account/authenticator/TotpNavArgsTest.kt | 2 +- .../authenticator/TotpRepositoryTest.kt | 2 +- .../account/AccountSettingsAction.kt | 19 +- .../account/AccountSettingsEvent.kt | 17 +- .../account/AccountSettingsScreen.kt | 322 ++++++++++++-- .../account/AccountSettingsState.kt | 32 +- .../appsettings/account/TwoFactorMethod.kt | 31 ++ .../signal/appsettings/passkeys/Passkey.kt | 18 - .../appsettings/passkeys/PasskeysAction.kt | 24 -- .../appsettings/passkeys/PasskeysEvent.kt | 27 -- .../passkeys/PasskeysRepository.kt | 14 - .../appsettings/passkeys/PasskeysScreen.kt | 392 ----------------- .../appsettings/passkeys/PasskeysState.kt | 11 - .../appsettings/passkeys/PasskeysViewModel.kt | 50 --- .../{totpapplist => totp}/TotpApp.kt | 4 +- .../totpapplist/TotpAppListAction.kt | 33 -- .../totpapplist/TotpAppListEvent.kt | 36 -- .../totpapplist/TotpAppListScreen.kt | 398 ------------------ .../totpapplist/TotpAppListState.kt | 42 -- .../totpnameentry/TotpNameEntryAction.kt | 4 +- .../appsettings/totpsetup/TotpSetupScreen.kt | 2 +- .../res/drawable/image_passkeys_phone.xml | 67 --- .../src/main/res/values/strings.xml | 112 ++--- .../account/AccountSettingsScreenTest.kt | 135 +++++- .../passkeys/PasskeysScreenTest.kt | 111 ----- .../passkeys/PasskeysViewModelTest.kt | 104 ----- .../totpapplist/TotpAppListScreenTest.kt | 172 -------- 42 files changed, 933 insertions(+), 2292 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpAppListFragment.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpAppListViewModel.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/PasskeysFragment.kt delete mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpAppListViewModelTest.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/account/TwoFactorMethod.kt delete mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/Passkey.kt delete mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysAction.kt delete mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysEvent.kt delete mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysRepository.kt delete mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysScreen.kt delete mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysState.kt delete mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysViewModel.kt rename feature/app-settings/src/main/java/org/signal/appsettings/{totpapplist => totp}/TotpApp.kt (82%) delete mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListAction.kt delete mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListEvent.kt delete mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListScreen.kt delete mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListState.kt delete mode 100644 feature/app-settings/src/main/res/drawable/image_passkeys_phone.xml delete mode 100644 feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysScreenTest.kt delete mode 100644 feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysViewModelTest.kt delete mode 100644 feature/app-settings/src/test/java/org/signal/appsettings/totpapplist/TotpAppListScreenTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt index fd3382d794..3747956125 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt @@ -24,12 +24,15 @@ import org.signal.appsettings.account.AccountSettingsScreen import org.signal.core.ui.compose.CollectActions import org.signal.core.ui.compose.ComposeFragment import org.signal.core.util.ServiceUtil +import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpNavArgs import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity import org.thoughtcrime.securesms.registration.ui.RegistrationActivity import org.thoughtcrime.securesms.util.PlayStoreUtil import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.signal.appsettings.R as AppSettingsR /** * Account settings shown on a primary device. Carries out the [AccountSettingsAction]s that need an Activity or the @@ -37,6 +40,10 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate */ class AccountSettingsFragment : ComposeFragment() { + companion object { + private val TAG = Log.tag(AccountSettingsFragment::class) + } + private val viewModel: AccountSettingsViewModel by viewModels() private lateinit var pinFlowLauncher: ActivityResultLauncher @@ -75,8 +82,17 @@ class AccountSettingsFragment : ComposeFragment() { AccountSettingsAction.LaunchChangePinFlow -> pinFlowLauncher.launch(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext())) AccountSettingsAction.ShowPinCreatedConfirmation -> Snackbar.make(requireView(), R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show() AccountSettingsAction.NavigateToSignalLoginDetails -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_signalLoginViewDetailsFragment) - AccountSettingsAction.NavigateToTotpAppList -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_authenticatorAppsFragment) - AccountSettingsAction.NavigateToPasskeys -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_passkeysFragment) + AccountSettingsAction.NavigateToTotpSetup -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_authenticatorSetupFragment) + is AccountSettingsAction.NavigateToRenameTotpApp -> { + findNavController().safeNavigate( + R.id.action_accountSettingsFragment_to_authenticatorNameFragment, + Bundle().apply { TotpNavArgs.putRenamedApp(this, action.app) } + ) + } + AccountSettingsAction.ShowTotpAppRemoved -> toast(AppSettingsR.string.AccountSettingsFragment__authenticator_app_removed) + AccountSettingsAction.ShowTotpAppRemovalFailed -> toast(AppSettingsR.string.AccountSettingsFragment__couldnt_remove_authenticator_app) + // TODO Open the two-factor authentication support article once one exists. + AccountSettingsAction.OpenLearnMore -> Log.w(TAG, "There's no support article to open yet.") AccountSettingsAction.NavigateToAdvancedPinSettings -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_advancedPinSettingsActivity) AccountSettingsAction.NavigateToChangePhoneNumber -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment) AccountSettingsAction.NavigateToDeviceTransfer -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsRepository.kt index 256b0702a3..5975725ae3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsRepository.kt @@ -6,6 +6,8 @@ package org.thoughtcrime.securesms.components.settings.app.account import kotlinx.coroutines.withContext +import org.signal.appsettings.account.TwoFactorMethod +import org.signal.appsettings.totp.TotpApp import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpRepository @@ -48,14 +50,31 @@ class AccountSettingsRepository { fun isPhoneNumberless(): Boolean = SignalStore.account.isPhoneNumberless + fun getMaxTotpApps(): Int = totpRepository.getMaxApps() + /** - * How many authenticator apps are on the account, or null if we couldn't find out. + * Every second factor on the account, authenticator apps first, or a failure if we couldn't find out. Passkeys are + * mocked for now, so only the authenticator apps can actually fail to load. */ - suspend fun getTotpAppCount(): Int? { - return (totpRepository.getTotpApps() as? TotpRepository.AppsResult.Success)?.apps?.size + suspend fun getTwoFactorMethods(): TwoFactorMethodsResult { + val apps = when (val result = totpRepository.getTotpApps()) { + is TotpRepository.AppsResult.Success -> result.apps + TotpRepository.AppsResult.NetworkFailure -> return TwoFactorMethodsResult.NetworkFailure + } + + return TwoFactorMethodsResult.Success(apps.map { it.toTwoFactorMethod() } + passkeysRepository.getPasskeys()) } - fun getPasskeyCount(): Int = passkeysRepository.getPasskeys().size + /** + * Removes an authenticator app from the account, returning whether it's gone. An app the service has already + * forgotten counts as gone, since that's the outcome the user asked for. + */ + suspend fun removeTotpApp(appId: Long): Boolean { + return when (totpRepository.removeTotpApp(appId)) { + TotpRepository.UpdateResult.Success, TotpRepository.UpdateResult.AppNotFound -> true + TotpRepository.UpdateResult.NetworkFailure -> false + } + } fun verifyLocalPin(pin: String): Boolean { val localPinHash = SignalStore.svr.localPinHash @@ -83,4 +102,14 @@ class AccountSettingsRepository { false } } + + private fun TotpApp.toTwoFactorMethod(): TwoFactorMethod { + return TwoFactorMethod(id = id, kind = TwoFactorMethod.Kind.AUTHENTICATOR_APP, name = name, createdAt = createdAt) + } + + sealed interface TwoFactorMethodsResult { + data class Success(val methods: List) : TwoFactorMethodsResult + + data object NetworkFailure : TwoFactorMethodsResult + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt index cf33027ffb..eab4a73807 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt @@ -18,6 +18,10 @@ import org.signal.appsettings.account.AccountSettingsAction import org.signal.appsettings.account.AccountSettingsEvent import org.signal.appsettings.account.AccountSettingsState import org.signal.appsettings.account.AccountSettingsState.Dialog +import org.signal.appsettings.account.AccountSettingsState.LoadState +import org.signal.appsettings.account.AccountSettingsState.SignalLogin +import org.signal.appsettings.account.TwoFactorMethod +import org.signal.appsettings.totp.TotpApp import org.signal.core.ui.compose.EventDrivenViewModel import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.lock.v2.PinKeyboardType @@ -61,13 +65,7 @@ class AccountSettingsViewModel( _actions.send(AccountSettingsAction.ShowPinCreatedConfirmation) } is AccountSettingsEvent.PinRemindersToggled -> { - if (event.enabled) { - repository.setPinRemindersEnabled(true) - refresh() - } else { - val keyboardType = repository.getPinKeyboardType() - _state.update { it.copy(dialog = Dialog.ConfirmPinToDisableReminders(isAlphanumericKeyboard = keyboardType == PinKeyboardType.ALPHA_NUMERIC)) } - } + applyPinRemindersToggled(event.enabled) } is AccountSettingsEvent.PinEntryChanged -> { updatePinDialog { it.copy(pin = event.pin, incorrectPin = false, canSubmit = canSubmit(event.pin)) } @@ -76,41 +74,31 @@ class AccountSettingsViewModel( updatePinDialog { it.copy(pin = "", isAlphanumericKeyboard = !it.isAlphanumericKeyboard, incorrectPin = false, canSubmit = false) } } AccountSettingsEvent.DisablePinRemindersConfirmed -> { - val dialog = _state.value.dialog as? Dialog.ConfirmPinToDisableReminders ?: return - - if (repository.verifyLocalPin(dialog.pin)) { - repository.setPinRemindersEnabled(false) - _state.update { it.copy(dialog = Dialog.None) } - refresh() - } else { - updatePinDialog { it.copy(incorrectPin = true) } - } + applyDisablePinRemindersConfirmed() } is AccountSettingsEvent.RegistrationLockToggled -> { _state.update { it.copy(dialog = Dialog.ConfirmRegistrationLock(enable = event.enabled)) } } AccountSettingsEvent.RegistrationLockConfirmed -> { - val dialog = _state.value.dialog as? Dialog.ConfirmRegistrationLock ?: return - - _state.update { it.copy(dialog = dialog.copy(inProgress = true)) } - val success = repository.setRegistrationLockEnabled(dialog.enable) - _state.update { it.copy(dialog = Dialog.None) } - refresh() - - if (!success) { - _actions.send( - if (dialog.enable) AccountSettingsAction.ShowRegistrationLockEnableFailed else AccountSettingsAction.ShowRegistrationLockDisableFailed - ) - } + applyRegistrationLockConfirmed() } AccountSettingsEvent.AccountAndRecoveryClicked -> { _actions.send(AccountSettingsAction.NavigateToSignalLoginDetails) } - AccountSettingsEvent.TotpAppClicked -> { - _actions.send(AccountSettingsAction.NavigateToTotpAppList) + AccountSettingsEvent.AddTotpAppClicked -> { + applyAddTotpAppClicked() } - AccountSettingsEvent.PasskeysClicked -> { - _actions.send(AccountSettingsAction.NavigateToPasskeys) + AccountSettingsEvent.LearnMoreClicked -> { + _actions.send(AccountSettingsAction.OpenLearnMore) + } + is AccountSettingsEvent.RenameMethodClicked -> { + applyRenameMethodClicked(event.method) + } + is AccountSettingsEvent.RemoveMethodClicked -> { + applyRemoveMethodClicked(event.method) + } + AccountSettingsEvent.RemoveTotpAppConfirmed -> { + applyRemoveTotpAppConfirmed() } AccountSettingsEvent.AdvancedPinSettingsClicked -> { _actions.send(AccountSettingsAction.NavigateToAdvancedPinSettings) @@ -149,9 +137,93 @@ class AccountSettingsViewModel( } } + private suspend fun applyPinRemindersToggled(enabled: Boolean) { + if (enabled) { + repository.setPinRemindersEnabled(true) + refresh() + } else { + val keyboardType = repository.getPinKeyboardType() + _state.update { it.copy(dialog = Dialog.ConfirmPinToDisableReminders(isAlphanumericKeyboard = keyboardType == PinKeyboardType.ALPHA_NUMERIC)) } + } + } + + private suspend fun applyDisablePinRemindersConfirmed() { + val dialog = _state.value.dialog as? Dialog.ConfirmPinToDisableReminders ?: return + + if (repository.verifyLocalPin(dialog.pin)) { + repository.setPinRemindersEnabled(false) + _state.update { it.copy(dialog = Dialog.None) } + refresh() + } else { + updatePinDialog { it.copy(incorrectPin = true) } + } + } + + private suspend fun applyRegistrationLockConfirmed() { + val dialog = _state.value.dialog as? Dialog.ConfirmRegistrationLock ?: return + + _state.update { it.copy(dialog = dialog.copy(inProgress = true)) } + val success = repository.setRegistrationLockEnabled(dialog.enable) + _state.update { it.copy(dialog = Dialog.None) } + refresh() + + if (!success) { + _actions.send( + if (dialog.enable) AccountSettingsAction.ShowRegistrationLockEnableFailed else AccountSettingsAction.ShowRegistrationLockDisableFailed + ) + } + } + + private suspend fun applyAddTotpAppClicked() { + if (_state.value.signalLogin?.atMaxTotpApps == true) { + _state.update { it.copy(dialog = Dialog.MaxTotpAppsReached) } + } else { + _actions.send(AccountSettingsAction.NavigateToTotpSetup) + } + } + + private suspend fun applyRenameMethodClicked(method: TwoFactorMethod) { + when (method.kind) { + TwoFactorMethod.Kind.AUTHENTICATOR_APP -> { + val app = TotpApp(id = method.id, name = method.name, createdAt = method.createdAt) + _actions.send(AccountSettingsAction.NavigateToRenameTotpApp(app)) + } + TwoFactorMethod.Kind.PASSKEY -> { + Log.w(TAG, "Passkey renaming isn't implemented yet.") + } + } + } + + private fun applyRemoveMethodClicked(method: TwoFactorMethod) { + when (method.kind) { + TwoFactorMethod.Kind.AUTHENTICATOR_APP -> { + _state.update { it.copy(dialog = Dialog.ConfirmRemoveTotpApp(method.id)) } + } + TwoFactorMethod.Kind.PASSKEY -> { + Log.w(TAG, "Passkey removal isn't implemented yet.") + } + } + } + + private suspend fun applyRemoveTotpAppConfirmed() { + val dialog = _state.value.dialog as? Dialog.ConfirmRemoveTotpApp ?: return + + _state.update { it.copy(dialog = Dialog.None) } + removeTotpApp(dialog.appId) + } + + private suspend fun removeTotpApp(appId: Long) { + if (repository.removeTotpApp(appId)) { + _actions.send(AccountSettingsAction.ShowTotpAppRemoved) + refreshTwoFactorMethods() + } else { + Log.w(TAG, "Couldn't remove the authenticator app. Leaving it in the list, where it still is.") + _actions.send(AccountSettingsAction.ShowTotpAppRemovalFailed) + } + } + private suspend fun refresh() { val isPhoneNumberless = repository.isPhoneNumberless() - val totpAppCount = repository.getTotpAppCount() _state.update { it.copy( @@ -162,16 +234,29 @@ class AccountSettingsViewModel( userUnregistered = repository.isUserUnregistered(), clientDeprecated = repository.isClientDeprecated(), isPhoneNumberless = isPhoneNumberless, - signalLogin = if (isPhoneNumberless) { - AccountSettingsState.SignalLogin( - totpAppCount = totpAppCount, - passkeyCount = repository.getPasskeyCount() - ) - } else { - null - } + // Held onto across refreshes so a resume doesn't drop the list back to its loading state. + signalLogin = if (isPhoneNumberless) it.signalLogin ?: SignalLogin(maxTotpApps = repository.getMaxTotpApps()) else null ) } + + if (isPhoneNumberless) { + refreshTwoFactorMethods() + } + } + + private suspend fun refreshTwoFactorMethods() { + val (methods, loadState) = when (val result = repository.getTwoFactorMethods()) { + is AccountSettingsRepository.TwoFactorMethodsResult.Success -> result.methods to LoadState.LOADED + AccountSettingsRepository.TwoFactorMethodsResult.NetworkFailure -> { + Log.w(TAG, "Couldn't reach the service to list the account's second factors.") + emptyList() to LoadState.NETWORK_FAILURE + } + } + + _state.update { state -> + val signalLogin = state.signalLogin ?: return@update state + state.copy(signalLogin = signalLogin.copy(twoFactorMethods = methods, loadState = loadState)) + } } private fun canSubmit(pin: String): Boolean = pin.length >= SvrConstants.MINIMUM_PIN_LENGTH diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpAppListFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpAppListFragment.kt deleted file mode 100644 index 9f64aaf376..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpAppListFragment.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.components.settings.app.account.authenticator - -import android.os.Bundle -import android.widget.Toast -import androidx.annotation.StringRes -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.fragment.app.viewModels -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.fragment.findNavController -import org.signal.appsettings.totpapplist.TotpAppListAction -import org.signal.appsettings.totpapplist.TotpAppListEvent -import org.signal.appsettings.totpapplist.TotpAppListScreen -import org.signal.core.ui.compose.CollectActions -import org.signal.core.ui.compose.ComposeFragment -import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.util.navigation.safeNavigate -import org.signal.appsettings.R as AppSettingsR - -/** - * Lists the authenticator apps on the account. Carries out the [TotpAppListAction]s that need the nav graph. - */ -class TotpAppListFragment : ComposeFragment() { - - companion object { - private val TAG = Log.tag(TotpAppListFragment::class) - } - - private val viewModel: TotpAppListViewModel by viewModels() - - @Composable - override fun FragmentContent() { - val state by viewModel.state.collectAsStateWithLifecycle() - - CollectActions(viewModel.actions) { action -> handleAction(action) } - - TotpAppListScreen( - state = state, - onEvent = viewModel::onEvent - ) - } - - override fun onResume() { - super.onResume() - viewModel.onEvent(TotpAppListEvent.ScreenResumed) - } - - private fun handleAction(action: TotpAppListAction) { - when (action) { - TotpAppListAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed() - TotpAppListAction.NavigateToSetup -> { - findNavController().safeNavigate(R.id.action_authenticatorAppsFragment_to_authenticatorSetupFragment) - } - is TotpAppListAction.NavigateToRename -> { - findNavController().safeNavigate( - R.id.action_authenticatorAppsFragment_to_authenticatorNameFragment, - Bundle().apply { TotpNavArgs.putRenamedApp(this, action.app) } - ) - } - TotpAppListAction.ShowTotpAppRemoved -> toast(AppSettingsR.string.TotpAppListScreen__authenticator_app_removed) - TotpAppListAction.ShowRemovalFailed -> toast(AppSettingsR.string.TotpAppListScreen__couldnt_remove_authenticator_app) - TotpAppListAction.OpenLearnMore -> Log.w(TAG, "There's no support article to open yet.") - } - } - - private fun toast(@StringRes message: Int) { - Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpAppListViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpAppListViewModel.kt deleted file mode 100644 index de26eded8d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpAppListViewModel.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.components.settings.app.account.authenticator - -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.signal.appsettings.totpapplist.TotpAppListAction -import org.signal.appsettings.totpapplist.TotpAppListEvent -import org.signal.appsettings.totpapplist.TotpAppListState -import org.signal.appsettings.totpapplist.TotpAppListState.Dialog -import org.signal.appsettings.totpapplist.TotpAppListState.LoadState -import org.signal.core.ui.compose.EventDrivenViewModel -import org.signal.core.util.logging.Log - -/** - * Drives the screen that lists the authenticator apps on the account. - */ -class TotpAppListViewModel( - private val repository: TotpRepository = TotpRepository() -) : EventDrivenViewModel(TAG) { - - companion object { - private val TAG = Log.tag(TotpAppListViewModel::class) - } - - private val _state = MutableStateFlow(TotpAppListState(maxApps = repository.getMaxApps())) - private val _actions = Channel(Channel.BUFFERED) - - val state: StateFlow = _state.asStateFlow() - val actions: Flow = _actions.receiveAsFlow() - - init { - viewModelScope.launch { refresh() } - } - - override suspend fun processEvent(event: TotpAppListEvent) { - when (event) { - TotpAppListEvent.ScreenResumed -> { - refresh() - } - TotpAppListEvent.NavigateBackClicked -> { - _actions.send(TotpAppListAction.NavigateBack) - } - TotpAppListEvent.AddTotpAppClicked -> { - if (_state.value.atMaxApps) { - _state.update { it.copy(dialog = Dialog.MaxAppsReached) } - } else { - _actions.send(TotpAppListAction.NavigateToSetup) - } - } - TotpAppListEvent.LearnMoreClicked -> { - _actions.send(TotpAppListAction.OpenLearnMore) - } - is TotpAppListEvent.RenameAppClicked -> { - val app = _state.value.apps.firstOrNull { it.id == event.appId } - if (app == null) { - Log.w(TAG, "Asked to rename an app that isn't in the list.") - } else { - _actions.send(TotpAppListAction.NavigateToRename(app)) - } - } - is TotpAppListEvent.RemoveAppClicked -> { - _state.update { it.copy(dialog = Dialog.ConfirmRemove(event.appId)) } - } - is TotpAppListEvent.RemoveAppConfirmed -> { - _state.update { it.copy(dialog = Dialog.None) } - removeApp(event.appId) - } - TotpAppListEvent.DialogDismissed -> { - _state.update { it.copy(dialog = Dialog.None) } - } - } - } - - private suspend fun removeApp(appId: Long) { - when (repository.removeTotpApp(appId)) { - TotpRepository.UpdateResult.Success, TotpRepository.UpdateResult.AppNotFound -> { - _actions.send(TotpAppListAction.ShowTotpAppRemoved) - refresh() - } - TotpRepository.UpdateResult.NetworkFailure -> { - Log.w(TAG, "Couldn't remove the authenticator app. Leaving it in the list, where it still is.") - _actions.send(TotpAppListAction.ShowRemovalFailed) - } - } - } - - private suspend fun refresh() { - when (val result = repository.getTotpApps()) { - is TotpRepository.AppsResult.Success -> { - _state.update { it.copy(apps = result.apps, loadState = LoadState.LOADED) } - } - TotpRepository.AppsResult.NetworkFailure -> { - Log.w(TAG, "Couldn't reach the service to list authenticator apps.") - _state.update { it.copy(apps = emptyList(), loadState = LoadState.NETWORK_FAILURE) } - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNameEntryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNameEntryFragment.kt index b5d18cd3db..eaf9a4af5c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNameEntryFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNameEntryFragment.kt @@ -49,7 +49,7 @@ class TotpNameEntryFragment : ComposeFragment() { private fun handleAction(action: TotpNameEntryAction) { when (action) { TotpNameEntryAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed() - TotpNameEntryAction.NavigateToTotpAppList -> findNavController().popBackStack(R.id.authenticatorAppsFragment, false) + TotpNameEntryAction.NavigateToAccountSettings -> findNavController().popBackStack(R.id.accountSettingsFragment, false) TotpNameEntryAction.ShowTotpAppSetUp -> toast(AppSettingsR.string.TotpNameEntryScreen__authenticator_app_set_up) TotpNameEntryAction.ShowTotpAppRenamed -> toast(AppSettingsR.string.TotpNameEntryScreen__authenticator_app_renamed) TotpNameEntryAction.ShowNameNotSaved -> toast(AppSettingsR.string.TotpNameEntryScreen__couldnt_save_name) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNameEntryViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNameEntryViewModel.kt index bda3d99fa5..9c6d9eefab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNameEntryViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNameEntryViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update -import org.signal.appsettings.totpapplist.TotpApp +import org.signal.appsettings.totp.TotpApp import org.signal.appsettings.totpnameentry.TotpNameEntryAction import org.signal.appsettings.totpnameentry.TotpNameEntryEvent import org.signal.appsettings.totpnameentry.TotpNameEntryState @@ -72,11 +72,11 @@ class TotpNameEntryViewModel( when (result) { TotpRepository.UpdateResult.Success -> { _actions.send(if (renamedApp != null) TotpNameEntryAction.ShowTotpAppRenamed else TotpNameEntryAction.ShowTotpAppSetUp) - _actions.send(TotpNameEntryAction.NavigateToTotpAppList) + _actions.send(TotpNameEntryAction.NavigateToAccountSettings) } TotpRepository.UpdateResult.AppNotFound -> { - Log.w(TAG, "Asked to name an app the service doesn't have. Going back to the list rather than stranding the user here.") - _actions.send(TotpNameEntryAction.NavigateToTotpAppList) + Log.w(TAG, "Asked to name an app the service doesn't have. Going back to account settings rather than stranding the user here.") + _actions.send(TotpNameEntryAction.NavigateToAccountSettings) } TotpRepository.UpdateResult.NetworkFailure -> { _state.update { it.copy(submitting = false) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNavArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNavArgs.kt index cd46b11b80..7a5cf31317 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNavArgs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNavArgs.kt @@ -6,7 +6,7 @@ package org.thoughtcrime.securesms.components.settings.app.account.authenticator import android.os.Bundle -import org.signal.appsettings.totpapplist.TotpApp +import org.signal.appsettings.totp.TotpApp /** * The nav arguments the authenticator app screens pass between each other, and the parsing that turns them back into diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpRepository.kt index 95d2e324d7..2a34891ff9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpRepository.kt @@ -5,7 +5,7 @@ package org.thoughtcrime.securesms.components.settings.app.account.authenticator -import org.signal.appsettings.totpapplist.TotpApp +import org.signal.appsettings.totp.TotpApp import org.signal.core.models.MasterKey import org.signal.core.util.Base32 import org.signal.core.util.logging.Log diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/AppPasskeysRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/AppPasskeysRepository.kt index 9f5c66421b..968e0498cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/AppPasskeysRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/AppPasskeysRepository.kt @@ -5,21 +5,18 @@ package org.thoughtcrime.securesms.components.settings.app.account.passkeys -import org.signal.appsettings.passkeys.Passkey -import org.signal.appsettings.passkeys.PasskeysRepository +import org.signal.appsettings.account.TwoFactorMethod /** - * Stand-in for wherever passkeys will eventually be read from. Nothing is fetched from the service yet, so the - * passkeys are mocked. + * Stand-in for wherever passkeys will eventually be read from. Nothing is fetched from the service yet, so there are + * never any passkeys. */ -class AppPasskeysRepository : PasskeysRepository { +class AppPasskeysRepository { companion object { - private val MOCK_PASSKEYS = listOf( - Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()), - Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis()) - ) + /** Empty so nothing fake reaches a real account. Fill it in locally to see passkey rows while testing. */ + private val MOCK_PASSKEYS = emptyList() } - override fun getPasskeys(): List = MOCK_PASSKEYS + fun getPasskeys(): List = MOCK_PASSKEYS } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/PasskeysFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/PasskeysFragment.kt deleted file mode 100644 index 49660d9f57..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/PasskeysFragment.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.components.settings.app.account.passkeys - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.signal.appsettings.passkeys.PasskeysAction -import org.signal.appsettings.passkeys.PasskeysScreen -import org.signal.appsettings.passkeys.PasskeysViewModel -import org.signal.core.ui.compose.CollectActions -import org.signal.core.ui.compose.ComposeFragment -import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.util.viewModel - -/** - * Explains passkeys and lets the user start creating one. Carries out the [PasskeysAction]s that need an Activity or - * the nav graph. - */ -class PasskeysFragment : ComposeFragment() { - - companion object { - private val TAG = Log.tag(PasskeysFragment::class) - } - - private val viewModel: PasskeysViewModel by viewModel { PasskeysViewModel(AppPasskeysRepository()) } - - @Composable - override fun FragmentContent() { - val state by viewModel.state.collectAsStateWithLifecycle() - - CollectActions(viewModel.actions) { action -> handleAction(action) } - - PasskeysScreen( - state = state, - onEvent = viewModel::onEvent - ) - } - - private fun handleAction(action: PasskeysAction) { - when (action) { - PasskeysAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed() - PasskeysAction.LaunchPasskeyCreation -> Log.w(TAG, "Passkey creation isn't implemented yet.") - PasskeysAction.OpenLearnMore -> Log.w(TAG, "There's no support article to open yet.") - } - } -} diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index 46f5b02e1a..13d892e2b7 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -224,15 +224,15 @@ app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> - - - - - - - () == CORRECT_PIN } coEvery { repository.setRegistrationLockEnabled(any()) } returns true } @@ -303,25 +314,206 @@ class AccountSettingsViewModelTest { @Test fun `the Signal Login section is filled in when the account is phone-numberless`() = runTest(testDispatcher) { every { repository.isPhoneNumberless() } returns true - coEvery { repository.getTotpAppCount() } returns 2 - every { repository.getPasskeyCount() } returns 8 + coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP, PASSKEY) val viewModel = createViewModel() assertThat(viewModel.state.value.isPhoneNumberless).isTrue() - assertThat(viewModel.state.value.signalLogin?.totpAppCount).isEqualTo(2) - assertThat(viewModel.state.value.signalLogin?.passkeyCount).isEqualTo(8) + assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).containsExactly(TOTP_APP, PASSKEY) + assertThat(viewModel.state.value.signalLogin?.loadState).isEqualTo(LoadState.LOADED) + assertThat(viewModel.state.value.signalLogin?.maxTotpApps).isEqualTo(2) } - /** Zero would render as "no authenticator apps", which is a claim we can't make when we couldn't reach the service. */ + /** An empty list says nothing on its own, so the screen leans on the load state to know we haven't heard back yet. */ @Test - fun `a count we couldn't fetch is null rather than zero`() = runTest(testDispatcher) { + fun `the two-factor list is LOADING until we've heard back about the account`() = runTest(testDispatcher) { every { repository.isPhoneNumberless() } returns true - coEvery { repository.getTotpAppCount() } returns null + coEvery { repository.getTwoFactorMethods() } coAnswers { awaitCancellation() } val viewModel = createViewModel() - assertThat(viewModel.state.value.signalLogin?.totpAppCount).isNull() + assertThat(viewModel.state.value.signalLogin?.loadState).isEqualTo(LoadState.LOADING) + assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).isEmpty() + } + + /** An account we couldn't ask about is not an account with no second factors. */ + @Test + fun `a service we couldn't reach clears the two-factor list and says so`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + coEvery { repository.getTwoFactorMethods() } returns AccountSettingsRepository.TwoFactorMethodsResult.NetworkFailure + + val viewModel = createViewModel() + + assertThat(viewModel.state.value.signalLogin?.loadState).isEqualTo(LoadState.NETWORK_FAILURE) + assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).isEmpty() + } + + @Test + fun `ScreenResumed picks up second factors added elsewhere`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + + val viewModel = createViewModel() + + coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP) + viewModel.onEvent(AccountSettingsEvent.ScreenResumed) + + assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).containsExactly(TOTP_APP) + } + + @Test + fun `AddTotpAppClicked opens setup when there's room for another app`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP) + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AccountSettingsEvent.AddTotpAppClicked) + + assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToTotpSetup) + } + + /** Passkeys share the list but not the limit, so they can't be what stops another app from being added. */ + @Test + fun `AddTotpAppClicked explains the limit when there's no room for another app`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP, OTHER_TOTP_APP, PASSKEY) + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AccountSettingsEvent.AddTotpAppClicked) + + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.MaxTotpAppsReached) + assertThat(actions).isEmpty() + } + + @Test + fun `RenameMethodClicked opens the naming screen for that app`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP) + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AccountSettingsEvent.RenameMethodClicked(TOTP_APP)) + + val expected = TotpApp(id = TOTP_APP.id, name = TOTP_APP.name, createdAt = TOTP_APP.createdAt) + assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToRenameTotpApp(expected)) + } + + /** Ids only mean anything within a kind, so a passkey sharing an id with an app must not be mistaken for it. */ + @Test + fun `RenameMethodClicked for an unsupported passkey does nothing`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP, PASSKEY) + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AccountSettingsEvent.RenameMethodClicked(PASSKEY)) + + assertThat(actions).isEmpty() + } + + @Test + fun `RemoveMethodClicked asks the user to confirm first`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP) + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AccountSettingsEvent.RemoveMethodClicked(TOTP_APP)) + + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmRemoveTotpApp(TOTP_APP.id)) + assertThat(actions).isEmpty() + } + + @Test + fun `RemoveMethodClicked for an unsupported passkey does nothing`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + coEvery { repository.getTwoFactorMethods() } returns methods(PASSKEY) + + val viewModel = createViewModel() + + viewModel.onEvent(AccountSettingsEvent.RemoveMethodClicked(PASSKEY)) + + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None) + } + + @Test + fun `RemoveTotpAppConfirmed removes the app and says so`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP) + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AccountSettingsEvent.RemoveMethodClicked(TOTP_APP)) + viewModel.onEvent(AccountSettingsEvent.RemoveTotpAppConfirmed) + + coVerify { repository.removeTotpApp(TOTP_APP.id) } + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None) + assertThat(actions.last()).isEqualTo(AccountSettingsAction.ShowTotpAppRemoved) + } + + /** The open dialog is what says which app is being removed, so a confirmation without one has no app to act on. */ + @Test + fun `RemoveTotpAppConfirmed without the confirmation dialog removes nothing`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP) + + val viewModel = createViewModel() + + viewModel.onEvent(AccountSettingsEvent.RemoveTotpAppConfirmed) + + coVerify(exactly = 0) { repository.removeTotpApp(any()) } + assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).containsExactly(TOTP_APP) + } + + /** The list is what tells the user the app is gone, so it has to be read again rather than assumed. */ + @Test + fun `a removal re-reads the list`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP) + + val viewModel = createViewModel() + + viewModel.onEvent(AccountSettingsEvent.RemoveMethodClicked(TOTP_APP)) + + coEvery { repository.getTwoFactorMethods() } returns methods() + viewModel.onEvent(AccountSettingsEvent.RemoveTotpAppConfirmed) + + assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).isEmpty() + } + + @Test + fun `a removal that didn't go through says so rather than pretending the app is gone`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP) + coEvery { repository.removeTotpApp(any()) } returns false + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AccountSettingsEvent.RemoveMethodClicked(TOTP_APP)) + viewModel.onEvent(AccountSettingsEvent.RemoveTotpAppConfirmed) + + assertThat(actions.last()).isEqualTo(AccountSettingsAction.ShowTotpAppRemovalFailed) + assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).containsExactly(TOTP_APP) + } + + @Test + fun `LearnMoreClicked opens the support article`() = runTest(testDispatcher) { + every { repository.isPhoneNumberless() } returns true + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AccountSettingsEvent.LearnMoreClicked) + + assertThat(actions.last()).isEqualTo(AccountSettingsAction.OpenLearnMore) } @Test @@ -336,29 +528,7 @@ class AccountSettingsViewModelTest { assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToSignalLoginDetails) } - @Test - fun `TotpAppClicked opens the authenticator apps screen`() = runTest(testDispatcher) { - every { repository.isPhoneNumberless() } returns true - - val viewModel = createViewModel() - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(AccountSettingsEvent.TotpAppClicked) - - assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToTotpAppList) - } - - @Test - fun `PasskeysClicked opens the passkeys screen`() = runTest(testDispatcher) { - every { repository.isPhoneNumberless() } returns true - - val viewModel = createViewModel() - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(AccountSettingsEvent.PasskeysClicked) - - assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToPasskeys) - } + private fun methods(vararg methods: TwoFactorMethod) = AccountSettingsRepository.TwoFactorMethodsResult.Success(methods.toList()) private fun createViewModel(): AccountSettingsViewModel = AccountSettingsViewModel(repository) diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpAppListViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpAppListViewModelTest.kt deleted file mode 100644 index 8d6168cfb1..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpAppListViewModelTest.kt +++ /dev/null @@ -1,280 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.components.settings.app.account.authenticator - -import assertk.assertThat -import assertk.assertions.containsExactly -import assertk.assertions.isEmpty -import assertk.assertions.isEqualTo -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.signal.appsettings.totpapplist.TotpApp -import org.signal.appsettings.totpapplist.TotpAppListAction -import org.signal.appsettings.totpapplist.TotpAppListEvent -import org.signal.appsettings.totpapplist.TotpAppListState.Dialog -import org.signal.appsettings.totpapplist.TotpAppListState.LoadState -import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule - -@OptIn(ExperimentalCoroutinesApi::class) -class TotpAppListViewModelTest { - - companion object { - private val APP_ONE = TotpApp(id = 1, name = "Bitwarden Authenticator", createdAt = 0) - private val APP_TWO = TotpApp(id = 2, name = "Twilio Authy", createdAt = 0) - } - - private val testDispatcher = UnconfinedTestDispatcher() - private val repository: TotpRepository = mockk(relaxed = true) - - @get:Rule - val dispatcherRule = CoroutineDispatcherRule(testDispatcher) - - @Before - fun setUp() { - Dispatchers.setMain(testDispatcher) - - every { repository.getMaxApps() } returns 2 - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(emptyList()) - coEvery { repository.removeTotpApp(any()) } returns TotpRepository.UpdateResult.Success - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `the configured apps are read on creation`() = runTest(testDispatcher) { - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE)) - - val viewModel = createViewModel() - - assertThat(viewModel.state.value.apps).containsExactly(APP_ONE) - } - - /** An empty list says nothing on its own, so the screen leans on [LoadState] to know we haven't heard back yet. */ - @Test - fun `the state is LOADING until we've heard back about the account`() = runTest(testDispatcher) { - coEvery { repository.getTotpApps() } coAnswers { awaitCancellation() } - - val viewModel = createViewModel() - - assertThat(viewModel.state.value.loadState).isEqualTo(LoadState.LOADING) - assertThat(viewModel.state.value.apps).isEmpty() - } - - @Test - fun `a service we couldn't reach clears the list and says so`() = runTest(testDispatcher) { - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.NetworkFailure - - val viewModel = createViewModel() - - assertThat(viewModel.state.value.loadState).isEqualTo(LoadState.NETWORK_FAILURE) - assertThat(viewModel.state.value.apps).isEmpty() - } - - @Test - fun `a load that succeeds after one that failed clears the failure`() = runTest(testDispatcher) { - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.NetworkFailure - - val viewModel = createViewModel() - - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE)) - viewModel.onEvent(TotpAppListEvent.ScreenResumed) - - assertThat(viewModel.state.value.loadState).isEqualTo(LoadState.LOADED) - assertThat(viewModel.state.value.apps).containsExactly(APP_ONE) - } - - @Test - fun `ScreenResumed picks up apps added elsewhere`() = runTest(testDispatcher) { - val viewModel = createViewModel() - - assertThat(viewModel.state.value.apps).isEmpty() - - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE)) - viewModel.onEvent(TotpAppListEvent.ScreenResumed) - - assertThat(viewModel.state.value.apps).containsExactly(APP_ONE) - } - - @Test - fun `AddTotpAppClicked opens setup when there's room for another app`() = runTest(testDispatcher) { - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE)) - - val viewModel = createViewModel() - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(TotpAppListEvent.AddTotpAppClicked) - - assertThat(actions.last()).isEqualTo(TotpAppListAction.NavigateToSetup) - } - - @Test - fun `AddTotpAppClicked explains the limit when there's no room for another app`() = runTest(testDispatcher) { - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE, APP_TWO)) - - val viewModel = createViewModel() - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(TotpAppListEvent.AddTotpAppClicked) - - assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.MaxAppsReached) - assertThat(actions).isEmpty() - } - - @Test - fun `RenameAppClicked opens the naming screen for that app`() = runTest(testDispatcher) { - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE)) - - val viewModel = createViewModel() - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(TotpAppListEvent.RenameAppClicked(APP_ONE.id)) - - assertThat(actions.last()).isEqualTo(TotpAppListAction.NavigateToRename(APP_ONE)) - } - - @Test - fun `RemoveAppClicked asks the user to confirm first`() = runTest(testDispatcher) { - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE)) - - val viewModel = createViewModel() - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(TotpAppListEvent.RemoveAppClicked(APP_ONE.id)) - - assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmRemove(APP_ONE.id)) - assertThat(actions).isEmpty() - } - - @Test - fun `RemoveAppConfirmed removes the app and says so`() = runTest(testDispatcher) { - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE)) - - val viewModel = createViewModel() - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(TotpAppListEvent.RemoveAppClicked(APP_ONE.id)) - viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id)) - - coVerify { repository.removeTotpApp(APP_ONE.id) } - assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None) - assertThat(actions.last()).isEqualTo(TotpAppListAction.ShowTotpAppRemoved) - } - - /** The list is what tells the user the app is gone, so it has to be read again rather than assumed. */ - @Test - fun `a removal re-reads the list`() = runTest(testDispatcher) { - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE)) - - val viewModel = createViewModel() - - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(emptyList()) - viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id)) - - assertThat(viewModel.state.value.apps).isEmpty() - } - - /** Removing a key the service has already forgotten is the outcome the user wanted, so it isn't an error. */ - @Test - fun `removing an app the service doesn't have still counts as removed`() = runTest(testDispatcher) { - coEvery { repository.removeTotpApp(any()) } returns TotpRepository.UpdateResult.AppNotFound - - val viewModel = createViewModel() - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id)) - - assertThat(actions.last()).isEqualTo(TotpAppListAction.ShowTotpAppRemoved) - } - - @Test - fun `a removal that didn't go through says so rather than pretending the app is gone`() = runTest(testDispatcher) { - coEvery { repository.removeTotpApp(any()) } returns TotpRepository.UpdateResult.NetworkFailure - - val viewModel = createViewModel() - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id)) - - assertThat(actions.last()).isEqualTo(TotpAppListAction.ShowRemovalFailed) - } - - /** - * What the confirm button actually does: the dialog dismisses itself before it reports the confirmation, so the - * removal has to survive arriving after the dialog is already gone. - */ - @Test - fun `RemoveAppConfirmed removes the app even though the dialog dismissed itself first`() = runTest(testDispatcher) { - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE)) - - val viewModel = createViewModel() - - viewModel.onEvent(TotpAppListEvent.RemoveAppClicked(APP_ONE.id)) - viewModel.onEvent(TotpAppListEvent.DialogDismissed) - viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id)) - - coVerify { repository.removeTotpApp(APP_ONE.id) } - } - - @Test - fun `DialogDismissed clears the dialog`() = runTest(testDispatcher) { - coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE)) - - val viewModel = createViewModel() - - viewModel.onEvent(TotpAppListEvent.RemoveAppClicked(APP_ONE.id)) - viewModel.onEvent(TotpAppListEvent.DialogDismissed) - - assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None) - } - - @Test - fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) { - val viewModel = createViewModel() - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(TotpAppListEvent.NavigateBackClicked) - - assertThat(actions.last()).isEqualTo(TotpAppListAction.NavigateBack) - } - - @Test - fun `LearnMoreClicked opens the support article`() = runTest(testDispatcher) { - val viewModel = createViewModel() - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(TotpAppListEvent.LearnMoreClicked) - - assertThat(actions.last()).isEqualTo(TotpAppListAction.OpenLearnMore) - } - - private fun createViewModel() = TotpAppListViewModel(repository) - - private fun TestScope.collectActions(actions: Flow): List { - val collected = mutableListOf() - backgroundScope.launch { actions.toList(collected) } - return collected - } -} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNameEntryViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNameEntryViewModelTest.kt index 138112cba1..634bee8f38 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNameEntryViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNameEntryViewModelTest.kt @@ -29,7 +29,7 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test -import org.signal.appsettings.totpapplist.TotpApp +import org.signal.appsettings.totp.TotpApp import org.signal.appsettings.totpnameentry.TotpNameEntryAction import org.signal.appsettings.totpnameentry.TotpNameEntryEvent import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule @@ -104,7 +104,7 @@ class TotpNameEntryViewModelTest { coVerify { repository.nameNewTotpApp(NEW_APP_ID, "Bitwarden Authenticator") } assertThat(actions).contains(TotpNameEntryAction.ShowTotpAppSetUp) - assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateToTotpAppList) + assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateToAccountSettings) } @Test @@ -117,7 +117,7 @@ class TotpNameEntryViewModelTest { coVerify { repository.renameTotpApp(EXISTING_APP, "Work Authenticator") } assertThat(actions).contains(TotpNameEntryAction.ShowTotpAppRenamed) - assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateToTotpAppList) + assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateToAccountSettings) } @Test diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNavArgsTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNavArgsTest.kt index 6e30e5588b..69ef120565 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNavArgsTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpNavArgsTest.kt @@ -14,7 +14,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import org.signal.appsettings.totpapplist.TotpApp +import org.signal.appsettings.totp.TotpApp @RunWith(RobolectricTestRunner::class) @Config(application = Application::class) diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpRepositoryTest.kt index 2d2b4123e9..35e61d884d 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpRepositoryTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/TotpRepositoryTest.kt @@ -19,7 +19,7 @@ import io.mockk.slot import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test -import org.signal.appsettings.totpapplist.TotpApp +import org.signal.appsettings.totp.TotpApp import org.signal.core.models.MasterKey import org.signal.libsignal.net.ConfirmedMfaKey import org.signal.libsignal.net.MfaKeyKind diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsAction.kt index 33eac38911..b0cb9e7e5f 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsAction.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsAction.kt @@ -5,6 +5,8 @@ package org.signal.appsettings.account +import org.signal.appsettings.totp.TotpApp + /** * One-shot side effects that need an Activity or the legacy nav graph, and therefore have to be carried out by * [AccountSettingsFragment] rather than the screen itself. @@ -28,11 +30,20 @@ sealed interface AccountSettingsAction { /** Open the screen that shows the account and recovery keys that make up the Signal Login. */ data object NavigateToSignalLoginDetails : AccountSettingsAction - /** Open the screen listing the account's authenticator apps. */ - data object NavigateToTotpAppList : AccountSettingsAction + /** Open the flow that pairs a new authenticator app. */ + data object NavigateToTotpSetup : AccountSettingsAction - /** Open the passkeys screen. */ - data object NavigateToPasskeys : AccountSettingsAction + /** Open the screen that renames [app]. */ + data class NavigateToRenameTotpApp(val app: TotpApp) : AccountSettingsAction + + /** Tell the user their authenticator app was removed. */ + data object ShowTotpAppRemoved : AccountSettingsAction + + /** Tell the user the removal didn't go through, so they know the app is still on the account. */ + data object ShowTotpAppRemovalFailed : AccountSettingsAction + + /** Send the user to a support article about two-factor authentication. */ + data object OpenLearnMore : AccountSettingsAction /** Open the advanced PIN settings screen. */ data object NavigateToAdvancedPinSettings : AccountSettingsAction diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsEvent.kt index f5519dc140..78e8c54961 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsEvent.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsEvent.kt @@ -45,11 +45,20 @@ sealed interface AccountSettingsEvent { /** The user confirmed turning registration lock on or off. */ data object RegistrationLockConfirmed : AccountSettingsEvent - /** The user tapped the authenticator app row in the two-factor authentication section. */ - data object TotpAppClicked : AccountSettingsEvent + /** The user tapped the authenticator app option in the two-factor set-up menu. */ + data object AddTotpAppClicked : AccountSettingsEvent - /** The user tapped the passkeys row in the two-factor authentication section. */ - data object PasskeysClicked : AccountSettingsEvent + /** The user tapped the learn more link on the dialog explaining the authenticator app limit. */ + data object LearnMoreClicked : AccountSettingsEvent + + /** The user tapped the rename option in [method]'s overflow menu. */ + data class RenameMethodClicked(val method: TwoFactorMethod) : AccountSettingsEvent + + /** The user tapped the remove option in [method]'s overflow menu, which asks them to confirm first. */ + data class RemoveMethodClicked(val method: TwoFactorMethod) : AccountSettingsEvent + + /** The user confirmed removing the authenticator app named by the open dialog, which removes it. */ + data object RemoveTotpAppConfirmed : AccountSettingsEvent /** The user tapped the advanced PIN settings row. */ data object AdvancedPinSettingsClicked : AccountSettingsEvent diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsScreen.kt index 7a16a27029..49d51ccbad 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsScreen.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsScreen.kt @@ -5,21 +5,27 @@ package org.signal.appsettings.account +import android.text.format.DateUtils import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.compose.foundation.Image 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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -32,10 +38,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -46,12 +52,15 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import org.signal.appsettings.R import org.signal.appsettings.account.AccountSettingsState.Dialog +import org.signal.appsettings.account.AccountSettingsState.LoadState import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.DropdownMenus import org.signal.core.ui.compose.PinVisualTransformation import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Rows.TextAndLabel import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.Texts @@ -62,8 +71,14 @@ import org.signal.core.ui.R as CoreUiR object AccountSettingsTestTags { const val SCROLLER = "scroller" const val CARD_SIGNAL_LOGIN = "card-signal-login" - const val ROW_TOTP_APP = "row-totp-app" - const val ROW_PASSKEYS = "row-passkeys" + const val ROW_SET_UP_TWO_FACTOR = "row-set-up-two-factor" + const val MENU_ITEM_AUTHENTICATOR_APP = "menu-item-authenticator-app" + const val ROW_TWO_FACTOR_METHOD = "row-two-factor-method" + const val BUTTON_METHOD_MENU = "button-method-menu" + const val MENU_ITEM_RENAME = "menu-item-rename" + const val MENU_ITEM_REMOVE = "menu-item-remove" + const val TWO_FACTOR_LOADING = "two-factor-loading" + const val TWO_FACTOR_LOAD_FAILED_MESSAGE = "two-factor-load-failed-message" const val ROW_MODIFY_PIN = "row-modify-pin" const val ROW_PIN_REMINDER = "row-pin-reminder" const val ROW_REGISTRATION_LOCK = "row-registration-lock" @@ -78,6 +93,8 @@ object AccountSettingsTestTags { const val DIALOG_CONFIRM_DELETE_ALL_DATA = "dialog-confirm-delete-all-data" const val DIALOG_CONFIRM_PIN = "dialog-confirm-pin" const val DIALOG_CONFIRM_REGISTRATION_LOCK = "dialog-confirm-registration-lock" + const val DIALOG_CONFIRM_REMOVE_TOTP_APP = "dialog-confirm-remove-totp-app" + const val DIALOG_MAX_TOTP_APPS_REACHED = "dialog-max-totp-apps-reached" const val PIN_INPUT = "pin-input" const val PIN_KEYBOARD_TOGGLE = "pin-keyboard-toggle" } @@ -125,34 +142,42 @@ fun AccountSettingsScreen( } item { - // A null count means we couldn't find out, which reads as the generic subtitle rather than as "none configured". - val totpAppCount = state.signalLogin.totpAppCount - - Rows.TextRow( - icon = SignalIcons.DevicePhone.imageVector, - text = stringResource(R.string.AccountSettingsFragment__authenticator_app), - label = if (totpAppCount != null && totpAppCount > 0) { - pluralStringResource(R.plurals.AccountSettingsFragment__d_configured, totpAppCount, totpAppCount) - } else { - stringResource(R.string.AccountSettingsFragment__one_time_verification_codes) - }, - onClick = { onEvent(AccountSettingsEvent.TotpAppClicked) }, - modifier = Modifier.testTag(AccountSettingsTestTags.ROW_TOTP_APP) - ) + SetUpTwoFactorRow(onEvent = onEvent) } - item { - Rows.TextRow( - icon = SignalIcons.Key.imageVector, - text = stringResource(R.string.AccountSettingsFragment__passkeys), - label = if (state.signalLogin.passkeyCount > 0) { - pluralStringResource(R.plurals.AccountSettingsFragment__d_passkeys, state.signalLogin.passkeyCount, state.signalLogin.passkeyCount) - } else { - stringResource(R.string.AccountSettingsFragment__device_biometrics_or_fido2_security_key) - }, - onClick = { onEvent(AccountSettingsEvent.PasskeysClicked) }, - modifier = Modifier.testTag(AccountSettingsTestTags.ROW_PASSKEYS) - ) + when (state.signalLogin.loadState) { + LoadState.LOADING -> item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + ) { + CircularProgressIndicator( + modifier = Modifier + .size(24.dp) + .testTag(AccountSettingsTestTags.TWO_FACTOR_LOADING) + ) + } + } + + LoadState.NETWORK_FAILURE -> item { + Text( + text = stringResource(R.string.AccountSettingsFragment__couldnt_load_your_two_factor_methods), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = dimensionResource(CoreUiR.dimen.gutter), vertical = 16.dp) + .testTag(AccountSettingsTestTags.TWO_FACTOR_LOAD_FAILED_MESSAGE) + ) + } + + LoadState.LOADED -> items(state.signalLogin.twoFactorMethods, key = { "${it.kind}:${it.id}" }) { method -> + TwoFactorMethodRow( + method = method, + onEvent = onEvent + ) + } } item { @@ -325,6 +350,155 @@ fun AccountSettingsScreen( RegistrationLockConfirmationDialog(dialog, onEvent) } } + is Dialog.ConfirmRemoveTotpApp -> ConfirmRemoveTotpAppDialog(onEvent = onEvent) + Dialog.MaxTotpAppsReached -> MaxTotpAppsReachedDialog(maxApps = state.signalLogin?.maxTotpApps ?: 0, onEvent = onEvent) + } +} + +/** + * The row that starts adding a second factor, which offers a choice of what to add. Passkeys aren't supported yet, so + * an authenticator app is the only thing there is to choose. + */ +@Composable +private fun SetUpTwoFactorRow( + onEvent: (AccountSettingsEvent) -> Unit, + modifier: Modifier = Modifier +) { + val menuController = remember { DropdownMenus.MenuController() } + + Box(modifier = modifier) { + Rows.TextRow( + icon = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + Icon( + imageVector = SignalIcons.Plus.imageVector, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + } + }, + text = { + TextAndLabel(text = stringResource(R.string.AccountSettingsFragment__set_up)) + }, + onClick = menuController::show, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR) + ) + + DropdownMenus.Menu(controller = menuController) { controller -> + DropdownMenus.Item( + leadingIconResId = CoreUiR.drawable.symbol_device_phone_24, + text = { + Column { + Text(text = stringResource(R.string.AccountSettingsFragment__authenticator_app)) + + Text( + text = stringResource(R.string.AccountSettingsFragment__one_time_verification_codes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + onClick = { + onEvent(AccountSettingsEvent.AddTotpAppClicked) + controller.hide() + }, + modifier = Modifier.testTag(AccountSettingsTestTags.MENU_ITEM_AUTHENTICATOR_APP) + ) + } + } +} + +@Composable +private fun TwoFactorMethodRow( + method: TwoFactorMethod, + onEvent: (AccountSettingsEvent) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val addedTime = remember(method.createdAt) { + DateUtils.getRelativeDateTimeString(context, method.createdAt, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString() + } + + val icon = when (method.kind) { + TwoFactorMethod.Kind.AUTHENTICATOR_APP -> SignalIcons.DevicePhone + TwoFactorMethod.Kind.PASSKEY -> SignalIcons.Key + } + + val kindName = when (method.kind) { + TwoFactorMethod.Kind.AUTHENTICATOR_APP -> stringResource(R.string.AccountSettingsFragment__authenticator_app) + TwoFactorMethod.Kind.PASSKEY -> stringResource(R.string.AccountSettingsFragment__passkey) + } + + Rows.TextRow( + icon = { + Icon( + painter = icon.painter, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + TextAndLabel( + text = method.name, + label = stringResource(R.string.AccountSettingsFragment__s_added_s, kindName, addedTime) + ) + + TwoFactorMethodMenuButton( + method = method, + onEvent = onEvent + ) + }, + modifier = modifier.testTag(AccountSettingsTestTags.ROW_TWO_FACTOR_METHOD) + ) +} + +@Composable +private fun TwoFactorMethodMenuButton( + method: TwoFactorMethod, + onEvent: (AccountSettingsEvent) -> Unit, + modifier: Modifier = Modifier +) { + val menuController = remember { DropdownMenus.MenuController() } + + Box(modifier = modifier) { + IconButton( + onClick = menuController::show, + modifier = Modifier.testTag(AccountSettingsTestTags.BUTTON_METHOD_MENU) + ) { + Icon( + imageVector = SignalIcons.MoreVertical.imageVector, + contentDescription = stringResource(R.string.AccountSettingsFragment__open_two_factor_options), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + DropdownMenus.Menu(controller = menuController) { controller -> + DropdownMenus.Item( + leadingIconResId = CoreUiR.drawable.symbol_edit_24, + text = { Text(text = stringResource(R.string.AccountSettingsFragment__rename)) }, + onClick = { + onEvent(AccountSettingsEvent.RenameMethodClicked(method)) + controller.hide() + }, + modifier = Modifier.testTag(AccountSettingsTestTags.MENU_ITEM_RENAME) + ) + + DropdownMenus.Item( + leadingIconResId = CoreUiR.drawable.symbol_x_circle_24, + text = { Text(text = stringResource(R.string.AccountSettingsFragment__remove)) }, + onClick = { + onEvent(AccountSettingsEvent.RemoveMethodClicked(method)) + controller.hide() + }, + modifier = Modifier.testTag(AccountSettingsTestTags.MENU_ITEM_REMOVE) + ) + } } } @@ -412,6 +586,40 @@ private fun DeleteAllDataConfirmationDialog( ) } +@Composable +private fun ConfirmRemoveTotpAppDialog( + onEvent: (AccountSettingsEvent) -> Unit +) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.AccountSettingsFragment__remove_authenticator_app), + body = stringResource(R.string.AccountSettingsFragment__you_wont_be_able_to_use_this_app), + confirm = stringResource(R.string.AccountSettingsFragment__remove), + onConfirm = { onEvent(AccountSettingsEvent.RemoveTotpAppConfirmed) }, + onDismiss = { onEvent(AccountSettingsEvent.DialogDismissed) }, + dismiss = stringResource(android.R.string.cancel), + onDismissRequest = { onEvent(AccountSettingsEvent.DialogDismissed) }, + modifier = Modifier.testTag(AccountSettingsTestTags.DIALOG_CONFIRM_REMOVE_TOTP_APP) + ) +} + +@Composable +private fun MaxTotpAppsReachedDialog( + maxApps: Int, + onEvent: (AccountSettingsEvent) -> Unit +) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.AccountSettingsFragment__cant_add_authenticator_app), + body = stringResource(R.string.AccountSettingsFragment__you_cant_add_more_than_d, maxApps), + confirm = stringResource(android.R.string.ok), + onConfirm = {}, + onDismiss = { onEvent(AccountSettingsEvent.DialogDismissed) }, + dismiss = stringResource(R.string.AccountSettingsFragment__learn_more), + onDeny = { onEvent(AccountSettingsEvent.LearnMoreClicked) }, + onDismissRequest = { onEvent(AccountSettingsEvent.DialogDismissed) }, + modifier = Modifier.testTag(AccountSettingsTestTags.DIALOG_MAX_TOTP_APPS_REACHED) + ) +} + @Composable private fun RegistrationLockConfirmationDialog( dialog: Dialog.ConfirmRegistrationLock, @@ -564,7 +772,11 @@ private fun AccountSettingsScreenSignalLoginPreview() { AccountSettingsScreen( state = AccountSettingsState( isPhoneNumberless = true, - signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 2, passkeyCount = 8) + signalLogin = AccountSettingsState.SignalLogin( + twoFactorMethods = PREVIEW_TWO_FACTOR_METHODS, + loadState = LoadState.LOADED, + maxTotpApps = 2 + ) ), onEvent = {} ) @@ -582,6 +794,34 @@ private fun AccountSettingsScreenDeprecatedPreview() { } } +@DayNightPreviews +@Composable +private fun AccountSettingsScreenNoTwoFactorMethodsPreview() { + Previews.Preview { + AccountSettingsScreen( + state = AccountSettingsState( + isPhoneNumberless = true, + signalLogin = AccountSettingsState.SignalLogin(loadState = LoadState.LOADED, maxTotpApps = 2) + ), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun AccountSettingsScreenTwoFactorLoadFailedPreview() { + Previews.Preview { + AccountSettingsScreen( + state = AccountSettingsState( + isPhoneNumberless = true, + signalLogin = AccountSettingsState.SignalLogin(loadState = LoadState.NETWORK_FAILURE, maxTotpApps = 2) + ), + onEvent = {} + ) + } +} + @DayNightPreviews @Composable private fun DeleteAllDataConfirmationDialogPreview() { @@ -611,3 +851,25 @@ private fun ConfirmPinToDisableRemindersDialogPreview() { ) } } + +@DayNightPreviews +@Composable +private fun ConfirmRemoveTotpAppDialogPreview() { + Previews.Preview { + ConfirmRemoveTotpAppDialog(onEvent = {}) + } +} + +@DayNightPreviews +@Composable +private fun MaxTotpAppsReachedDialogPreview() { + Previews.Preview { + MaxTotpAppsReachedDialog(maxApps = 2, onEvent = {}) + } +} + +private val PREVIEW_TWO_FACTOR_METHODS = listOf( + TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.AUTHENTICATOR_APP, name = "Bitwarden Authenticator", createdAt = System.currentTimeMillis()), + TwoFactorMethod(id = 2, kind = TwoFactorMethod.Kind.AUTHENTICATOR_APP, name = "Twilio Authy", createdAt = System.currentTimeMillis()), + TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.PASSKEY, name = "Pixel Phone", createdAt = System.currentTimeMillis()) +) diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsState.kt index e208230f72..2a471c89af 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsState.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsState.kt @@ -26,10 +26,28 @@ data class AccountSettingsState( * the sections aren't shown at all. */ data class SignalLogin( - /** How many authenticator apps are on the account, or null while we don't know -- see `getTotpAppCount`. */ - val totpAppCount: Int?, - val passkeyCount: Int - ) + /** The second factors on the account, which only mean anything once [loadState] is [LoadState.LOADED]. */ + val twoFactorMethods: List = emptyList(), + /** How the last look at the account went, which decides what the two-factor list shows in place of rows. */ + val loadState: LoadState = LoadState.LOADING, + /** How many authenticator apps the account is allowed to have at once. */ + val maxTotpApps: Int = 0 + ) { + + val atMaxTotpApps: Boolean + get() = twoFactorMethods.count { it.kind == TwoFactorMethod.Kind.AUTHENTICATOR_APP } >= maxTotpApps + } + + /** How the last attempt to read the account's second factors went, since an empty list can't say on its own. */ + enum class LoadState { + /** We haven't heard back about the account yet. */ + LOADING, + + LOADED, + + /** We couldn't reach the service, which is worth another try. */ + NETWORK_FAILURE + } /** Whichever dialog the screen is showing, if any. Only one is ever up at a time. */ sealed interface Dialog { @@ -56,5 +74,11 @@ data class AccountSettingsState( val enable: Boolean, val inProgress: Boolean = false ) : Dialog + + /** Confirms removing [appId], which still has to be backed up by a code from the app itself. */ + data class ConfirmRemoveTotpApp(val appId: Long) : Dialog + + /** Explains that the account already has as many authenticator apps as it's allowed. */ + data object MaxTotpAppsReached : Dialog } } diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/account/TwoFactorMethod.kt b/feature/app-settings/src/main/java/org/signal/appsettings/account/TwoFactorMethod.kt new file mode 100644 index 0000000000..2962903e8f --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/account/TwoFactorMethod.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.account + +import org.signal.core.util.censor + +/** + * One second factor on the user's account, as shown in the unified two-factor list on [AccountSettingsScreen]. + * + * Ids are only unique within a [kind], since each kind comes from a different place on the account, so anything + * identifying a method has to carry both. + */ +data class TwoFactorMethod( + val id: Long, + val kind: Kind, + val name: String, + /** When the method was added to the account, in epoch milliseconds. */ + val createdAt: Long +) { + + /** What sort of second factor this is, which decides its icon, its subtitle, and what its menu can do. */ + enum class Kind { + AUTHENTICATOR_APP, + PASSKEY + } + + override fun toString(): String = "TwoFactorMethod(id=$id, kind=$kind, name=${name.censor()}, createdAt=$createdAt)" +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/Passkey.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/Passkey.kt deleted file mode 100644 index 95b429ef6f..0000000000 --- a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/Passkey.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.passkeys - -/** - * A single passkey on the user's account, as shown on [PasskeysScreen]. - */ -data class Passkey( - val id: Long, - val name: String, - /** When the passkey was added, in epoch milliseconds. */ - val createdAt: Long -) { - override fun toString(): String = "Passkey(id=$id)" -} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysAction.kt deleted file mode 100644 index 37127167d0..0000000000 --- a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysAction.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.passkeys - -/** - * One-shot side effects that need an Activity or the nav graph, and therefore have to be carried out by the fragment - * hosting [PasskeysScreen] rather than the screen itself. - * - * Actions are logged, so be sure `toString()` contains nothing sensitive. - */ -sealed interface PasskeysAction { - - /** Leave the screen. */ - data object NavigateBack : PasskeysAction - - /** Kick off creating a new passkey. */ - data object LaunchPasskeyCreation : PasskeysAction - - /** Send the user to a support article about passkeys. */ - data object OpenLearnMore : PasskeysAction -} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysEvent.kt deleted file mode 100644 index 0dfcb32590..0000000000 --- a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysEvent.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.passkeys - -/** - * Reminder that these events are logged, so don't include anything sensitive in the toString. - */ -sealed interface PasskeysEvent { - - /** The user tapped the navigation (back) icon. */ - data object NavigateBackClicked : PasskeysEvent - - /** The user tapped the button that starts creating a passkey. */ - data object SetUpPasskeyClicked : PasskeysEvent - - /** The user tapped the learn more link. */ - data object LearnMoreClicked : PasskeysEvent - - /** The user tapped the rename option in a passkey's overflow menu. */ - data class RenamePasskeyClicked(val passkeyId: Long) : PasskeysEvent - - /** The user tapped the remove option in a passkey's overflow menu. */ - data class RemovePasskeyClicked(val passkeyId: Long) : PasskeysEvent -} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysRepository.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysRepository.kt deleted file mode 100644 index f16eb59661..0000000000 --- a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysRepository.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.passkeys - -/** - * Where [PasskeysScreen] reads passkeys from. - */ -interface PasskeysRepository { - - fun getPasskeys(): List -} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysScreen.kt deleted file mode 100644 index 6ff2a0bbf0..0000000000 --- a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysScreen.kt +++ /dev/null @@ -1,392 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.passkeys - -import android.text.format.DateUtils -import androidx.annotation.VisibleForTesting -import androidx.compose.foundation.Image -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withLink -import androidx.compose.ui.unit.dp -import org.signal.appsettings.R -import org.signal.core.ui.compose.Buttons -import org.signal.core.ui.compose.DayNightPreviews -import org.signal.core.ui.compose.Dividers -import org.signal.core.ui.compose.DropdownMenus -import org.signal.core.ui.compose.Previews -import org.signal.core.ui.compose.Rows -import org.signal.core.ui.compose.Rows.TextAndLabel -import org.signal.core.ui.compose.Scaffolds -import org.signal.core.ui.compose.SignalIcons -import org.signal.core.ui.compose.Texts -import org.signal.core.ui.R as CoreUiR - -@VisibleForTesting -object PasskeysTestTags { - const val SCROLLER = "scroller" - const val LEARN_MORE = "learn-more" - const val BUTTON_SET_UP = "button-set-up" - const val ROW_PASSKEY = "row-passkey" - const val BUTTON_PASSKEY_MENU = "button-passkey-menu" - const val MENU_ITEM_RENAME = "menu-item-rename" - const val MENU_ITEM_REMOVE = "menu-item-remove" -} - -/** - * Explains what passkeys are and lets the user start creating one. Once passkeys exist, they're listed here with - * management options instead. - */ -@Composable -fun PasskeysScreen( - state: PasskeysState, - onEvent: (PasskeysEvent) -> Unit -) { - Scaffolds.Settings( - title = stringResource(R.string.PasskeysScreen__passkeys), - onNavigationClick = { onEvent(PasskeysEvent.NavigateBackClicked) }, - navigationIcon = SignalIcons.ArrowStart.imageVector - ) { contentPadding -> - if (state.passkeys.isEmpty()) { - NoPasskeysContent( - onEvent = onEvent, - modifier = Modifier.padding(contentPadding) - ) - } else { - PasskeyListContent( - passkeys = state.passkeys, - onEvent = onEvent, - modifier = Modifier.padding(contentPadding) - ) - } - } -} - -/** - * Shown before any passkeys exist: an explanation of what passkeys are with a button to create the first one. - */ -@Composable -private fun NoPasskeysContent( - onEvent: (PasskeysEvent) -> Unit, - modifier: Modifier = Modifier -) { - Column(modifier = modifier.fillMaxSize()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - .testTag(PasskeysTestTags.SCROLLER) - ) { - Image( - painter = painterResource(R.drawable.image_passkeys_phone), - contentDescription = null, - modifier = Modifier.padding(top = 32.dp) - ) - - DescriptionWithLearnMore( - text = stringResource(R.string.PasskeysScreen__with_passkeys_you_can_easily_add), - onEvent = onEvent, - modifier = Modifier - .padding(top = 24.dp) - .padding(horizontal = 28.dp) - .testTag(PasskeysTestTags.LEARN_MORE) - ) - - Column( - verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier - .fillMaxWidth() - .padding(top = 40.dp) - .padding(horizontal = 64.dp) - ) { - BulletRow( - icon = SignalIcons.CheckCircle, - text = stringResource(R.string.PasskeysScreen__give_your_passkey_a_friendly_name) - ) - - BulletRow( - icon = SignalIcons.Lock, - text = stringResource(R.string.PasskeysScreen__use_your_devices_biometrics) - ) - - BulletRow( - icon = SignalIcons.Trash, - text = stringResource(R.string.PasskeysScreen__add_or_remove_passkeys_at_anytime) - ) - } - } - - SetUpPasskeyButton( - text = stringResource(R.string.PasskeysScreen__set_up_a_passkey), - onEvent = onEvent, - modifier = Modifier.padding(vertical = 16.dp) - ) - } -} - -/** - * Shown once passkeys exist: a shorter explanation with a button to add another, followed by the list of passkeys. - */ -@Composable -private fun PasskeyListContent( - passkeys: List, - onEvent: (PasskeysEvent) -> Unit, - modifier: Modifier = Modifier -) { - LazyColumn( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - .fillMaxSize() - .testTag(PasskeysTestTags.SCROLLER) - ) { - item { - Image( - painter = painterResource(R.drawable.image_passkeys_phone), - contentDescription = null, - modifier = Modifier.padding(top = 32.dp) - ) - } - - item { - DescriptionWithLearnMore( - text = stringResource(R.string.PasskeysScreen__set_up_a_passkey_with), - onEvent = onEvent, - modifier = Modifier - .padding(top = 24.dp) - .padding(horizontal = 34.dp) - .testTag(PasskeysTestTags.LEARN_MORE) - ) - } - - item { - SetUpPasskeyButton( - text = stringResource(R.string.PasskeysScreen__add_a_new_passkey), - onEvent = onEvent, - modifier = Modifier.padding(top = 24.dp, bottom = 20.dp) - ) - } - - item { - Dividers.Default() - } - - item { - Texts.SectionHeader( - text = stringResource(R.string.PasskeysScreen__passkeys), - modifier = Modifier.fillMaxWidth() - ) - } - - items(passkeys, key = { it.id }) { passkey -> - PasskeyRow( - passkey = passkey, - onEvent = onEvent - ) - } - } -} - -@Composable -private fun PasskeyRow( - passkey: Passkey, - onEvent: (PasskeysEvent) -> Unit -) { - val context = LocalContext.current - val addedTime = remember(passkey.createdAt) { - DateUtils.getRelativeDateTimeString(context, passkey.createdAt, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString() - } - - Rows.TextRow( - icon = { - Icon( - painter = SignalIcons.Key.painter, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface - ) - }, - text = { - TextAndLabel( - text = passkey.name, - label = stringResource(R.string.PasskeysScreen__added_s, addedTime) - ) - - PasskeyMenuButton( - passkey = passkey, - onEvent = onEvent - ) - }, - modifier = Modifier.testTag(PasskeysTestTags.ROW_PASSKEY) - ) -} - -@Composable -private fun PasskeyMenuButton( - passkey: Passkey, - onEvent: (PasskeysEvent) -> Unit -) { - val menuController = remember { DropdownMenus.MenuController() } - - Box { - IconButton( - onClick = menuController::show, - modifier = Modifier.testTag(PasskeysTestTags.BUTTON_PASSKEY_MENU) - ) { - Icon( - imageVector = SignalIcons.MoreVertical.imageVector, - contentDescription = stringResource(R.string.PasskeysScreen__open_passkey_options), - tint = MaterialTheme.colorScheme.onSurface - ) - } - - DropdownMenus.Menu(controller = menuController) { controller -> - DropdownMenus.Item( - leadingIconResId = CoreUiR.drawable.symbol_edit_24, - text = { Text(text = stringResource(R.string.PasskeysScreen__rename)) }, - onClick = { - onEvent(PasskeysEvent.RenamePasskeyClicked(passkey.id)) - controller.hide() - }, - modifier = Modifier.testTag(PasskeysTestTags.MENU_ITEM_RENAME) - ) - - DropdownMenus.Item( - leadingIconResId = CoreUiR.drawable.symbol_x_circle_24, - text = { Text(text = stringResource(R.string.PasskeysScreen__remove)) }, - onClick = { - onEvent(PasskeysEvent.RemovePasskeyClicked(passkey.id)) - controller.hide() - }, - modifier = Modifier.testTag(PasskeysTestTags.MENU_ITEM_REMOVE) - ) - } - } -} - -@Composable -private fun DescriptionWithLearnMore( - text: String, - onEvent: (PasskeysEvent) -> Unit, - modifier: Modifier = Modifier -) { - Text( - text = buildAnnotatedString { - append(text) - append(' ') - - withLink( - LinkAnnotation.Clickable( - tag = "learn-more", - styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)), - linkInteractionListener = { onEvent(PasskeysEvent.LearnMoreClicked) } - ) - ) { - append(stringResource(R.string.PasskeysScreen__learn_more)) - } - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = modifier - ) -} - -@Composable -private fun SetUpPasskeyButton( - text: String, - onEvent: (PasskeysEvent) -> Unit, - modifier: Modifier = Modifier -) { - Buttons.MediumTonal( - onClick = { onEvent(PasskeysEvent.SetUpPasskeyClicked) }, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ), - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 40.dp) - .testTag(PasskeysTestTags.BUTTON_SET_UP) - ) { - Text(text = text) - } -} - -@Composable -private fun BulletRow( - icon: SignalIcons, - text: String -) { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Icon( - painter = icon.painter, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp) - ) - - Text( - text = text, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -@DayNightPreviews -@Composable -private fun PasskeysScreenPreview() { - Previews.Preview { - PasskeysScreen( - state = PasskeysState(), - onEvent = {} - ) - } -} - -@DayNightPreviews -@Composable -private fun PasskeysScreenWithPasskeysPreview() { - Previews.Preview { - PasskeysScreen( - state = PasskeysState( - passkeys = listOf( - Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()), - Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis()) - ) - ), - onEvent = {} - ) - } -} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysState.kt deleted file mode 100644 index 079c09cbd2..0000000000 --- a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysState.kt +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.passkeys - -data class PasskeysState( - /** The passkeys on the account. When empty, the screen explains passkeys instead of listing them. */ - val passkeys: List = emptyList() -) diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysViewModel.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysViewModel.kt deleted file mode 100644 index 4a5e97627b..0000000000 --- a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysViewModel.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.passkeys - -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import org.signal.core.ui.compose.EventDrivenViewModel -import org.signal.core.util.logging.Log - -/** - * Drives the screen that explains passkeys and lists any that already exist. - */ -class PasskeysViewModel( - repository: PasskeysRepository -) : EventDrivenViewModel(TAG) { - - companion object { - private val TAG = Log.tag(PasskeysViewModel::class) - } - - private val _state = MutableStateFlow(PasskeysState(passkeys = repository.getPasskeys())) - private val _actions = Channel(Channel.BUFFERED) - - val state: StateFlow = _state.asStateFlow() - val actions: Flow = _actions.receiveAsFlow() - - override suspend fun processEvent(event: PasskeysEvent) { - when (event) { - PasskeysEvent.NavigateBackClicked -> { - _actions.send(PasskeysAction.NavigateBack) - } - PasskeysEvent.SetUpPasskeyClicked -> { - _actions.send(PasskeysAction.LaunchPasskeyCreation) - } - PasskeysEvent.LearnMoreClicked -> { - _actions.send(PasskeysAction.OpenLearnMore) - } - is PasskeysEvent.RenamePasskeyClicked, is PasskeysEvent.RemovePasskeyClicked -> { - // Nothing to do yet -- rename and remove haven't been built. - } - } - } -} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpApp.kt b/feature/app-settings/src/main/java/org/signal/appsettings/totp/TotpApp.kt similarity index 82% rename from feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpApp.kt rename to feature/app-settings/src/main/java/org/signal/appsettings/totp/TotpApp.kt index c2e1a938a4..05259fde3d 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpApp.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/totp/TotpApp.kt @@ -3,10 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.appsettings.totpapplist +package org.signal.appsettings.totp /** - * A single authenticator app configured on the user's account, as shown on [TotpAppListScreen]. + * A single authenticator app configured on the user's account, as shown on the account settings screen. */ data class TotpApp( val id: Long, diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListAction.kt deleted file mode 100644 index 15e578a59d..0000000000 --- a/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListAction.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.totpapplist - -/** - * One-shot side effects that need the nav graph, and therefore have to be carried out by the fragment hosting - * [TotpAppListScreen] rather than the screen itself. - * - * Actions are logged, so be sure `toString()` contains nothing sensitive. - */ -sealed interface TotpAppListAction { - - /** Leave the screen. */ - data object NavigateBack : TotpAppListAction - - /** Open the flow that pairs a new authenticator app. */ - data object NavigateToSetup : TotpAppListAction - - /** Open the screen that renames [app]. */ - data class NavigateToRename(val app: TotpApp) : TotpAppListAction - - /** Tell the user their authenticator app was removed. */ - data object ShowTotpAppRemoved : TotpAppListAction - - /** Tell the user the removal didn't go through, so they know the app is still on the account. */ - data object ShowRemovalFailed : TotpAppListAction - - /** Send the user to a support article about authenticator apps. */ - data object OpenLearnMore : TotpAppListAction -} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListEvent.kt deleted file mode 100644 index dd8644a396..0000000000 --- a/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListEvent.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.totpapplist - -/** - * Reminder that these events are logged, so don't include anything sensitive in the toString. - */ -sealed interface TotpAppListEvent { - - /** The screen came back to the foreground, so the list we read out of storage may be stale. */ - data object ScreenResumed : TotpAppListEvent - - /** The user tapped the navigation (back) icon. */ - data object NavigateBackClicked : TotpAppListEvent - - /** The user tapped the button that starts setting up another authenticator app. */ - data object AddTotpAppClicked : TotpAppListEvent - - /** The user tapped the learn more link. */ - data object LearnMoreClicked : TotpAppListEvent - - /** The user tapped the rename option in an app's overflow menu. */ - data class RenameAppClicked(val appId: Long) : TotpAppListEvent - - /** The user tapped the remove option in an app's overflow menu, which asks them to confirm first. */ - data class RemoveAppClicked(val appId: Long) : TotpAppListEvent - - /** The user confirmed removing the app, which removes it. */ - data class RemoveAppConfirmed(val appId: Long) : TotpAppListEvent - - /** Dismisses whatever is in [TotpAppListState.dialog]. */ - data object DialogDismissed : TotpAppListEvent -} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListScreen.kt deleted file mode 100644 index c830109214..0000000000 --- a/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListScreen.kt +++ /dev/null @@ -1,398 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.totpapplist - -import android.text.format.DateUtils -import androidx.annotation.VisibleForTesting -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withLink -import androidx.compose.ui.unit.dp -import org.signal.appsettings.R -import org.signal.appsettings.totpapplist.TotpAppListState.Dialog -import org.signal.appsettings.totpapplist.TotpAppListState.LoadState -import org.signal.core.ui.compose.Buttons -import org.signal.core.ui.compose.DayNightPreviews -import org.signal.core.ui.compose.Dialogs -import org.signal.core.ui.compose.Dividers -import org.signal.core.ui.compose.DropdownMenus -import org.signal.core.ui.compose.Previews -import org.signal.core.ui.compose.Rows -import org.signal.core.ui.compose.Rows.TextAndLabel -import org.signal.core.ui.compose.Scaffolds -import org.signal.core.ui.compose.SignalIcons -import org.signal.core.ui.compose.Texts -import org.signal.core.ui.R as CoreUiR - -@VisibleForTesting -object TotpAppListTestTags { - const val SCROLLER = "scroller" - const val LEARN_MORE = "learn-more" - const val BUTTON_ADD = "button-add" - const val ROW_APP = "row-app" - const val BUTTON_APP_MENU = "button-app-menu" - const val MENU_ITEM_RENAME = "menu-item-rename" - const val MENU_ITEM_REMOVE = "menu-item-remove" - const val EMPTY_MESSAGE = "empty-message" - const val LOAD_FAILED_MESSAGE = "load-failed-message" - const val DIALOG_CONFIRM_REMOVE = "dialog-confirm-remove" - const val DIALOG_MAX_APPS_REACHED = "dialog-max-apps-reached" - const val LOADING = "loading" -} - -/** - * Lists the authenticator apps configured on the account and lets the user add, rename, or remove one. - */ -@Composable -fun TotpAppListScreen( - state: TotpAppListState, - onEvent: (TotpAppListEvent) -> Unit -) { - Scaffolds.Settings( - title = stringResource(R.string.TotpAppListScreen__authenticator_app), - onNavigationClick = { onEvent(TotpAppListEvent.NavigateBackClicked) }, - navigationIcon = SignalIcons.ArrowStart.imageVector - ) { contentPadding -> - LazyColumn( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .padding(contentPadding) - .testTag(TotpAppListTestTags.SCROLLER) - ) { - item { - Image( - painter = painterResource(R.drawable.image_authenticator_open_app), - contentDescription = null, - modifier = Modifier - .padding(top = 32.dp) - .size(width = 55.dp, height = 105.dp) - ) - } - - item { - DescriptionWithLearnMore( - onEvent = onEvent, - modifier = Modifier - .padding(top = 24.dp) - .padding(horizontal = 34.dp) - .testTag(TotpAppListTestTags.LEARN_MORE) - ) - } - - item { - Buttons.MediumTonal( - onClick = { onEvent(TotpAppListEvent.AddTotpAppClicked) }, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ), - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp, bottom = 20.dp) - .padding(horizontal = 40.dp) - .testTag(TotpAppListTestTags.BUTTON_ADD) - ) { - Text(text = stringResource(R.string.TotpAppListScreen__add_authenticator_app)) - } - } - - item { - Dividers.Default() - } - - item { - Texts.SectionHeader( - text = stringResource(R.string.TotpAppListScreen__authenticator_apps), - modifier = Modifier.fillMaxWidth() - ) - } - - when (state.loadState) { - LoadState.LOADING -> item { - CircularProgressIndicator( - modifier = Modifier - .padding(top = 40.dp) - .size(24.dp) - .testTag(TotpAppListTestTags.LOADING) - ) - } - - LoadState.NETWORK_FAILURE -> item { - SectionMessage( - text = stringResource(R.string.TotpAppListScreen__couldnt_load_authenticator_apps), - modifier = Modifier.testTag(TotpAppListTestTags.LOAD_FAILED_MESSAGE) - ) - } - - LoadState.LOADED -> if (state.apps.isEmpty()) { - item { - SectionMessage( - text = stringResource(R.string.TotpAppListScreen__no_authenticator_apps), - modifier = Modifier.testTag(TotpAppListTestTags.EMPTY_MESSAGE) - ) - } - } else { - items(state.apps, key = { it.id }) { app -> - TotpAppRow( - app = app, - onEvent = onEvent - ) - } - } - } - } - } - - when (val dialog = state.dialog) { - Dialog.None -> Unit - is Dialog.ConfirmRemove -> ConfirmRemoveDialog(appId = dialog.appId, onEvent = onEvent) - Dialog.MaxAppsReached -> MaxAppsReachedDialog(maxApps = state.maxApps, onEvent = onEvent) - } -} - -/** Whatever the list section has to say when it has no rows to show. */ -@Composable -private fun SectionMessage( - text: String, - modifier: Modifier = Modifier -) { - Text( - text = text, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = modifier - .padding(top = 40.dp) - .padding(horizontal = 34.dp) - ) -} - -@Composable -private fun TotpAppRow( - app: TotpApp, - onEvent: (TotpAppListEvent) -> Unit -) { - val context = LocalContext.current - val addedTime = remember(app.createdAt) { - DateUtils.getRelativeDateTimeString(context, app.createdAt, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString() - } - - Rows.TextRow( - icon = { - Icon( - painter = SignalIcons.DevicePhone.painter, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface - ) - }, - text = { - TextAndLabel( - text = app.name, - label = stringResource(R.string.TotpAppListScreen__added_s, addedTime) - ) - - TotpAppMenuButton( - app = app, - onEvent = onEvent - ) - }, - modifier = Modifier.testTag(TotpAppListTestTags.ROW_APP) - ) -} - -@Composable -private fun TotpAppMenuButton( - app: TotpApp, - onEvent: (TotpAppListEvent) -> Unit -) { - val menuController = remember { DropdownMenus.MenuController() } - - Box { - IconButton( - onClick = menuController::show, - modifier = Modifier.testTag(TotpAppListTestTags.BUTTON_APP_MENU) - ) { - Icon( - imageVector = SignalIcons.MoreVertical.imageVector, - contentDescription = stringResource(R.string.TotpAppListScreen__open_authenticator_app_options), - tint = MaterialTheme.colorScheme.onSurface - ) - } - - DropdownMenus.Menu(controller = menuController) { controller -> - DropdownMenus.Item( - leadingIconResId = CoreUiR.drawable.symbol_edit_24, - text = { Text(text = stringResource(R.string.TotpAppListScreen__rename)) }, - onClick = { - onEvent(TotpAppListEvent.RenameAppClicked(app.id)) - controller.hide() - }, - modifier = Modifier.testTag(TotpAppListTestTags.MENU_ITEM_RENAME) - ) - - DropdownMenus.Item( - leadingIconResId = CoreUiR.drawable.symbol_x_circle_24, - text = { Text(text = stringResource(R.string.TotpAppListScreen__remove)) }, - onClick = { - onEvent(TotpAppListEvent.RemoveAppClicked(app.id)) - controller.hide() - }, - modifier = Modifier.testTag(TotpAppListTestTags.MENU_ITEM_REMOVE) - ) - } - } -} - -@Composable -private fun DescriptionWithLearnMore( - onEvent: (TotpAppListEvent) -> Unit, - modifier: Modifier = Modifier -) { - Text( - text = buildAnnotatedString { - append(stringResource(R.string.TotpAppListScreen__set_up_an_authenticator_app)) - append(' ') - - withLink( - LinkAnnotation.Clickable( - tag = "learn-more", - styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)), - linkInteractionListener = { onEvent(TotpAppListEvent.LearnMoreClicked) } - ) - ) { - append(stringResource(R.string.TotpAppListScreen__learn_more)) - } - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = modifier - ) -} - -@Composable -private fun ConfirmRemoveDialog( - appId: Long, - onEvent: (TotpAppListEvent) -> Unit -) { - Dialogs.SimpleAlertDialog( - title = stringResource(R.string.TotpAppListScreen__remove_authenticator_app), - body = stringResource(R.string.TotpAppListScreen__you_wont_be_able_to_use_this_app), - confirm = stringResource(R.string.TotpAppListScreen__remove), - onConfirm = { onEvent(TotpAppListEvent.RemoveAppConfirmed(appId)) }, - onDismiss = { onEvent(TotpAppListEvent.DialogDismissed) }, - dismiss = stringResource(android.R.string.cancel), - onDismissRequest = { onEvent(TotpAppListEvent.DialogDismissed) }, - modifier = Modifier.testTag(TotpAppListTestTags.DIALOG_CONFIRM_REMOVE) - ) -} - -@Composable -private fun MaxAppsReachedDialog( - maxApps: Int, - onEvent: (TotpAppListEvent) -> Unit -) { - Dialogs.SimpleAlertDialog( - title = stringResource(R.string.TotpAppListScreen__cant_add_authenticator_app), - body = stringResource(R.string.TotpAppListScreen__you_cant_add_more_than_d, maxApps), - confirm = stringResource(android.R.string.ok), - onConfirm = {}, - onDismiss = { onEvent(TotpAppListEvent.DialogDismissed) }, - dismiss = stringResource(R.string.TotpAppListScreen__learn_more), - onDeny = { onEvent(TotpAppListEvent.LearnMoreClicked) }, - onDismissRequest = { onEvent(TotpAppListEvent.DialogDismissed) }, - modifier = Modifier.testTag(TotpAppListTestTags.DIALOG_MAX_APPS_REACHED) - ) -} - -@DayNightPreviews -@Composable -private fun TotpAppListScreenPreview() { - Previews.Preview { - TotpAppListScreen( - state = TotpAppListState(), - onEvent = {} - ) - } -} - -@DayNightPreviews -@Composable -private fun TotpAppListScreenEmptyPreview() { - Previews.Preview { - TotpAppListScreen( - state = TotpAppListState(loadState = LoadState.LOADED), - onEvent = {} - ) - } -} - -@DayNightPreviews -@Composable -private fun TotpAppListScreenLoadFailedPreview() { - Previews.Preview { - TotpAppListScreen( - state = TotpAppListState(loadState = LoadState.NETWORK_FAILURE), - onEvent = {} - ) - } -} - -@DayNightPreviews -@Composable -private fun TotpAppListPreview() { - Previews.Preview { - TotpAppListScreen( - state = TotpAppListState(apps = PREVIEW_APPS, loadState = LoadState.LOADED), - onEvent = {} - ) - } -} - -@DayNightPreviews -@Composable -private fun ConfirmRemoveDialogPreview() { - Previews.Preview { - ConfirmRemoveDialog(appId = 1, onEvent = {}) - } -} - -@DayNightPreviews -@Composable -private fun MaxAppsReachedDialogPreview() { - Previews.Preview { - MaxAppsReachedDialog(maxApps = 2, onEvent = {}) - } -} - -private val PREVIEW_APPS = listOf( - TotpApp(id = 1, name = "Bitwarden Authenticator", createdAt = System.currentTimeMillis()), - TotpApp(id = 2, name = "Twilio Authy", createdAt = System.currentTimeMillis()) -) diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListState.kt deleted file mode 100644 index dbc0de346e..0000000000 --- a/feature/app-settings/src/main/java/org/signal/appsettings/totpapplist/TotpAppListState.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.totpapplist - -data class TotpAppListState( - /** The authenticator apps configured on the account, which only means anything once [loadState] is [LoadState.LOADED]. */ - val apps: List = emptyList(), - /** How many authenticator apps the account is allowed to have at once. */ - val maxApps: Int = 0, - /** How the last look at the account went, which decides what the list section shows in place of rows. */ - val loadState: LoadState = LoadState.LOADING, - val dialog: Dialog = Dialog.None -) { - - val atMaxApps: Boolean - get() = apps.size >= maxApps - - /** How the last attempt to read the account's authenticator apps went, since an empty list can't say on its own. */ - enum class LoadState { - /** We haven't heard back about the account yet. */ - LOADING, - - LOADED, - - /** We couldn't reach the service, which is worth another try. */ - NETWORK_FAILURE - } - - /** Whichever dialog the screen is showing, if any. Only one is ever up at a time. */ - sealed interface Dialog { - data object None : Dialog - - /** Confirms removing [appId], which still has to be backed up by a code from the app itself. */ - data class ConfirmRemove(val appId: Long) : Dialog - - /** Explains that the account already has as many authenticator apps as it's allowed. */ - data object MaxAppsReached : Dialog - } -} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/totpnameentry/TotpNameEntryAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/totpnameentry/TotpNameEntryAction.kt index 4353f554e4..d155792c3a 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/totpnameentry/TotpNameEntryAction.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/totpnameentry/TotpNameEntryAction.kt @@ -16,8 +16,8 @@ sealed interface TotpNameEntryAction { /** Leave the screen. */ data object NavigateBack : TotpNameEntryAction - /** The app has a name now, so go back to the list of authenticator apps. */ - data object NavigateToTotpAppList : TotpNameEntryAction + /** The app has a name now, so go back to the account settings screen that lists it. */ + data object NavigateToAccountSettings : TotpNameEntryAction /** Tell the user their authenticator app was set up. */ data object ShowTotpAppSetUp : TotpNameEntryAction diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/totpsetup/TotpSetupScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/totpsetup/TotpSetupScreen.kt index 98ade7788f..1307a63579 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/totpsetup/TotpSetupScreen.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/totpsetup/TotpSetupScreen.kt @@ -216,7 +216,7 @@ private fun SetupDialog( ) { val message = when (dialog) { TotpSetupState.Dialog.None -> return - is TotpSetupState.Dialog.MaxAppsReached -> stringResource(R.string.TotpAppListScreen__you_cant_add_more_than_d, dialog.maxApps) + is TotpSetupState.Dialog.MaxAppsReached -> stringResource(R.string.TotpSetupScreen__you_cant_add_more_than_d, dialog.maxApps) TotpSetupState.Dialog.NetworkFailure -> stringResource(R.string.TotpSetupScreen__couldnt_reach_signal) } diff --git a/feature/app-settings/src/main/res/drawable/image_passkeys_phone.xml b/feature/app-settings/src/main/res/drawable/image_passkeys_phone.xml deleted file mode 100644 index 4e57ed6e03..0000000000 --- a/feature/app-settings/src/main/res/drawable/image_passkeys_phone.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/feature/app-settings/src/main/res/values/strings.xml b/feature/app-settings/src/main/res/values/strings.xml index 85c4e0af73..83b0184d19 100644 --- a/feature/app-settings/src/main/res/values/strings.xml +++ b/feature/app-settings/src/main/res/values/strings.xml @@ -52,25 +52,37 @@ Your Signal Login is used to recover and restore your account. Keep these keys stored safely in a password manager you trust. Two-factor authentication - + + Set up + Authenticator app - + One-time verification codes - - - %1$d configured - %1$d configured - - - Passkeys - - Device biometrics or FIDO2 security key - - - %1$d passkey - %1$d passkeys - - + + Passkey + + %1$s ยท Added %2$s + + Open two-factor authentication options + + Rename + + Remove + + Remove authenticator app? + + You won\'t be able to use this app for one-time verification codes when you sign in. + + Can\'t add authenticator app + + You can\'t add more than %1$d authenticator apps. Try removing one first. + + Couldn\'t load your two-factor authentication methods. Check your connection and try again. + + Authenticator app removed + + Couldn\'t remove authenticator app. Check your connection and try again. + Use a second form of authentication to protect your account when using your Signal Login on a new device. Learn more @@ -100,6 +112,8 @@ Copied to clipboard No authenticator app found + + You can\'t add more than %1$d authenticator apps. Try removing one first. Couldn\'t reach Signal. Check your connection and try again. @@ -109,34 +123,6 @@ Continue - - - Passkeys - - With passkeys you can easily add a secure second form of authentication to your account. Passkeys can use your device\'s biometrics or a compatible FIDO2 security key. - - Learn more - - Give your passkey a friendly name to identify it - - Use your device\'s biometrics or a FIDO2 security key to store your passkey - - Add or remove passkeys at anytime. - - Set up a passkey - - Set up a passkey with your device\'s biometrics or a compatible FIDO2 security key. - - Add a new passkey - - Added %1$s - - Open passkey options - - Rename - - Remove - Enter your code @@ -150,42 +136,6 @@ Incorrect code. Enter the code showing in your authenticator app now. Couldn\'t reach Signal. Check your connection and try again. - - - Authenticator app - - Set up an authenticator app to generate one-time verification codes - - Learn more - - Add authenticator app - - Authenticator apps - - No authenticator apps - - Added %1$s - - Open authenticator app options - - Rename - - Remove - - Remove authenticator app? - - You won\'t be able to use this app for one-time verification codes when you sign in. - - Can\'t add authenticator app - - You can\'t add more than %1$d authenticator apps. Try removing one first. - - Couldn\'t load your authenticator apps. Check your connection and try again. - - Authenticator app removed - - Couldn\'t remove authenticator app. Check your connection and try again. - Choose a name diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/account/AccountSettingsScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/account/AccountSettingsScreenTest.kt index 043d0a088a..71517f11f7 100644 --- a/feature/app-settings/src/test/java/org/signal/appsettings/account/AccountSettingsScreenTest.kt +++ b/feature/app-settings/src/test/java/org/signal/appsettings/account/AccountSettingsScreenTest.kt @@ -6,12 +6,15 @@ package org.signal.appsettings.account import android.app.Application +import android.text.format.DateUtils import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -26,12 +29,30 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import org.signal.appsettings.R import org.signal.appsettings.account.AccountSettingsState.Dialog +import org.signal.appsettings.account.AccountSettingsState.LoadState import org.signal.core.ui.compose.Dialogs @RunWith(RobolectricTestRunner::class) @Config(application = Application::class) class AccountSettingsScreenTest { + companion object { + /** Fixed so the "Added ..." subtitle a row renders is something the test can predict. */ + private const val CREATED_AT = 1_700_000_000_000L + private val ADDED_TIME: String = DateUtils.getRelativeDateTimeString( + RuntimeEnvironment.getApplication(), + CREATED_AT, + DateUtils.DAY_IN_MILLIS, + DateUtils.WEEK_IN_MILLIS, + 0 + ).toString() + + private val METHODS = listOf( + TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.AUTHENTICATOR_APP, name = "Bitwarden Authenticator", createdAt = CREATED_AT), + TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.PASSKEY, name = "Pixel Phone", createdAt = CREATED_AT) + ) + } + private val context: Application = RuntimeEnvironment.getApplication() @get:Rule @@ -299,12 +320,12 @@ class AccountSettingsScreenTest { setContent(createState()) composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertDoesNotExist() - composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TOTP_APP).assertDoesNotExist() + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR).assertDoesNotExist() } @Test fun givenASignalLogin_whenIClickTheSignalLoginCard_thenIExpectAccountAndRecoveryEvent() { - setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0))) + setContent(createState(signalLogin = signalLogin())) composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).performClick() @@ -312,40 +333,106 @@ class AccountSettingsScreenTest { } @Test - fun givenASignalLogin_whenIClickTotpApp_thenIExpectTotpAppEvent() { - setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0))) + fun givenASignalLogin_whenIPickAuthenticatorAppFromTheSetUpMenu_thenIExpectAddTotpAppEvent() { + setContent(createState(signalLogin = signalLogin())) - composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertIsDisplayed() + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR).performClick() + composeTestRule.onNodeWithTag(AccountSettingsTestTags.MENU_ITEM_AUTHENTICATOR_APP).performClick() - composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TOTP_APP).performClick() - - assertThat(events).contains(AccountSettingsEvent.TotpAppClicked) + assertThat(events).contains(AccountSettingsEvent.AddTotpAppClicked) } - /** A count we couldn't fetch has to read the same as no count at all, rather than as "0 configured". */ + /** Passkeys aren't supported yet, so the menu can't offer to set one up. */ @Test - fun givenAnUnknownTotpAppCount_whenScreenDisplayed_thenTheRowDoesNotClaimACount() { - setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = null, passkeyCount = 0))) + fun givenTheSetUpMenu_whenItIsOpen_thenPasskeyIsNotOffered() { + setContent(createState(signalLogin = signalLogin())) - composeTestRule.onNodeWithText(context.getString(R.string.AccountSettingsFragment__one_time_verification_codes)).assertIsDisplayed() + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR).performClick() + + composeTestRule.onNodeWithText(context.getString(R.string.AccountSettingsFragment__passkey)).assertDoesNotExist() } @Test - fun givenConfiguredTotpApps_whenScreenDisplayed_thenTheRowShowsTheCount() { - setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 2, passkeyCount = 0))) + fun givenTwoFactorMethods_whenScreenDisplayed_thenIExpectARowPerMethod() { + setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS))) - composeTestRule.onNodeWithText(context.resources.getQuantityString(R.plurals.AccountSettingsFragment__d_configured, 2, 2)).assertIsDisplayed() + for (method in METHODS) { + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER).performScrollToNode(hasText(method.name)) + composeTestRule.onNodeWithText(method.name).assertIsDisplayed() + } + } + + /** Authenticator apps and passkeys share one list, so a row's subtitle is what says which kind it is. */ + @Test + fun givenTwoFactorMethods_whenScreenDisplayed_thenEachRowSaysWhatKindItIs() { + setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS))) + + for (kind in listOf(R.string.AccountSettingsFragment__authenticator_app, R.string.AccountSettingsFragment__passkey)) { + val label = context.getString(R.string.AccountSettingsFragment__s_added_s, context.getString(kind), ADDED_TIME) + composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER).performScrollToNode(hasText(label)) + composeTestRule.onNodeWithText(label).assertIsDisplayed() + } } @Test - fun givenASignalLogin_whenIClickPasskeys_thenIExpectPasskeysEvent() { - setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0))) + fun givenTwoFactorMethods_whenIClickRenameInTheMenu_thenIExpectRenameMethodEvent() { + setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS))) - scrollTo(AccountSettingsTestTags.ROW_PASSKEYS) + scrollTo(AccountSettingsTestTags.ROW_TWO_FACTOR_METHOD) + composeTestRule.onAllNodesWithTag(AccountSettingsTestTags.BUTTON_METHOD_MENU)[0].performClick() + composeTestRule.onNodeWithTag(AccountSettingsTestTags.MENU_ITEM_RENAME).performClick() - composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_PASSKEYS).performClick() + assertThat(events).contains(AccountSettingsEvent.RenameMethodClicked(METHODS[0])) + } - assertThat(events).contains(AccountSettingsEvent.PasskeysClicked) + @Test + fun givenTwoFactorMethods_whenIClickRemoveInTheMenu_thenIExpectRemoveMethodEvent() { + setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS))) + + scrollTo(AccountSettingsTestTags.ROW_TWO_FACTOR_METHOD) + composeTestRule.onAllNodesWithTag(AccountSettingsTestTags.BUTTON_METHOD_MENU)[0].performClick() + composeTestRule.onNodeWithTag(AccountSettingsTestTags.MENU_ITEM_REMOVE).performClick() + + assertThat(events).contains(AccountSettingsEvent.RemoveMethodClicked(METHODS[0])) + } + + @Test + fun givenTheConfirmRemoveDialog_whenIConfirm_thenIExpectRemoveTotpAppConfirmedForThatApp() { + setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS), dialog = Dialog.ConfirmRemoveTotpApp(METHODS[0].id))) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.DIALOG_CONFIRM_REMOVE_TOTP_APP).assertIsDisplayed() + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick() + + assertThat(events).contains(AccountSettingsEvent.RemoveTotpAppConfirmed) + } + + @Test + fun givenTheMaxAppsDialog_whenIClickLearnMore_thenIExpectLearnMoreAndDismissEvents() { + setContent(createState(signalLogin = signalLogin(), dialog = Dialog.MaxTotpAppsReached)) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.DIALOG_MAX_TOTP_APPS_REACHED).assertIsDisplayed() + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick() + + assertThat(events).contains(AccountSettingsEvent.LearnMoreClicked) + assertThat(events).contains(AccountSettingsEvent.DialogDismissed) + } + + @Test + fun whenTheTwoFactorListHasntArrived_thenIExpectASpinnerRatherThanAnEmptyList() { + setContent(createState(signalLogin = signalLogin(loadState = LoadState.LOADING))) + + scrollTo(AccountSettingsTestTags.TWO_FACTOR_LOADING) + composeTestRule.onNodeWithTag(AccountSettingsTestTags.TWO_FACTOR_LOADING).assertIsDisplayed() + } + + /** An account we couldn't ask about is not an account with no second factors. */ + @Test + fun givenTheTwoFactorListCouldntBeLoaded_whenScreenDisplayed_thenIExpectTheFailureMessage() { + setContent(createState(signalLogin = signalLogin(loadState = LoadState.NETWORK_FAILURE))) + + scrollTo(AccountSettingsTestTags.TWO_FACTOR_LOAD_FAILED_MESSAGE) + composeTestRule.onNodeWithTag(AccountSettingsTestTags.TWO_FACTOR_LOAD_FAILED_MESSAGE).assertIsDisplayed() + composeTestRule.onNodeWithTag(AccountSettingsTestTags.TWO_FACTOR_LOADING).assertDoesNotExist() } @Test @@ -374,6 +461,14 @@ class AccountSettingsScreenTest { } } + private fun signalLogin( + twoFactorMethods: List = emptyList(), + loadState: LoadState = LoadState.LOADED, + maxTotpApps: Int = 2 + ): AccountSettingsState.SignalLogin { + return AccountSettingsState.SignalLogin(twoFactorMethods = twoFactorMethods, loadState = loadState, maxTotpApps = maxTotpApps) + } + private fun scrollTo(testTag: String) { composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER) .performScrollToNode(hasTestTag(testTag)) diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysScreenTest.kt deleted file mode 100644 index 500ae264b2..0000000000 --- a/feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysScreenTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.passkeys - -import android.app.Application -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollToNode -import assertk.assertThat -import assertk.assertions.contains -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(application = Application::class) -class PasskeysScreenTest { - - companion object { - private val PASSKEYS = listOf( - Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()), - Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis()) - ) - } - - @get:Rule - val composeTestRule = createComposeRule() - - private val events = mutableListOf() - - @Test - fun givenNoPasskeys_whenIClickSetUpAPasskey_thenIExpectSetUpPasskeyEvent() { - setContent(PasskeysState()) - - composeTestRule.onNodeWithTag(PasskeysTestTags.BUTTON_SET_UP) - .assertIsDisplayed() - .performClick() - - assertThat(events).contains(PasskeysEvent.SetUpPasskeyClicked) - } - - @Test - fun givenPasskeys_whenIDisplayScreen_thenIExpectARowPerPasskey() { - setContent(PasskeysState(passkeys = PASSKEYS)) - - for (passkey in PASSKEYS) { - composeTestRule.onNodeWithTag(PasskeysTestTags.SCROLLER).performScrollToNode(hasText(passkey.name)) - composeTestRule.onNodeWithText(passkey.name).assertIsDisplayed() - } - } - - @Test - fun givenPasskeys_whenIClickAddANewPasskey_thenIExpectSetUpPasskeyEvent() { - setContent(PasskeysState(passkeys = PASSKEYS)) - - composeTestRule.onNodeWithTag(PasskeysTestTags.BUTTON_SET_UP) - .assertIsDisplayed() - .performClick() - - assertThat(events).contains(PasskeysEvent.SetUpPasskeyClicked) - } - - @Test - fun givenPasskeys_whenIClickRenameInAPasskeysMenu_thenIExpectRenameEvent() { - setContent(PasskeysState(passkeys = PASSKEYS)) - - scrollTo(PasskeysTestTags.BUTTON_PASSKEY_MENU) - - composeTestRule.onAllNodesWithTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)[0].performClick() - composeTestRule.onNodeWithTag(PasskeysTestTags.MENU_ITEM_RENAME).performClick() - - assertThat(events).contains(PasskeysEvent.RenamePasskeyClicked(passkeyId = PASSKEYS[0].id)) - } - - @Test - fun givenPasskeys_whenIClickRemoveInAPasskeysMenu_thenIExpectRemoveEvent() { - setContent(PasskeysState(passkeys = PASSKEYS)) - - scrollTo(PasskeysTestTags.BUTTON_PASSKEY_MENU) - - composeTestRule.onAllNodesWithTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)[0].performClick() - composeTestRule.onNodeWithTag(PasskeysTestTags.MENU_ITEM_REMOVE).performClick() - - assertThat(events).contains(PasskeysEvent.RemovePasskeyClicked(passkeyId = PASSKEYS[0].id)) - } - - private fun setContent(state: PasskeysState) { - composeTestRule.setContent { - PasskeysScreen( - state = state, - onEvent = { events += it } - ) - } - } - - private fun scrollTo(testTag: String) { - composeTestRule.onNodeWithTag(PasskeysTestTags.SCROLLER) - .performScrollToNode(hasTestTag(testTag)) - } -} diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysViewModelTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysViewModelTest.kt deleted file mode 100644 index 1b1264f3f2..0000000000 --- a/feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysViewModelTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.passkeys - -import assertk.assertThat -import assertk.assertions.isEmpty -import assertk.assertions.isEqualTo -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class PasskeysViewModelTest { - - companion object { - private val PASSKEYS = listOf( - Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()), - Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis()) - ) - } - - private val testDispatcher = UnconfinedTestDispatcher() - - private val repository = object : PasskeysRepository { - override fun getPasskeys(): List = PASSKEYS - } - - @Before - fun setUp() { - Dispatchers.setMain(testDispatcher) - } - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `the passkeys are available as soon as the screen opens`() = runTest(testDispatcher) { - val viewModel = PasskeysViewModel(repository) - - assertThat(viewModel.state.value.passkeys).isEqualTo(PASSKEYS) - } - - @Test - fun `SetUpPasskeyClicked launches passkey creation`() = runTest(testDispatcher) { - val viewModel = PasskeysViewModel(repository) - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(PasskeysEvent.SetUpPasskeyClicked) - - assertThat(actions.last()).isEqualTo(PasskeysAction.LaunchPasskeyCreation) - } - - @Test - fun `LearnMoreClicked opens the learn more article`() = runTest(testDispatcher) { - val viewModel = PasskeysViewModel(repository) - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(PasskeysEvent.LearnMoreClicked) - - assertThat(actions.last()).isEqualTo(PasskeysAction.OpenLearnMore) - } - - @Test - fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) { - val viewModel = PasskeysViewModel(repository) - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(PasskeysEvent.NavigateBackClicked) - - assertThat(actions.last()).isEqualTo(PasskeysAction.NavigateBack) - } - - @Test - fun `rename and remove produce no actions yet`() = runTest(testDispatcher) { - val viewModel = PasskeysViewModel(repository) - val actions = collectActions(viewModel.actions) - - viewModel.onEvent(PasskeysEvent.RenamePasskeyClicked(passkeyId = 1)) - viewModel.onEvent(PasskeysEvent.RemovePasskeyClicked(passkeyId = 1)) - - assertThat(actions).isEmpty() - } - - private fun TestScope.collectActions(actions: Flow): List { - val collected = mutableListOf() - backgroundScope.launch { actions.toList(collected) } - return collected - } -} diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/totpapplist/TotpAppListScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/totpapplist/TotpAppListScreenTest.kt deleted file mode 100644 index e760b08310..0000000000 --- a/feature/app-settings/src/test/java/org/signal/appsettings/totpapplist/TotpAppListScreenTest.kt +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.appsettings.totpapplist - -import android.app.Application -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollToNode -import assertk.assertThat -import assertk.assertions.contains -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.signal.appsettings.totpapplist.TotpAppListState.Dialog -import org.signal.appsettings.totpapplist.TotpAppListState.LoadState -import org.signal.core.ui.compose.Dialogs - -@RunWith(RobolectricTestRunner::class) -@Config(application = Application::class) -class TotpAppListScreenTest { - - companion object { - private val APPS = listOf( - TotpApp(id = 1, name = "Bitwarden Authenticator", createdAt = System.currentTimeMillis()), - TotpApp(id = 2, name = "Twilio Authy", createdAt = System.currentTimeMillis()) - ) - } - - @get:Rule - val composeTestRule = createComposeRule() - - private val events = mutableListOf() - - @Test - fun givenNoApps_whenIDisplayScreen_thenIExpectTheEmptyMessage() { - setContent(TotpAppListState(loadState = LoadState.LOADED)) - - scrollTo(TotpAppListTestTags.EMPTY_MESSAGE) - composeTestRule.onNodeWithTag(TotpAppListTestTags.EMPTY_MESSAGE).assertIsDisplayed() - } - - @Test - fun givenApps_whenIDisplayScreen_thenIExpectARowPerApp() { - setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED)) - - for (app in APPS) { - composeTestRule.onNodeWithTag(TotpAppListTestTags.SCROLLER).performScrollToNode(hasText(app.name)) - composeTestRule.onNodeWithText(app.name).assertIsDisplayed() - } - } - - @Test - fun whenIClickAddTotpApp_thenIExpectAddTotpAppClickedEvent() { - setContent(TotpAppListState(loadState = LoadState.LOADED)) - - composeTestRule.onNodeWithTag(TotpAppListTestTags.BUTTON_ADD) - .assertIsDisplayed() - .performClick() - - assertThat(events).contains(TotpAppListEvent.AddTotpAppClicked) - } - - @Test - fun givenApps_whenIClickRenameInTheMenu_thenIExpectRenameAppClickedEvent() { - setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED)) - - scrollTo(TotpAppListTestTags.ROW_APP) - composeTestRule.onAllNodesWithTag(TotpAppListTestTags.BUTTON_APP_MENU)[0].performClick() - composeTestRule.onNodeWithTag(TotpAppListTestTags.MENU_ITEM_RENAME).performClick() - - assertThat(events).contains(TotpAppListEvent.RenameAppClicked(appId = APPS[0].id)) - } - - @Test - fun givenApps_whenIClickRemoveInTheMenu_thenIExpectRemoveAppClickedEvent() { - setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED)) - - scrollTo(TotpAppListTestTags.ROW_APP) - composeTestRule.onAllNodesWithTag(TotpAppListTestTags.BUTTON_APP_MENU)[0].performClick() - composeTestRule.onNodeWithTag(TotpAppListTestTags.MENU_ITEM_REMOVE).performClick() - - assertThat(events).contains(TotpAppListEvent.RemoveAppClicked(appId = APPS[0].id)) - } - - @Test - fun givenTheConfirmRemoveDialog_whenIDisplayScreen_thenIExpectTheDialog() { - setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.ConfirmRemove(APPS[0].id))) - - composeTestRule.onNodeWithTag(TotpAppListTestTags.DIALOG_CONFIRM_REMOVE).assertIsDisplayed() - } - - /** The event has to carry the id, since the dialog holding it is dismissed before the confirmation is reported. */ - @Test - fun givenTheConfirmRemoveDialog_whenIConfirm_thenIExpectRemoveAppConfirmedForThatApp() { - setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.ConfirmRemove(APPS[1].id))) - - composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick() - - assertThat(events).contains(TotpAppListEvent.RemoveAppConfirmed(APPS[1].id)) - } - - @Test - fun givenTheConfirmRemoveDialog_whenICancel_thenIExpectDialogDismissedEvent() { - setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.ConfirmRemove(APPS[0].id))) - - composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick() - - assertThat(events).contains(TotpAppListEvent.DialogDismissed) - } - - @Test - fun givenTheMaxAppsDialog_whenIDisplayScreen_thenIExpectTheDialog() { - setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.MaxAppsReached)) - - composeTestRule.onNodeWithTag(TotpAppListTestTags.DIALOG_MAX_APPS_REACHED).assertIsDisplayed() - } - - @Test - fun givenTheMaxAppsDialog_whenIClickLearnMore_thenIExpectLearnMoreAndDismissEvents() { - setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.MaxAppsReached)) - - composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick() - - assertThat(events).contains(TotpAppListEvent.LearnMoreClicked) - assertThat(events).contains(TotpAppListEvent.DialogDismissed) - } - - @Test - fun whenTheListHasntArrived_thenIExpectASpinnerRatherThanNoTotpApps() { - setContent(TotpAppListState(loadState = LoadState.LOADING)) - - scrollTo(TotpAppListTestTags.LOADING) - composeTestRule.onNodeWithTag(TotpAppListTestTags.LOADING).assertIsDisplayed() - composeTestRule.onNodeWithTag(TotpAppListTestTags.EMPTY_MESSAGE).assertDoesNotExist() - } - - /** An account we couldn't ask about is not an account with no authenticator apps. */ - @Test - fun givenTheListCouldntBeLoaded_whenIDisplayScreen_thenIExpectTheFailureMessageRatherThanTheEmptyOne() { - setContent(TotpAppListState(loadState = LoadState.NETWORK_FAILURE)) - - scrollTo(TotpAppListTestTags.LOAD_FAILED_MESSAGE) - composeTestRule.onNodeWithTag(TotpAppListTestTags.LOAD_FAILED_MESSAGE).assertIsDisplayed() - composeTestRule.onNodeWithTag(TotpAppListTestTags.EMPTY_MESSAGE).assertDoesNotExist() - composeTestRule.onNodeWithTag(TotpAppListTestTags.LOADING).assertDoesNotExist() - } - - private fun setContent(state: TotpAppListState) { - composeTestRule.setContent { - TotpAppListScreen( - state = state, - onEvent = { events += it } - ) - } - } - - private fun scrollTo(testTag: String) { - composeTestRule.onNodeWithTag(TotpAppListTestTags.SCROLLER) - .performScrollToNode(hasTestTag(testTag)) - } -}