From c1453fab772a7e7a93a5c20c1c3cd0f242f6b71e Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 25 Aug 2026 23:02:17 -0400 Subject: [PATCH] Add additional authenticator UI scaffolding. --- .../app/account/AccountSettingsFragment.kt | 2 +- .../app/account/AccountSettingsRepository.kt | 6 +- .../app/account/AccountSettingsViewModel.kt | 5 +- .../authenticator/AuthenticatorAppStore.kt | 31 +- .../AuthenticatorAppsFragment.kt | 75 ++++ .../AuthenticatorAppsViewModel.kt | 81 +++++ .../AuthenticatorCodeEntryFragment.kt | 20 +- .../AuthenticatorCodeEntryViewModel.kt | 22 +- .../AuthenticatorNameFragment.kt | 56 +++ .../AuthenticatorNameViewModel.kt | 73 ++++ .../authenticator/AuthenticatorNavArgs.kt | 47 +++ .../authenticator/AuthenticatorRepository.kt | 16 +- .../app_settings_with_change_number.xml | 62 +++- .../account/AccountSettingsViewModelTest.kt | 13 +- .../AuthenticatorAppsViewModelTest.kt | 195 ++++++++++ .../AuthenticatorCodeEntryViewModelTest.kt | 41 ++- .../AuthenticatorNameViewModelTest.kt | 134 +++++++ .../authenticator/AuthenticatorNavArgsTest.kt | 60 ++++ .../account/AccountSettingsAction.kt | 4 +- .../account/AccountSettingsScreen.kt | 14 +- .../account/AccountSettingsState.kt | 3 +- .../authenticatorapps/AuthenticatorApp.kt | 18 + .../AuthenticatorAppsAction.kt | 30 ++ .../AuthenticatorAppsEvent.kt | 36 ++ .../AuthenticatorAppsScreen.kt | 340 ++++++++++++++++++ .../AuthenticatorAppsState.kt | 29 ++ .../AuthenticatorCodeEntryAction.kt | 11 +- .../AuthenticatorCodeEntryState.kt | 14 +- .../AuthenticatorNameAction.kt | 27 ++ .../AuthenticatorNameEvent.kt | 23 ++ .../AuthenticatorNameScreen.kt | 140 ++++++++ .../AuthenticatorNameState.kt | 19 + .../AuthenticatorSetupEvent.kt | 2 +- .../AuthenticatorSetupScreen.kt | 4 +- .../src/main/res/values/strings.xml | 67 +++- .../account/AccountSettingsScreenTest.kt | 4 +- .../AuthenticatorAppsScreenTest.kt | 141 ++++++++ .../AuthenticatorNameScreenTest.kt | 77 ++++ 38 files changed, 1873 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppsFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppsViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNameFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNameViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNavArgs.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppsViewModelTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNameViewModelTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNavArgsTest.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorApp.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsAction.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsEvent.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsScreen.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsState.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameAction.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameEvent.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameScreen.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameState.kt create mode 100644 feature/app-settings/src/test/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsScreenTest.kt create mode 100644 feature/app-settings/src/test/java/org/signal/appsettings/authenticatorname/AuthenticatorNameScreenTest.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 243d6cbd42..207cf3d5ab 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 @@ -74,7 +74,7 @@ class AccountSettingsFragment : ComposeFragment() { AccountSettingsAction.LaunchCreatePinFlow -> pinFlowLauncher.launch(CreateSvrPinActivity.getIntentForPinCreate(requireContext())) AccountSettingsAction.LaunchChangePinFlow -> pinFlowLauncher.launch(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext())) AccountSettingsAction.ShowPinCreatedConfirmation -> Snackbar.make(requireView(), R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show() - AccountSettingsAction.NavigateToAuthenticatorAppSetup -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_authenticatorSetupFragment) + AccountSettingsAction.NavigateToAuthenticatorApps -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_authenticatorAppsFragment) AccountSettingsAction.NavigateToPasskeys -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_passkeysFragment) AccountSettingsAction.NavigateToAdvancedPinSettings -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_advancedPinSettingsActivity) AccountSettingsAction.NavigateToChangePhoneNumber -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment) 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 4d2714ad97..43727688cd 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 @@ -9,6 +9,7 @@ import kotlinx.coroutines.withContext import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.components.settings.app.account.authenticator.AuthenticatorRepository +import org.thoughtcrime.securesms.components.settings.app.account.passkeys.AppPasskeysRepository import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.lock.v2.PinKeyboardType @@ -28,6 +29,7 @@ class AccountSettingsRepository { } private val authenticatorRepository = AuthenticatorRepository() + private val passkeysRepository = AppPasskeysRepository() fun hasPin(): Boolean = SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut() @@ -47,7 +49,9 @@ class AccountSettingsRepository { fun isPhoneNumberlessRegistrationEnabled(): Boolean = Environment.PHONENUMBERLESS_REGISTRATION - fun hasAuthenticatorApp(): Boolean = authenticatorRepository.hasAuthenticatorApp() + fun getAuthenticatorAppCount(): Int = authenticatorRepository.getAuthenticatorApps().size + + fun getPasskeyCount(): Int = passkeysRepository.getPasskeys().size fun verifyLocalPin(pin: String): Boolean { val localPinHash = SignalStore.svr.localPinHash 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 378ab90e92..c127f79b0c 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 @@ -105,7 +105,7 @@ class AccountSettingsViewModel( } } AccountSettingsEvent.AuthenticatorAppClicked -> { - _actions.send(AccountSettingsAction.NavigateToAuthenticatorAppSetup) + _actions.send(AccountSettingsAction.NavigateToAuthenticatorApps) } AccountSettingsEvent.PasskeysClicked -> { _actions.send(AccountSettingsAction.NavigateToPasskeys) @@ -159,7 +159,8 @@ class AccountSettingsViewModel( signalLogin = if (repository.isPhoneNumberlessRegistrationEnabled()) { AccountSettingsState.SignalLogin( keyCount = MOCK_SIGNAL_LOGIN_KEY_COUNT, - hasAuthenticatorApp = repository.hasAuthenticatorApp() + authenticatorAppCount = repository.getAuthenticatorAppCount(), + passkeyCount = repository.getPasskeyCount() ) } else { null diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppStore.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppStore.kt index c34fb5dbbb..20b2f3a05e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppStore.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppStore.kt @@ -5,6 +5,8 @@ package org.thoughtcrime.securesms.components.settings.app.account.authenticator +import org.signal.appsettings.authenticatorapps.AuthenticatorApp + /** * Stand-in for wherever authenticator app state will eventually live. Nothing is persisted or sent to the service yet, * so all of this is mocked up and lasts only as long as the process does. @@ -14,6 +16,31 @@ object AuthenticatorAppStore { /** The key we'd hand off to an authenticator app, which the service will supply for real later on. */ const val MOCK_SETUP_KEY = "KVZ7WL3FDDWJZMTOB7PLZPKVRFD4LYSX" - @Volatile - var hasAuthenticatorApp: Boolean = false + /** How many authenticator apps an account is allowed, which the service will decide for real later on. */ + const val MAX_APPS = 2 + + private val lock = Any() + private val apps = mutableListOf() + private var nextId = 1L + + fun getApps(): List = synchronized(lock) { apps.toList() } + + fun addApp(name: String, createdAt: Long): Long = synchronized(lock) { + val id = nextId++ + apps += AuthenticatorApp(id = id, name = name, createdAt = createdAt) + id + } + + fun renameApp(id: Long, name: String) = synchronized(lock) { + val index = apps.indexOfFirst { it.id == id } + if (index >= 0) { + apps[index] = apps[index].copy(name = name) + } + } + + fun removeApp(id: Long) = synchronized(lock) { + apps.removeAll { it.id == id } + } + + fun getApp(id: Long): AuthenticatorApp? = synchronized(lock) { apps.firstOrNull { it.id == id } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppsFragment.kt new file mode 100644 index 0000000000..d7b7da4dc5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppsFragment.kt @@ -0,0 +1,75 @@ +/* + * 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 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.authenticatorapps.AuthenticatorAppsAction +import org.signal.appsettings.authenticatorapps.AuthenticatorAppsEvent +import org.signal.appsettings.authenticatorapps.AuthenticatorAppsScreen +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 + +/** + * Lists the authenticator apps on the account. Carries out the [AuthenticatorAppsAction]s that need the nav graph. + */ +class AuthenticatorAppsFragment : ComposeFragment() { + + companion object { + private val TAG = Log.tag(AuthenticatorAppsFragment::class) + } + + private val viewModel: AuthenticatorAppsViewModel by viewModels() + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + + CollectActions(viewModel.actions) { action -> handleAction(action) } + + AuthenticatorAppsScreen( + state = state, + onEvent = viewModel::onEvent + ) + } + + override fun onResume() { + super.onResume() + viewModel.onEvent(AuthenticatorAppsEvent.ScreenResumed) + } + + private fun handleAction(action: AuthenticatorAppsAction) { + when (action) { + AuthenticatorAppsAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed() + AuthenticatorAppsAction.NavigateToSetup -> { + findNavController().safeNavigate(R.id.action_authenticatorAppsFragment_to_authenticatorSetupFragment) + } + is AuthenticatorAppsAction.NavigateToRename -> { + findNavController().safeNavigate( + R.id.action_authenticatorAppsFragment_to_authenticatorNameFragment, + Bundle().apply { putLong(AuthenticatorNavArgs.ARG_APP_ID, action.appId) } + ) + } + is AuthenticatorAppsAction.NavigateToRemovalCodeEntry -> { + findNavController().safeNavigate( + R.id.action_authenticatorAppsFragment_to_authenticatorCodeEntryFragment, + Bundle().apply { + putString(AuthenticatorNavArgs.ARG_PURPOSE, AuthenticatorNavArgs.PURPOSE_REMOVE) + putLong(AuthenticatorNavArgs.ARG_APP_ID, action.appId) + } + ) + } + AuthenticatorAppsAction.OpenLearnMore -> Log.w(TAG, "There's no support article to open yet.") + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppsViewModel.kt new file mode 100644 index 0000000000..002503548a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppsViewModel.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.authenticator + +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 org.signal.appsettings.authenticatorapps.AuthenticatorAppsAction +import org.signal.appsettings.authenticatorapps.AuthenticatorAppsEvent +import org.signal.appsettings.authenticatorapps.AuthenticatorAppsState +import org.signal.appsettings.authenticatorapps.AuthenticatorAppsState.Dialog +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 AuthenticatorAppsViewModel( + private val repository: AuthenticatorRepository = AuthenticatorRepository() +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(AuthenticatorAppsViewModel::class) + } + + private val _state = MutableStateFlow(AuthenticatorAppsState(maxApps = repository.getMaxApps())) + private val _actions = Channel(Channel.BUFFERED) + + val state: StateFlow = _state.asStateFlow() + val actions: Flow = _actions.receiveAsFlow() + + init { + refresh() + } + + override suspend fun processEvent(event: AuthenticatorAppsEvent) { + when (event) { + AuthenticatorAppsEvent.ScreenResumed -> { + refresh() + } + AuthenticatorAppsEvent.NavigateBackClicked -> { + _actions.send(AuthenticatorAppsAction.NavigateBack) + } + AuthenticatorAppsEvent.AddAuthenticatorAppClicked -> { + if (_state.value.atMaxApps) { + _state.update { it.copy(dialog = Dialog.MaxAppsReached) } + } else { + _actions.send(AuthenticatorAppsAction.NavigateToSetup) + } + } + AuthenticatorAppsEvent.LearnMoreClicked -> { + _actions.send(AuthenticatorAppsAction.OpenLearnMore) + } + is AuthenticatorAppsEvent.RenameAppClicked -> { + _actions.send(AuthenticatorAppsAction.NavigateToRename(event.appId)) + } + is AuthenticatorAppsEvent.RemoveAppClicked -> { + _state.update { it.copy(dialog = Dialog.ConfirmRemove(event.appId)) } + } + AuthenticatorAppsEvent.RemoveAppConfirmed -> { + val dialog = _state.value.dialog as? Dialog.ConfirmRemove ?: return + _state.update { it.copy(dialog = Dialog.None) } + _actions.send(AuthenticatorAppsAction.NavigateToRemovalCodeEntry(dialog.appId)) + } + AuthenticatorAppsEvent.DialogDismissed -> { + _state.update { it.copy(dialog = Dialog.None) } + } + } + } + + private fun refresh() { + _state.update { it.copy(apps = repository.getAuthenticatorApps()) } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryFragment.kt index b867e4654b..09ceb2489e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryFragment.kt @@ -8,7 +8,6 @@ package org.thoughtcrime.securesms.components.settings.app.account.authenticator import android.widget.Toast 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.authenticatorcodeentry.AuthenticatorCodeEntryAction @@ -16,15 +15,19 @@ import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryScree import org.signal.core.ui.compose.CollectActions import org.signal.core.ui.compose.ComposeFragment import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.thoughtcrime.securesms.util.viewModel import org.signal.appsettings.R as AppSettingsR /** - * Collects the code from the user's authenticator app. Carries out the [AuthenticatorCodeEntryAction]s that need the - * nav graph. + * Collects the code from the user's authenticator app, either to confirm a newly paired one or to authorize removing + * one. Carries out the [AuthenticatorCodeEntryAction]s that need the nav graph. */ class AuthenticatorCodeEntryFragment : ComposeFragment() { - private val viewModel: AuthenticatorCodeEntryViewModel by viewModels() + private val viewModel: AuthenticatorCodeEntryViewModel by viewModel { + AuthenticatorCodeEntryViewModel(AuthenticatorNavArgs.purpose(arguments)) + } @Composable override fun FragmentContent() { @@ -41,9 +44,12 @@ class AuthenticatorCodeEntryFragment : ComposeFragment() { private fun handleAction(action: AuthenticatorCodeEntryAction) { when (action) { AuthenticatorCodeEntryAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed() - AuthenticatorCodeEntryAction.NavigateToAccountSettings -> findNavController().popBackStack(R.id.accountSettingsFragment, false) - AuthenticatorCodeEntryAction.ShowAuthenticatorAppAdded -> { - Toast.makeText(requireContext(), AppSettingsR.string.AuthenticatorCodeEntryScreen__authenticator_app_added, Toast.LENGTH_SHORT).show() + AuthenticatorCodeEntryAction.NavigateToNaming -> { + findNavController().safeNavigate(R.id.action_authenticatorCodeEntryFragment_to_authenticatorNameFragment) + } + AuthenticatorCodeEntryAction.NavigateToAuthenticatorApps -> findNavController().popBackStack(R.id.authenticatorAppsFragment, false) + AuthenticatorCodeEntryAction.ShowAuthenticatorAppRemoved -> { + Toast.makeText(requireContext(), AppSettingsR.string.AuthenticatorAppsScreen__authenticator_app_removed, Toast.LENGTH_SHORT).show() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModel.kt index bcce007b0c..766a9bc770 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModel.kt @@ -15,14 +15,17 @@ import kotlinx.coroutines.flow.update import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryAction import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryEvent import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryState +import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryState.Purpose import org.signal.core.ui.compose.EventDrivenViewModel import org.signal.core.util.logging.Log /** - * Drives the screen that collects a code from the user's authenticator app. There's nothing to verify the code - * against yet, so any code of the right length is treated as correct. + * Drives the screen that collects a code from the user's authenticator app, which is required both to confirm a newly + * paired app and to remove one that already exists. There's nothing to verify the code against yet, so any code of the + * right length is treated as correct. */ class AuthenticatorCodeEntryViewModel( + purpose: Purpose = Purpose.Add, private val repository: AuthenticatorRepository = AuthenticatorRepository() ) : EventDrivenViewModel(TAG) { @@ -30,7 +33,7 @@ class AuthenticatorCodeEntryViewModel( private val TAG = Log.tag(AuthenticatorCodeEntryViewModel::class) } - private val _state = MutableStateFlow(AuthenticatorCodeEntryState()) + private val _state = MutableStateFlow(AuthenticatorCodeEntryState(purpose = purpose)) private val _actions = Channel(Channel.BUFFERED) val state: StateFlow = _state.asStateFlow() @@ -52,10 +55,17 @@ class AuthenticatorCodeEntryViewModel( Log.i(TAG, "Accepting the entered code without verifying it, which is all we can do until this is wired up.") _state.update { it.copy(submitting = true) } - repository.setHasAuthenticatorApp(true) - _actions.send(AuthenticatorCodeEntryAction.ShowAuthenticatorAppAdded) - _actions.send(AuthenticatorCodeEntryAction.NavigateToAccountSettings) + when (val purpose = _state.value.purpose) { + Purpose.Add -> { + _actions.send(AuthenticatorCodeEntryAction.NavigateToNaming) + } + is Purpose.Remove -> { + repository.removeAuthenticatorApp(purpose.appId) + _actions.send(AuthenticatorCodeEntryAction.ShowAuthenticatorAppRemoved) + _actions.send(AuthenticatorCodeEntryAction.NavigateToAuthenticatorApps) + } + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNameFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNameFragment.kt new file mode 100644 index 0000000000..ab951264d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNameFragment.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.authenticator + +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.fragment.findNavController +import org.signal.appsettings.authenticatorname.AuthenticatorNameAction +import org.signal.appsettings.authenticatorname.AuthenticatorNameScreen +import org.signal.core.ui.compose.CollectActions +import org.signal.core.ui.compose.ComposeFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.viewModel +import org.signal.appsettings.R as AppSettingsR + +/** + * Names an authenticator app, either a newly paired one or one being renamed. Carries out the + * [AuthenticatorNameAction]s that need the nav graph. + */ +class AuthenticatorNameFragment : ComposeFragment() { + + private val viewModel: AuthenticatorNameViewModel by viewModel { + AuthenticatorNameViewModel(AuthenticatorNavArgs.appId(arguments)) + } + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + + CollectActions(viewModel.actions) { action -> handleAction(action) } + + AuthenticatorNameScreen( + state = state, + onEvent = viewModel::onEvent + ) + } + + private fun handleAction(action: AuthenticatorNameAction) { + when (action) { + AuthenticatorNameAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed() + AuthenticatorNameAction.NavigateToAuthenticatorApps -> findNavController().popBackStack(R.id.authenticatorAppsFragment, false) + AuthenticatorNameAction.ShowAuthenticatorAppSetUp -> toast(AppSettingsR.string.AuthenticatorNameScreen__authenticator_app_set_up) + AuthenticatorNameAction.ShowAuthenticatorAppRenamed -> toast(AppSettingsR.string.AuthenticatorNameScreen__authenticator_app_renamed) + } + } + + 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/AuthenticatorNameViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNameViewModel.kt new file mode 100644 index 0000000000..0cbbc218cf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNameViewModel.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.authenticator + +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 org.signal.appsettings.authenticatorname.AuthenticatorNameAction +import org.signal.appsettings.authenticatorname.AuthenticatorNameEvent +import org.signal.appsettings.authenticatorname.AuthenticatorNameState +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log + +/** + * Drives the screen that names an authenticator app. [appId] is null when a newly paired app is being named for the + * first time, and set when an existing one is being renamed. + */ +class AuthenticatorNameViewModel( + private val appId: Long?, + private val repository: AuthenticatorRepository = AuthenticatorRepository() +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(AuthenticatorNameViewModel::class) + } + + private val _state = MutableStateFlow( + AuthenticatorNameState( + name = appId?.let { repository.getAuthenticatorApp(it)?.name } ?: "", + renaming = appId != null + ) + ) + private val _actions = Channel(Channel.BUFFERED) + + val state: StateFlow = _state.asStateFlow() + val actions: Flow = _actions.receiveAsFlow() + + override suspend fun processEvent(event: AuthenticatorNameEvent) { + when (event) { + AuthenticatorNameEvent.NavigateBackClicked -> { + _actions.send(AuthenticatorNameAction.NavigateBack) + } + is AuthenticatorNameEvent.NameChanged -> { + _state.update { it.copy(name = event.name) } + } + AuthenticatorNameEvent.NextClicked -> { + if (!_state.value.canSubmit) { + return + } + + val name = _state.value.name.trim() + _state.update { it.copy(submitting = true) } + + if (appId != null) { + repository.renameAuthenticatorApp(appId, name) + _actions.send(AuthenticatorNameAction.ShowAuthenticatorAppRenamed) + } else { + repository.addAuthenticatorApp(name) + _actions.send(AuthenticatorNameAction.ShowAuthenticatorAppSetUp) + } + + _actions.send(AuthenticatorNameAction.NavigateToAuthenticatorApps) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNavArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNavArgs.kt new file mode 100644 index 0000000000..c481d74d02 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNavArgs.kt @@ -0,0 +1,47 @@ +/* + * 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 org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryState.Purpose +import org.signal.core.util.logging.Log + +/** + * The nav arguments the authenticator app screens pass between each other, and the parsing that turns them back into + * something typed. Keep the values in sync with the argument defaults declared for these destinations in + * app_settings_with_change_number.xml. + */ +object AuthenticatorNavArgs { + + private val TAG = Log.tag(AuthenticatorNavArgs::class) + + /** Which of [PURPOSE_ADD]/[PURPOSE_REMOVE] a code is being collected for. */ + const val ARG_PURPOSE = "purpose" + const val PURPOSE_ADD = "ADD" + const val PURPOSE_REMOVE = "REMOVE" + + /** The app being removed or renamed, or [NO_APP_ID] when the screen is acting on a newly paired app. */ + const val ARG_APP_ID = "app_id" + const val NO_APP_ID = -1L + + /** The app id in [arguments], or null when the screen is acting on a newly paired app. */ + fun appId(arguments: Bundle?): Long? = arguments?.getLong(ARG_APP_ID, NO_APP_ID)?.takeIf { it != NO_APP_ID } + + /** The purpose in [arguments], falling back to [Purpose.Add] rather than removing an app we can't identify. */ + fun purpose(arguments: Bundle?): Purpose { + if (arguments?.getString(ARG_PURPOSE) != PURPOSE_REMOVE) { + return Purpose.Add + } + + val appId = appId(arguments) + if (appId == null) { + Log.w(TAG, "Asked to remove an authenticator app without an id. Collecting a code to add one instead.") + return Purpose.Add + } + + return Purpose.Remove(appId) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorRepository.kt index c57b06b107..4d810a8913 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorRepository.kt @@ -5,13 +5,21 @@ package org.thoughtcrime.securesms.components.settings.app.account.authenticator +import org.signal.appsettings.authenticatorapps.AuthenticatorApp + class AuthenticatorRepository { fun getSetupKey(): String = AuthenticatorAppStore.MOCK_SETUP_KEY - fun hasAuthenticatorApp(): Boolean = AuthenticatorAppStore.hasAuthenticatorApp + fun getMaxApps(): Int = AuthenticatorAppStore.MAX_APPS - fun setHasAuthenticatorApp(hasAuthenticatorApp: Boolean) { - AuthenticatorAppStore.hasAuthenticatorApp = hasAuthenticatorApp - } + fun getAuthenticatorApps(): List = AuthenticatorAppStore.getApps() + + fun getAuthenticatorApp(id: Long): AuthenticatorApp? = AuthenticatorAppStore.getApp(id) + + fun addAuthenticatorApp(name: String): Long = AuthenticatorAppStore.addApp(name, System.currentTimeMillis()) + + fun renameAuthenticatorApp(id: Long, name: String) = AuthenticatorAppStore.renameApp(id, name) + + fun removeAuthenticatorApp(id: Long) = AuthenticatorAppStore.removeApp(id) } 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 e79e35e786..92bfb66304 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 @@ -217,8 +217,8 @@ app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + + + + + + android:label="authenticator_code_entry_fragment"> + + + + + + + + + + + () == CORRECT_PIN } coEvery { repository.setRegistrationLockEnabled(any()) } returns true } @@ -301,15 +302,17 @@ class AccountSettingsViewModelTest { @Test fun `the Signal Login section is filled in when phone-numberless registration is on`() = runTest(testDispatcher) { every { repository.isPhoneNumberlessRegistrationEnabled() } returns true - every { repository.hasAuthenticatorApp() } returns true + every { repository.getAuthenticatorAppCount() } returns 2 + every { repository.getPasskeyCount() } returns 8 val viewModel = createViewModel() - assertThat(viewModel.state.value.signalLogin?.hasAuthenticatorApp).isEqualTo(true) + assertThat(viewModel.state.value.signalLogin?.authenticatorAppCount).isEqualTo(2) + assertThat(viewModel.state.value.signalLogin?.passkeyCount).isEqualTo(8) } @Test - fun `AuthenticatorAppClicked opens the authenticator setup flow`() = runTest(testDispatcher) { + fun `AuthenticatorAppClicked opens the authenticator apps screen`() = runTest(testDispatcher) { every { repository.isPhoneNumberlessRegistrationEnabled() } returns true val viewModel = createViewModel() @@ -317,7 +320,7 @@ class AccountSettingsViewModelTest { viewModel.onEvent(AccountSettingsEvent.AuthenticatorAppClicked) - assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToAuthenticatorAppSetup) + assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToAuthenticatorApps) } @Test diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppsViewModelTest.kt new file mode 100644 index 0000000000..dcc6f8500c --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppsViewModelTest.kt @@ -0,0 +1,195 @@ +/* + * 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.every +import io.mockk.mockk +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.Rule +import org.junit.Test +import org.signal.appsettings.authenticatorapps.AuthenticatorApp +import org.signal.appsettings.authenticatorapps.AuthenticatorAppsAction +import org.signal.appsettings.authenticatorapps.AuthenticatorAppsEvent +import org.signal.appsettings.authenticatorapps.AuthenticatorAppsState.Dialog +import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule + +@OptIn(ExperimentalCoroutinesApi::class) +class AuthenticatorAppsViewModelTest { + + companion object { + private val APP_ONE = AuthenticatorApp(id = 1, name = "Bitwarden Authenticator", createdAt = 0) + private val APP_TWO = AuthenticatorApp(id = 2, name = "Twilio Authy", createdAt = 0) + } + + private val testDispatcher = UnconfinedTestDispatcher() + private val repository: AuthenticatorRepository = mockk(relaxed = true) + + @get:Rule + val dispatcherRule = CoroutineDispatcherRule(testDispatcher) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + every { repository.getMaxApps() } returns 2 + every { repository.getAuthenticatorApps() } returns emptyList() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the configured apps are read on creation`() = runTest(testDispatcher) { + every { repository.getAuthenticatorApps() } returns listOf(APP_ONE) + + val viewModel = createViewModel() + + 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() + + every { repository.getAuthenticatorApps() } returns listOf(APP_ONE) + viewModel.onEvent(AuthenticatorAppsEvent.ScreenResumed) + + assertThat(viewModel.state.value.apps).containsExactly(APP_ONE) + } + + @Test + fun `AddAuthenticatorAppClicked opens setup when there's room for another app`() = runTest(testDispatcher) { + every { repository.getAuthenticatorApps() } returns listOf(APP_ONE) + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorAppsEvent.AddAuthenticatorAppClicked) + + assertThat(actions.last()).isEqualTo(AuthenticatorAppsAction.NavigateToSetup) + } + + @Test + fun `AddAuthenticatorAppClicked explains the limit when there's no room for another app`() = runTest(testDispatcher) { + every { repository.getAuthenticatorApps() } returns listOf(APP_ONE, APP_TWO) + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorAppsEvent.AddAuthenticatorAppClicked) + + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.MaxAppsReached) + assertThat(actions).isEmpty() + } + + @Test + fun `RenameAppClicked opens the naming screen for that app`() = runTest(testDispatcher) { + every { repository.getAuthenticatorApps() } returns listOf(APP_ONE) + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorAppsEvent.RenameAppClicked(APP_ONE.id)) + + assertThat(actions.last()).isEqualTo(AuthenticatorAppsAction.NavigateToRename(APP_ONE.id)) + } + + @Test + fun `RemoveAppClicked asks the user to confirm first`() = runTest(testDispatcher) { + every { repository.getAuthenticatorApps() } returns listOf(APP_ONE) + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorAppsEvent.RemoveAppClicked(APP_ONE.id)) + + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmRemove(APP_ONE.id)) + assertThat(actions).isEmpty() + } + + @Test + fun `RemoveAppConfirmed collects a code before removing the app`() = runTest(testDispatcher) { + every { repository.getAuthenticatorApps() } returns listOf(APP_ONE) + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorAppsEvent.RemoveAppClicked(APP_ONE.id)) + viewModel.onEvent(AuthenticatorAppsEvent.RemoveAppConfirmed) + + assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None) + assertThat(actions.last()).isEqualTo(AuthenticatorAppsAction.NavigateToRemovalCodeEntry(APP_ONE.id)) + } + + @Test + fun `RemoveAppConfirmed does nothing when no removal is pending`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorAppsEvent.RemoveAppConfirmed) + + assertThat(actions).isEmpty() + } + + @Test + fun `DialogDismissed clears the dialog`() = runTest(testDispatcher) { + every { repository.getAuthenticatorApps() } returns listOf(APP_ONE) + + val viewModel = createViewModel() + + viewModel.onEvent(AuthenticatorAppsEvent.RemoveAppClicked(APP_ONE.id)) + viewModel.onEvent(AuthenticatorAppsEvent.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(AuthenticatorAppsEvent.NavigateBackClicked) + + assertThat(actions.last()).isEqualTo(AuthenticatorAppsAction.NavigateBack) + } + + @Test + fun `LearnMoreClicked opens the support article`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorAppsEvent.LearnMoreClicked) + + assertThat(actions.last()).isEqualTo(AuthenticatorAppsAction.OpenLearnMore) + } + + private fun createViewModel() = AuthenticatorAppsViewModel(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/AuthenticatorCodeEntryViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModelTest.kt index 9ae61f35b1..2702fa8fdc 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModelTest.kt @@ -10,7 +10,6 @@ import assertk.assertions.contains import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isFalse -import assertk.assertions.isTrue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -27,6 +26,7 @@ import org.junit.Rule import org.junit.Test import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryAction import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryEvent +import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryState.Purpose import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule @OptIn(ExperimentalCoroutinesApi::class) @@ -44,18 +44,18 @@ class AuthenticatorCodeEntryViewModelTest { @Before fun setUp() { Dispatchers.setMain(testDispatcher) - AuthenticatorAppStore.hasAuthenticatorApp = false + clearApps() } @After fun tearDown() { Dispatchers.resetMain() - AuthenticatorAppStore.hasAuthenticatorApp = false + clearApps() } @Test fun `non-digits are dropped and the code is capped at six digits`() = runTest(testDispatcher) { - val viewModel = AuthenticatorCodeEntryViewModel() + val viewModel = createViewModel() viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged("12a34 5678")) @@ -64,7 +64,7 @@ class AuthenticatorCodeEntryViewModelTest { @Test fun `a partial code can't be submitted`() = runTest(testDispatcher) { - val viewModel = AuthenticatorCodeEntryViewModel() + val viewModel = createViewModel() val actions = collectActions(viewModel.actions) viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged("123")) @@ -74,25 +74,36 @@ class AuthenticatorCodeEntryViewModelTest { viewModel.onEvent(AuthenticatorCodeEntryEvent.DoneClicked) assertThat(actions).isEmpty() - assertThat(AuthenticatorAppStore.hasAuthenticatorApp).isFalse() } @Test - fun `a full code is accepted and sends the user back to account settings`() = runTest(testDispatcher) { - val viewModel = AuthenticatorCodeEntryViewModel() + fun `a full code entered while adding sends the user on to name the app`() = runTest(testDispatcher) { + val viewModel = createViewModel(purpose = Purpose.Add) val actions = collectActions(viewModel.actions) viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged(FULL_CODE)) viewModel.onEvent(AuthenticatorCodeEntryEvent.DoneClicked) - assertThat(AuthenticatorAppStore.hasAuthenticatorApp).isTrue() - assertThat(actions).contains(AuthenticatorCodeEntryAction.ShowAuthenticatorAppAdded) - assertThat(actions.last()).isEqualTo(AuthenticatorCodeEntryAction.NavigateToAccountSettings) + assertThat(actions.last()).isEqualTo(AuthenticatorCodeEntryAction.NavigateToNaming) + } + + @Test + fun `a full code entered while removing removes the app and goes back to the list`() = runTest(testDispatcher) { + val appId = AuthenticatorAppStore.addApp(name = "Bitwarden Authenticator", createdAt = 0) + val viewModel = createViewModel(purpose = Purpose.Remove(appId)) + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged(FULL_CODE)) + viewModel.onEvent(AuthenticatorCodeEntryEvent.DoneClicked) + + assertThat(AuthenticatorAppStore.getApps()).isEmpty() + assertThat(actions).contains(AuthenticatorCodeEntryAction.ShowAuthenticatorAppRemoved) + assertThat(actions.last()).isEqualTo(AuthenticatorCodeEntryAction.NavigateToAuthenticatorApps) } @Test fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) { - val viewModel = AuthenticatorCodeEntryViewModel() + val viewModel = createViewModel() val actions = collectActions(viewModel.actions) viewModel.onEvent(AuthenticatorCodeEntryEvent.NavigateBackClicked) @@ -100,6 +111,12 @@ class AuthenticatorCodeEntryViewModelTest { assertThat(actions.last()).isEqualTo(AuthenticatorCodeEntryAction.NavigateBack) } + private fun createViewModel(purpose: Purpose = Purpose.Add) = AuthenticatorCodeEntryViewModel(purpose = purpose) + + private fun clearApps() { + AuthenticatorAppStore.getApps().forEach { AuthenticatorAppStore.removeApp(it.id) } + } + private fun TestScope.collectActions(actions: Flow): List { val collected = mutableListOf() backgroundScope.launch { actions.toList(collected) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNameViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNameViewModelTest.kt new file mode 100644 index 0000000000..c752e6e4cb --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNameViewModelTest.kt @@ -0,0 +1,134 @@ +/* + * 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.contains +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +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.Rule +import org.junit.Test +import org.signal.appsettings.authenticatorapps.AuthenticatorApp +import org.signal.appsettings.authenticatorname.AuthenticatorNameAction +import org.signal.appsettings.authenticatorname.AuthenticatorNameEvent +import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule + +@OptIn(ExperimentalCoroutinesApi::class) +class AuthenticatorNameViewModelTest { + + companion object { + private val EXISTING_APP = AuthenticatorApp(id = 7, name = "Twilio Authy", createdAt = 0) + } + + private val testDispatcher = UnconfinedTestDispatcher() + private val repository: AuthenticatorRepository = mockk(relaxed = true) + + @get:Rule + val dispatcherRule = CoroutineDispatcherRule(testDispatcher) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + every { repository.getAuthenticatorApp(EXISTING_APP.id) } returns EXISTING_APP + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `naming a new app starts empty and isn't renaming`() = runTest(testDispatcher) { + val viewModel = createViewModel(appId = null) + + assertThat(viewModel.state.value.name).isEqualTo("") + assertThat(viewModel.state.value.renaming).isFalse() + } + + @Test + fun `renaming starts from the app's current name`() = runTest(testDispatcher) { + val viewModel = createViewModel(appId = EXISTING_APP.id) + + assertThat(viewModel.state.value.name).isEqualTo(EXISTING_APP.name) + assertThat(viewModel.state.value.renaming).isTrue() + } + + @Test + fun `a blank name can't be submitted`() = runTest(testDispatcher) { + val viewModel = createViewModel(appId = null) + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorNameEvent.NameChanged(" ")) + + assertThat(viewModel.state.value.canSubmit).isFalse() + + viewModel.onEvent(AuthenticatorNameEvent.NextClicked) + + assertThat(actions).isEmpty() + } + + @Test + fun `NextClicked adds a new app and goes back to the list`() = runTest(testDispatcher) { + val viewModel = createViewModel(appId = null) + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorNameEvent.NameChanged(" Bitwarden Authenticator ")) + viewModel.onEvent(AuthenticatorNameEvent.NextClicked) + + verify { repository.addAuthenticatorApp("Bitwarden Authenticator") } + assertThat(actions).contains(AuthenticatorNameAction.ShowAuthenticatorAppSetUp) + assertThat(actions.last()).isEqualTo(AuthenticatorNameAction.NavigateToAuthenticatorApps) + } + + @Test + fun `NextClicked renames an existing app and goes back to the list`() = runTest(testDispatcher) { + val viewModel = createViewModel(appId = EXISTING_APP.id) + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorNameEvent.NameChanged("Work Authenticator")) + viewModel.onEvent(AuthenticatorNameEvent.NextClicked) + + verify { repository.renameAuthenticatorApp(EXISTING_APP.id, "Work Authenticator") } + assertThat(actions).contains(AuthenticatorNameAction.ShowAuthenticatorAppRenamed) + assertThat(actions.last()).isEqualTo(AuthenticatorNameAction.NavigateToAuthenticatorApps) + } + + @Test + fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) { + val viewModel = createViewModel(appId = null) + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorNameEvent.NavigateBackClicked) + + assertThat(actions.last()).isEqualTo(AuthenticatorNameAction.NavigateBack) + } + + private fun createViewModel(appId: Long?) = AuthenticatorNameViewModel(appId, 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/AuthenticatorNavArgsTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNavArgsTest.kt new file mode 100644 index 0000000000..0f0b2f37d8 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorNavArgsTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.authenticator + +import android.app.Application +import android.os.Bundle +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryState.Purpose + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class AuthenticatorNavArgsTest { + + @Test + fun `no arguments means adding a new app`() { + assertThat(AuthenticatorNavArgs.purpose(null)).isEqualTo(Purpose.Add) + assertThat(AuthenticatorNavArgs.appId(null)).isNull() + } + + @Test + fun `an unset app id reads as null`() { + val arguments = Bundle().apply { putLong(AuthenticatorNavArgs.ARG_APP_ID, AuthenticatorNavArgs.NO_APP_ID) } + + assertThat(AuthenticatorNavArgs.appId(arguments)).isNull() + } + + @Test + fun `a removal carries the app id it names`() { + val arguments = Bundle().apply { + putString(AuthenticatorNavArgs.ARG_PURPOSE, AuthenticatorNavArgs.PURPOSE_REMOVE) + putLong(AuthenticatorNavArgs.ARG_APP_ID, 7) + } + + assertThat(AuthenticatorNavArgs.appId(arguments)).isEqualTo(7L) + assertThat(AuthenticatorNavArgs.purpose(arguments)).isEqualTo(Purpose.Remove(7)) + } + + @Test + fun `a removal without an app id falls back to adding rather than removing something unidentified`() { + val arguments = Bundle().apply { putString(AuthenticatorNavArgs.ARG_PURPOSE, AuthenticatorNavArgs.PURPOSE_REMOVE) } + + assertThat(AuthenticatorNavArgs.purpose(arguments)).isEqualTo(Purpose.Add) + } + + @Test + fun `an unrecognized purpose falls back to adding instead of throwing`() { + val arguments = Bundle().apply { putString(AuthenticatorNavArgs.ARG_PURPOSE, "nonsense") } + + assertThat(AuthenticatorNavArgs.purpose(arguments)).isEqualTo(Purpose.Add) + } +} 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 61b06fc4ac..dbd1169b65 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 @@ -25,8 +25,8 @@ sealed interface AccountSettingsAction { /** Tell the user their PIN was created. */ data object ShowPinCreatedConfirmation : AccountSettingsAction - /** Open the flow that sets up an authenticator app. */ - data object NavigateToAuthenticatorAppSetup : AccountSettingsAction + /** Open the screen listing the account's authenticator apps. */ + data object NavigateToAuthenticatorApps : AccountSettingsAction /** Open the passkeys screen. */ data object NavigateToPasskeys : AccountSettingsAction 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 d11f52a007..7888c715dd 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 @@ -126,10 +126,10 @@ fun AccountSettingsScreen( Rows.TextRow( icon = SignalIcons.DevicePhone.imageVector, text = stringResource(R.string.AccountSettingsFragment__authenticator_app), - label = if (state.signalLogin.hasAuthenticatorApp) { - stringResource(R.string.AccountSettingsFragment__enabled) + label = if (state.signalLogin.authenticatorAppCount > 0) { + pluralStringResource(R.plurals.AccountSettingsFragment__d_configured, state.signalLogin.authenticatorAppCount, state.signalLogin.authenticatorAppCount) } else { - stringResource(R.string.AccountSettingsFragment__use_an_authenticator_app) + stringResource(R.string.AccountSettingsFragment__one_time_verification_codes) }, onClick = { onEvent(AccountSettingsEvent.AuthenticatorAppClicked) }, modifier = Modifier.testTag(AccountSettingsTestTags.ROW_AUTHENTICATOR_APP) @@ -140,7 +140,11 @@ fun AccountSettingsScreen( Rows.TextRow( icon = SignalIcons.Key.imageVector, text = stringResource(R.string.AccountSettingsFragment__passkeys), - label = stringResource(R.string.AccountSettingsFragment__device_biometrics_or_fido2_security_key), + 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) ) @@ -565,7 +569,7 @@ private fun AccountSettingsScreenSignalLoginPreview() { state = AccountSettingsState( hasPin = true, pinRemindersEnabled = true, - signalLogin = AccountSettingsState.SignalLogin(keyCount = 2, hasAuthenticatorApp = false) + signalLogin = AccountSettingsState.SignalLogin(keyCount = 2, authenticatorAppCount = 2, passkeyCount = 8) ), onEvent = {} ) 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 ff8b450a4a..f2cca555f5 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,7 +26,8 @@ data class AccountSettingsState( */ data class SignalLogin( val keyCount: Int, - val hasAuthenticatorApp: Boolean + val authenticatorAppCount: Int, + val passkeyCount: Int ) /** Whichever dialog the screen is showing, if any. Only one is ever up at a time. */ diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorApp.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorApp.kt new file mode 100644 index 0000000000..0aab02a3c4 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorApp.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorapps + +/** + * A single authenticator app configured on the user's account, as shown on [AuthenticatorAppsScreen]. + */ +data class AuthenticatorApp( + val id: Long, + val name: String, + /** When the app was configured, in epoch milliseconds. */ + val createdAt: Long +) { + override fun toString(): String = "AuthenticatorApp(id=$id)" +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsAction.kt new file mode 100644 index 0000000000..fb82f2970f --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsAction.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorapps + +/** + * One-shot side effects that need the nav graph, and therefore have to be carried out by the fragment hosting + * [AuthenticatorAppsScreen] rather than the screen itself. + * + * Actions are logged, so be sure `toString()` contains nothing sensitive. + */ +sealed interface AuthenticatorAppsAction { + + /** Leave the screen. */ + data object NavigateBack : AuthenticatorAppsAction + + /** Open the flow that pairs a new authenticator app. */ + data object NavigateToSetup : AuthenticatorAppsAction + + /** Open the screen that renames [appId]. */ + data class NavigateToRename(val appId: Long) : AuthenticatorAppsAction + + /** Collect a code from [appId] before it's removed. */ + data class NavigateToRemovalCodeEntry(val appId: Long) : AuthenticatorAppsAction + + /** Send the user to a support article about authenticator apps. */ + data object OpenLearnMore : AuthenticatorAppsAction +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsEvent.kt new file mode 100644 index 0000000000..a774f52466 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsEvent.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorapps + +/** + * Reminder that these events are logged, so don't include anything sensitive in the toString. + */ +sealed interface AuthenticatorAppsEvent { + + /** The screen came back to the foreground, so the list we read out of storage may be stale. */ + data object ScreenResumed : AuthenticatorAppsEvent + + /** The user tapped the navigation (back) icon. */ + data object NavigateBackClicked : AuthenticatorAppsEvent + + /** The user tapped the button that starts setting up another authenticator app. */ + data object AddAuthenticatorAppClicked : AuthenticatorAppsEvent + + /** The user tapped the learn more link. */ + data object LearnMoreClicked : AuthenticatorAppsEvent + + /** The user tapped the rename option in an app's overflow menu. */ + data class RenameAppClicked(val appId: Long) : AuthenticatorAppsEvent + + /** The user tapped the remove option in an app's overflow menu, which asks them to confirm first. */ + data class RemoveAppClicked(val appId: Long) : AuthenticatorAppsEvent + + /** The user confirmed removing the app named in [AuthenticatorAppsState.Dialog.ConfirmRemove]. */ + data object RemoveAppConfirmed : AuthenticatorAppsEvent + + /** Dismisses whatever is in [AuthenticatorAppsState.dialog]. */ + data object DialogDismissed : AuthenticatorAppsEvent +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsScreen.kt new file mode 100644 index 0000000000..41ad0b7592 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsScreen.kt @@ -0,0 +1,340 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorapps + +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.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.authenticatorapps.AuthenticatorAppsState.Dialog +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 AuthenticatorAppsTestTags { + 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 DIALOG_CONFIRM_REMOVE = "dialog-confirm-remove" + const val DIALOG_MAX_APPS_REACHED = "dialog-max-apps-reached" +} + +/** + * Lists the authenticator apps configured on the account and lets the user add, rename, or remove one. + */ +@Composable +fun AuthenticatorAppsScreen( + state: AuthenticatorAppsState, + onEvent: (AuthenticatorAppsEvent) -> Unit +) { + Scaffolds.Settings( + title = stringResource(R.string.AuthenticatorAppsScreen__authenticator_app), + onNavigationClick = { onEvent(AuthenticatorAppsEvent.NavigateBackClicked) }, + navigationIcon = SignalIcons.ArrowStart.imageVector + ) { contentPadding -> + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(contentPadding) + .testTag(AuthenticatorAppsTestTags.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(AuthenticatorAppsTestTags.LEARN_MORE) + ) + } + + item { + Buttons.MediumTonal( + onClick = { onEvent(AuthenticatorAppsEvent.AddAuthenticatorAppClicked) }, + 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(AuthenticatorAppsTestTags.BUTTON_ADD) + ) { + Text(text = stringResource(R.string.AuthenticatorAppsScreen__add_authenticator_app)) + } + } + + item { + Dividers.Default() + } + + item { + Texts.SectionHeader( + text = stringResource(R.string.AuthenticatorAppsScreen__authenticator_apps), + modifier = Modifier.fillMaxWidth() + ) + } + + if (state.apps.isEmpty()) { + item { + Text( + text = stringResource(R.string.AuthenticatorAppsScreen__no_authenticator_apps), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(top = 40.dp) + .testTag(AuthenticatorAppsTestTags.EMPTY_MESSAGE) + ) + } + } else { + items(state.apps, key = { it.id }) { app -> + AuthenticatorAppRow( + app = app, + onEvent = onEvent + ) + } + } + } + } + + when (val dialog = state.dialog) { + Dialog.None -> Unit + is Dialog.ConfirmRemove -> ConfirmRemoveDialog(onEvent) + Dialog.MaxAppsReached -> MaxAppsReachedDialog(maxApps = state.maxApps, onEvent = onEvent) + } +} + +@Composable +private fun AuthenticatorAppRow( + app: AuthenticatorApp, + onEvent: (AuthenticatorAppsEvent) -> 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.AuthenticatorAppsScreen__added_s, addedTime) + ) + + AuthenticatorAppMenuButton( + app = app, + onEvent = onEvent + ) + }, + modifier = Modifier.testTag(AuthenticatorAppsTestTags.ROW_APP) + ) +} + +@Composable +private fun AuthenticatorAppMenuButton( + app: AuthenticatorApp, + onEvent: (AuthenticatorAppsEvent) -> Unit +) { + val menuController = remember { DropdownMenus.MenuController() } + + Box { + IconButton( + onClick = menuController::show, + modifier = Modifier.testTag(AuthenticatorAppsTestTags.BUTTON_APP_MENU) + ) { + Icon( + imageVector = SignalIcons.MoreVertical.imageVector, + contentDescription = stringResource(R.string.AuthenticatorAppsScreen__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.AuthenticatorAppsScreen__rename)) }, + onClick = { + onEvent(AuthenticatorAppsEvent.RenameAppClicked(app.id)) + controller.hide() + }, + modifier = Modifier.testTag(AuthenticatorAppsTestTags.MENU_ITEM_RENAME) + ) + + DropdownMenus.Item( + leadingIconResId = CoreUiR.drawable.symbol_x_circle_24, + text = { Text(text = stringResource(R.string.AuthenticatorAppsScreen__remove)) }, + onClick = { + onEvent(AuthenticatorAppsEvent.RemoveAppClicked(app.id)) + controller.hide() + }, + modifier = Modifier.testTag(AuthenticatorAppsTestTags.MENU_ITEM_REMOVE) + ) + } + } +} + +@Composable +private fun DescriptionWithLearnMore( + onEvent: (AuthenticatorAppsEvent) -> Unit, + modifier: Modifier = Modifier +) { + Text( + text = buildAnnotatedString { + append(stringResource(R.string.AuthenticatorAppsScreen__set_up_an_authenticator_app)) + append(' ') + + withLink( + LinkAnnotation.Clickable( + tag = "learn-more", + styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)), + linkInteractionListener = { onEvent(AuthenticatorAppsEvent.LearnMoreClicked) } + ) + ) { + append(stringResource(R.string.AuthenticatorAppsScreen__learn_more)) + } + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = modifier + ) +} + +@Composable +private fun ConfirmRemoveDialog( + onEvent: (AuthenticatorAppsEvent) -> Unit +) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.AuthenticatorAppsScreen__remove_authenticator_app), + body = stringResource(R.string.AuthenticatorAppsScreen__to_remove_this_authentication_method), + confirm = stringResource(R.string.AuthenticatorAppsScreen__remove), + onConfirm = { onEvent(AuthenticatorAppsEvent.RemoveAppConfirmed) }, + onDismiss = { onEvent(AuthenticatorAppsEvent.DialogDismissed) }, + dismiss = stringResource(android.R.string.cancel), + onDismissRequest = { onEvent(AuthenticatorAppsEvent.DialogDismissed) }, + modifier = Modifier.testTag(AuthenticatorAppsTestTags.DIALOG_CONFIRM_REMOVE) + ) +} + +@Composable +private fun MaxAppsReachedDialog( + maxApps: Int, + onEvent: (AuthenticatorAppsEvent) -> Unit +) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.AuthenticatorAppsScreen__cant_add_authenticator_app), + body = stringResource(R.string.AuthenticatorAppsScreen__you_cant_add_more_than_d, maxApps), + confirm = stringResource(android.R.string.ok), + onConfirm = {}, + onDismiss = { onEvent(AuthenticatorAppsEvent.DialogDismissed) }, + dismiss = stringResource(R.string.AuthenticatorAppsScreen__learn_more), + onDeny = { onEvent(AuthenticatorAppsEvent.LearnMoreClicked) }, + onDismissRequest = { onEvent(AuthenticatorAppsEvent.DialogDismissed) }, + modifier = Modifier.testTag(AuthenticatorAppsTestTags.DIALOG_MAX_APPS_REACHED) + ) +} + +@DayNightPreviews +@Composable +private fun AuthenticatorAppsScreenPreview() { + Previews.Preview { + AuthenticatorAppsScreen( + state = AuthenticatorAppsState(), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun AuthenticatorAppsScreenWithAppsPreview() { + Previews.Preview { + AuthenticatorAppsScreen( + state = AuthenticatorAppsState(apps = PREVIEW_APPS), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun ConfirmRemoveDialogPreview() { + Previews.Preview { + ConfirmRemoveDialog(onEvent = {}) + } +} + +@DayNightPreviews +@Composable +private fun MaxAppsReachedDialogPreview() { + Previews.Preview { + MaxAppsReachedDialog(maxApps = 2, onEvent = {}) + } +} + +private val PREVIEW_APPS = listOf( + AuthenticatorApp(id = 1, name = "Bitwarden Authenticator", createdAt = System.currentTimeMillis()), + AuthenticatorApp(id = 2, name = "Twilio Authy", createdAt = System.currentTimeMillis()) +) diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsState.kt new file mode 100644 index 0000000000..2dc9606bd4 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsState.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorapps + +data class AuthenticatorAppsState( + /** The authenticator apps configured on the account. When empty, the list section says so instead of listing rows. */ + val apps: List = emptyList(), + /** How many authenticator apps the account is allowed to have at once. */ + val maxApps: Int = 0, + val dialog: Dialog = Dialog.None +) { + + val atMaxApps: Boolean + get() = apps.size >= maxApps + + /** 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/authenticatorcodeentry/AuthenticatorCodeEntryAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryAction.kt index 58f711b101..4ab3d9d44b 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryAction.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryAction.kt @@ -16,9 +16,12 @@ sealed interface AuthenticatorCodeEntryAction { /** Leave the screen. */ data object NavigateBack : AuthenticatorCodeEntryAction - /** The authenticator app is set up, so go back to account settings. */ - data object NavigateToAccountSettings : AuthenticatorCodeEntryAction + /** The new authenticator app is confirmed, so go name it. */ + data object NavigateToNaming : AuthenticatorCodeEntryAction - /** Tell the user their authenticator app was added. */ - data object ShowAuthenticatorAppAdded : AuthenticatorCodeEntryAction + /** The removal is done, so go back to the list of authenticator apps. */ + data object NavigateToAuthenticatorApps : AuthenticatorCodeEntryAction + + /** Tell the user their authenticator app was removed. */ + data object ShowAuthenticatorAppRemoved : AuthenticatorCodeEntryAction } diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryState.kt index 1c5ace1f12..665f6ff7b3 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryState.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryState.kt @@ -7,13 +7,23 @@ package org.signal.appsettings.authenticatorcodeentry data class AuthenticatorCodeEntryState( val code: String = "", - val submitting: Boolean = false + val submitting: Boolean = false, + /** What the code is being collected for, which decides where the user goes once it's accepted. */ + val purpose: Purpose = Purpose.Add ) { val canSubmit: Boolean get() = code.length == CODE_LENGTH && !submitting - override fun toString(): String = "AuthenticatorCodeEntryState(codeLength=${code.length}, submitting=$submitting)" + override fun toString(): String = "AuthenticatorCodeEntryState(codeLength=${code.length}, submitting=$submitting, purpose=$purpose)" + + sealed interface Purpose { + /** Confirming a newly paired authenticator app, which is then named. */ + data object Add : Purpose + + /** Confirming removal of the already-configured app identified by [appId]. */ + data class Remove(val appId: Long) : Purpose + } companion object { const val CODE_LENGTH = 6 diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameAction.kt new file mode 100644 index 0000000000..346785bb11 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameAction.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorname + +/** + * One-shot side effects that need the nav graph, and therefore have to be carried out by the fragment hosting + * [AuthenticatorNameScreen] rather than the screen itself. + * + * Actions are logged, so be sure `toString()` contains nothing sensitive. + */ +sealed interface AuthenticatorNameAction { + + /** Leave the screen. */ + data object NavigateBack : AuthenticatorNameAction + + /** The app has a name now, so go back to the list of authenticator apps. */ + data object NavigateToAuthenticatorApps : AuthenticatorNameAction + + /** Tell the user their authenticator app was set up. */ + data object ShowAuthenticatorAppSetUp : AuthenticatorNameAction + + /** Tell the user their authenticator app was renamed. */ + data object ShowAuthenticatorAppRenamed : AuthenticatorNameAction +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameEvent.kt new file mode 100644 index 0000000000..034eef5108 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameEvent.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorname + +/** + * Reminder that these events are logged, so don't include anything sensitive in the toString. + */ +sealed interface AuthenticatorNameEvent { + + /** The user tapped the navigation (back) icon. */ + data object NavigateBackClicked : AuthenticatorNameEvent + + /** The user typed in the name field. */ + data class NameChanged(val name: String) : AuthenticatorNameEvent { + override fun toString(): String = "NameChanged(length=${name.length})" + } + + /** The user submitted the name they entered. */ + data object NextClicked : AuthenticatorNameEvent +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameScreen.kt new file mode 100644 index 0000000000..5a7ff28fbc --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameScreen.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorname + +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +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.Previews +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons + +@VisibleForTesting +object AuthenticatorNameTestTags { + const val NAME_INPUT = "name-input" + const val BUTTON_NEXT = "button-next" +} + +/** + * Collects the name the user wants to identify an authenticator app by, either right after pairing one or when + * renaming one that already exists. + */ +@Composable +fun AuthenticatorNameScreen( + state: AuthenticatorNameState, + onEvent: (AuthenticatorNameEvent) -> Unit +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Scaffolds.Settings( + title = stringResource(R.string.AuthenticatorNameScreen__choose_a_name), + onNavigationClick = { onEvent(AuthenticatorNameEvent.NavigateBackClicked) }, + navigationIcon = SignalIcons.ArrowStart.imageVector + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .imePadding(), + horizontalAlignment = Alignment.End + ) { + Text( + text = if (state.renaming) { + stringResource(R.string.AuthenticatorNameScreen__choose_a_unique_name) + } else { + stringResource(R.string.AuthenticatorNameScreen__choose_a_unique_name_to_help_you_identify_it) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + ) + + TextField( + value = state.name, + onValueChange = { onEvent(AuthenticatorNameEvent.NameChanged(it)) }, + label = { Text(text = stringResource(R.string.AuthenticatorNameScreen__name)) }, + singleLine = true, + enabled = !state.submitting, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (state.canSubmit) onEvent(AuthenticatorNameEvent.NextClicked) }), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .focusRequester(focusRequester) + .testTag(AuthenticatorNameTestTags.NAME_INPUT) + ) + + Spacer(modifier = Modifier.weight(1f)) + + Buttons.LargeTonal( + onClick = { onEvent(AuthenticatorNameEvent.NextClicked) }, + enabled = state.canSubmit, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 24.dp) + .testTag(AuthenticatorNameTestTags.BUTTON_NEXT) + ) { + Text(text = stringResource(R.string.AuthenticatorNameScreen__next)) + } + } + } +} + +@DayNightPreviews +@Composable +private fun AuthenticatorNameScreenPreview() { + Previews.Preview { + AuthenticatorNameScreen( + state = AuthenticatorNameState(name = "Bitwarden Authenticator"), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun AuthenticatorNameScreenRenamePreview() { + Previews.Preview { + AuthenticatorNameScreen( + state = AuthenticatorNameState(name = "Twilio Authy", renaming = true), + onEvent = {} + ) + } +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameState.kt new file mode 100644 index 0000000000..6769f5e49a --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorname/AuthenticatorNameState.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorname + +data class AuthenticatorNameState( + val name: String = "", + /** True when an already-configured app is being renamed, false when one is being named for the first time. */ + val renaming: Boolean = false, + val submitting: Boolean = false +) { + + val canSubmit: Boolean + get() = name.isNotBlank() && !submitting + + override fun toString(): String = "AuthenticatorNameState(nameLength=${name.length}, renaming=$renaming, submitting=$submitting)" +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupEvent.kt index 3a325488c8..99ec29866c 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupEvent.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupEvent.kt @@ -10,7 +10,7 @@ package org.signal.appsettings.authenticatorsetup */ sealed interface AuthenticatorSetupEvent { - /** The user tapped the navigation (close) icon. */ + /** The user tapped the navigation (back) icon. */ data object NavigateBackClicked : AuthenticatorSetupEvent /** The user tapped the button that hands the setup key off to their authenticator app. */ diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupScreen.kt index 6a3f9b2488..1521aed636 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupScreen.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupScreen.kt @@ -68,9 +68,9 @@ fun AuthenticatorSetupScreen( onEvent: (AuthenticatorSetupEvent) -> Unit ) { Scaffolds.Settings( - title = stringResource(R.string.AuthenticatorSetupScreen__authenticator_app), + title = stringResource(R.string.AuthenticatorSetupScreen__set_up_your_authenticator_app), onNavigationClick = { onEvent(AuthenticatorSetupEvent.NavigateBackClicked) }, - navigationIcon = SignalIcons.X.imageVector + navigationIcon = SignalIcons.ArrowStart.imageVector ) { contentPadding -> Column( modifier = Modifier diff --git a/feature/app-settings/src/main/res/values/strings.xml b/feature/app-settings/src/main/res/values/strings.xml index fec04d049c..e8893194e3 100644 --- a/feature/app-settings/src/main/res/values/strings.xml +++ b/feature/app-settings/src/main/res/values/strings.xml @@ -59,14 +59,22 @@ Two-factor authentication Authenticator app - - Use an authenticator app to generate one-time verification codes - - Enabled + + One-time verification codes + + + %1$d configured + %1$d configured + Passkeys - + Device biometrics or FIDO2 security key + + + %1$d passkey + %1$d passkeys + Use a second form of authentication to protect your account when using your Signal Login on a new device. @@ -74,7 +82,7 @@ - Authenticator app + Set up your authenticator app Follow these steps to set up your authenticator app. @@ -141,6 +149,51 @@ Code Done + + + 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? + + To remove this authentication method the 6-digit code from your authenticator app is required. + + Can\'t add authenticator app + + You can\'t add more than %1$d authenticator apps. Try removing one first. + + Authenticator app removed + + + + Choose a name + + Choose a unique name for this authenticator app. + + Choose a unique name for this authenticator app to help you identify it later. + + Name + + Next - Authenticator app added + Authenticator app set up + + Authenticator app renamed 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 ab717d97fc..5e5ccb3121 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 @@ -299,7 +299,7 @@ class AccountSettingsScreenTest { @Test fun givenASignalLogin_whenIClickAuthenticatorApp_thenIExpectAuthenticatorAppEvent() { - setContent(createState(signalLogin = AccountSettingsState.SignalLogin(keyCount = 2, hasAuthenticatorApp = false))) + setContent(createState(signalLogin = AccountSettingsState.SignalLogin(keyCount = 2, authenticatorAppCount = 0, passkeyCount = 0))) composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertIsDisplayed() @@ -310,7 +310,7 @@ class AccountSettingsScreenTest { @Test fun givenASignalLogin_whenIClickPasskeys_thenIExpectPasskeysEvent() { - setContent(createState(signalLogin = AccountSettingsState.SignalLogin(keyCount = 2, hasAuthenticatorApp = false))) + setContent(createState(signalLogin = AccountSettingsState.SignalLogin(keyCount = 2, authenticatorAppCount = 0, passkeyCount = 0))) scrollTo(AccountSettingsTestTags.ROW_PASSKEYS) diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsScreenTest.kt new file mode 100644 index 0000000000..85bfc76888 --- /dev/null +++ b/feature/app-settings/src/test/java/org/signal/appsettings/authenticatorapps/AuthenticatorAppsScreenTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorapps + +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.authenticatorapps.AuthenticatorAppsState.Dialog +import org.signal.core.ui.compose.Dialogs + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class AuthenticatorAppsScreenTest { + + companion object { + private val APPS = listOf( + AuthenticatorApp(id = 1, name = "Bitwarden Authenticator", createdAt = System.currentTimeMillis()), + AuthenticatorApp(id = 2, name = "Twilio Authy", createdAt = System.currentTimeMillis()) + ) + } + + @get:Rule + val composeTestRule = createComposeRule() + + private val events = mutableListOf() + + @Test + fun givenNoApps_whenIDisplayScreen_thenIExpectTheEmptyMessage() { + setContent(AuthenticatorAppsState()) + + scrollTo(AuthenticatorAppsTestTags.EMPTY_MESSAGE) + composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.EMPTY_MESSAGE).assertIsDisplayed() + } + + @Test + fun givenApps_whenIDisplayScreen_thenIExpectARowPerApp() { + setContent(AuthenticatorAppsState(apps = APPS)) + + for (app in APPS) { + composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.SCROLLER).performScrollToNode(hasText(app.name)) + composeTestRule.onNodeWithText(app.name).assertIsDisplayed() + } + } + + @Test + fun whenIClickAddAuthenticatorApp_thenIExpectAddAuthenticatorAppClickedEvent() { + setContent(AuthenticatorAppsState()) + + composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.BUTTON_ADD) + .assertIsDisplayed() + .performClick() + + assertThat(events).contains(AuthenticatorAppsEvent.AddAuthenticatorAppClicked) + } + + @Test + fun givenApps_whenIClickRenameInTheMenu_thenIExpectRenameAppClickedEvent() { + setContent(AuthenticatorAppsState(apps = APPS)) + + scrollTo(AuthenticatorAppsTestTags.ROW_APP) + composeTestRule.onAllNodesWithTag(AuthenticatorAppsTestTags.BUTTON_APP_MENU)[0].performClick() + composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.MENU_ITEM_RENAME).performClick() + + assertThat(events).contains(AuthenticatorAppsEvent.RenameAppClicked(appId = APPS[0].id)) + } + + @Test + fun givenApps_whenIClickRemoveInTheMenu_thenIExpectRemoveAppClickedEvent() { + setContent(AuthenticatorAppsState(apps = APPS)) + + scrollTo(AuthenticatorAppsTestTags.ROW_APP) + composeTestRule.onAllNodesWithTag(AuthenticatorAppsTestTags.BUTTON_APP_MENU)[0].performClick() + composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.MENU_ITEM_REMOVE).performClick() + + assertThat(events).contains(AuthenticatorAppsEvent.RemoveAppClicked(appId = APPS[0].id)) + } + + @Test + fun givenTheConfirmRemoveDialog_whenIDisplayScreen_thenIExpectTheDialog() { + setContent(AuthenticatorAppsState(apps = APPS, dialog = Dialog.ConfirmRemove(APPS[0].id))) + + composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.DIALOG_CONFIRM_REMOVE).assertIsDisplayed() + } + + @Test + fun givenTheConfirmRemoveDialog_whenICancel_thenIExpectDialogDismissedEvent() { + setContent(AuthenticatorAppsState(apps = APPS, dialog = Dialog.ConfirmRemove(APPS[0].id))) + + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick() + + assertThat(events).contains(AuthenticatorAppsEvent.DialogDismissed) + } + + @Test + fun givenTheMaxAppsDialog_whenIDisplayScreen_thenIExpectTheDialog() { + setContent(AuthenticatorAppsState(apps = APPS, dialog = Dialog.MaxAppsReached)) + + composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.DIALOG_MAX_APPS_REACHED).assertIsDisplayed() + } + + @Test + fun givenTheMaxAppsDialog_whenIClickLearnMore_thenIExpectLearnMoreAndDismissEvents() { + setContent(AuthenticatorAppsState(apps = APPS, dialog = Dialog.MaxAppsReached)) + + composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick() + + assertThat(events).contains(AuthenticatorAppsEvent.LearnMoreClicked) + assertThat(events).contains(AuthenticatorAppsEvent.DialogDismissed) + } + + private fun setContent(state: AuthenticatorAppsState) { + composeTestRule.setContent { + AuthenticatorAppsScreen( + state = state, + onEvent = { events += it } + ) + } + } + + private fun scrollTo(testTag: String) { + composeTestRule.onNodeWithTag(AuthenticatorAppsTestTags.SCROLLER) + .performScrollToNode(hasTestTag(testTag)) + } +} diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/authenticatorname/AuthenticatorNameScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/authenticatorname/AuthenticatorNameScreenTest.kt new file mode 100644 index 0000000000..a7d73b53e8 --- /dev/null +++ b/feature/app-settings/src/test/java/org/signal/appsettings/authenticatorname/AuthenticatorNameScreenTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorname + +import android.app.Application +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +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 AuthenticatorNameScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val events = mutableListOf() + + @Test + fun whenITypeAName_thenIExpectNameChangedEvent() { + setContent(AuthenticatorNameState()) + + composeTestRule.onNodeWithTag(AuthenticatorNameTestTags.NAME_INPUT) + .assertIsDisplayed() + .performTextReplacement("Bitwarden Authenticator") + + assertThat(events).contains(AuthenticatorNameEvent.NameChanged("Bitwarden Authenticator")) + } + + @Test + fun givenABlankName_whenIDisplayScreen_thenIExpectNextDisabled() { + setContent(AuthenticatorNameState()) + + composeTestRule.onNodeWithTag(AuthenticatorNameTestTags.BUTTON_NEXT).assertIsNotEnabled() + } + + @Test + fun givenASubmittingState_whenIDisplayScreen_thenIExpectNextDisabled() { + setContent(AuthenticatorNameState(name = "Twilio Authy", submitting = true)) + + composeTestRule.onNodeWithTag(AuthenticatorNameTestTags.BUTTON_NEXT).assertIsNotEnabled() + } + + @Test + fun givenAName_whenIClickNext_thenIExpectNextClickedEvent() { + setContent(AuthenticatorNameState(name = "Twilio Authy")) + + composeTestRule.onNodeWithTag(AuthenticatorNameTestTags.BUTTON_NEXT) + .assertIsEnabled() + .performClick() + + assertThat(events).contains(AuthenticatorNameEvent.NextClicked) + } + + private fun setContent(state: AuthenticatorNameState) { + composeTestRule.setContent { + AuthenticatorNameScreen( + state = state, + onEvent = { events += it } + ) + } + } +}