Update TOTP/Passkey management UX.

This commit is contained in:
Greyson Parrelli
2026-09-09 16:37:43 -04:00
committed by Cody Henthorne
parent 141cd31070
commit 03256a7ceb
42 changed files with 933 additions and 2292 deletions
@@ -24,12 +24,15 @@ import org.signal.appsettings.account.AccountSettingsScreen
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.util.ServiceUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpNavArgs
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.signal.appsettings.R as AppSettingsR
/**
* Account settings shown on a primary device. Carries out the [AccountSettingsAction]s that need an Activity or the
@@ -37,6 +40,10 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
*/
class AccountSettingsFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(AccountSettingsFragment::class)
}
private val viewModel: AccountSettingsViewModel by viewModels()
private lateinit var pinFlowLauncher: ActivityResultLauncher<Intent>
@@ -75,8 +82,17 @@ class AccountSettingsFragment : ComposeFragment() {
AccountSettingsAction.LaunchChangePinFlow -> pinFlowLauncher.launch(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()))
AccountSettingsAction.ShowPinCreatedConfirmation -> Snackbar.make(requireView(), R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show()
AccountSettingsAction.NavigateToSignalLoginDetails -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_signalLoginViewDetailsFragment)
AccountSettingsAction.NavigateToTotpAppList -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_authenticatorAppsFragment)
AccountSettingsAction.NavigateToPasskeys -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_passkeysFragment)
AccountSettingsAction.NavigateToTotpSetup -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_authenticatorSetupFragment)
is AccountSettingsAction.NavigateToRenameTotpApp -> {
findNavController().safeNavigate(
R.id.action_accountSettingsFragment_to_authenticatorNameFragment,
Bundle().apply { TotpNavArgs.putRenamedApp(this, action.app) }
)
}
AccountSettingsAction.ShowTotpAppRemoved -> toast(AppSettingsR.string.AccountSettingsFragment__authenticator_app_removed)
AccountSettingsAction.ShowTotpAppRemovalFailed -> toast(AppSettingsR.string.AccountSettingsFragment__couldnt_remove_authenticator_app)
// TODO Open the two-factor authentication support article once one exists.
AccountSettingsAction.OpenLearnMore -> Log.w(TAG, "There's no support article to open yet.")
AccountSettingsAction.NavigateToAdvancedPinSettings -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_advancedPinSettingsActivity)
AccountSettingsAction.NavigateToChangePhoneNumber -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment)
AccountSettingsAction.NavigateToDeviceTransfer -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity)
@@ -6,6 +6,8 @@
package org.thoughtcrime.securesms.components.settings.app.account
import kotlinx.coroutines.withContext
import org.signal.appsettings.account.TwoFactorMethod
import org.signal.appsettings.totp.TotpApp
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpRepository
@@ -48,14 +50,31 @@ class AccountSettingsRepository {
fun isPhoneNumberless(): Boolean = SignalStore.account.isPhoneNumberless
fun getMaxTotpApps(): Int = totpRepository.getMaxApps()
/**
* How many authenticator apps are on the account, or null if we couldn't find out.
* Every second factor on the account, authenticator apps first, or a failure if we couldn't find out. Passkeys are
* mocked for now, so only the authenticator apps can actually fail to load.
*/
suspend fun getTotpAppCount(): Int? {
return (totpRepository.getTotpApps() as? TotpRepository.AppsResult.Success)?.apps?.size
suspend fun getTwoFactorMethods(): TwoFactorMethodsResult {
val apps = when (val result = totpRepository.getTotpApps()) {
is TotpRepository.AppsResult.Success -> result.apps
TotpRepository.AppsResult.NetworkFailure -> return TwoFactorMethodsResult.NetworkFailure
}
return TwoFactorMethodsResult.Success(apps.map { it.toTwoFactorMethod() } + passkeysRepository.getPasskeys())
}
fun getPasskeyCount(): Int = passkeysRepository.getPasskeys().size
/**
* Removes an authenticator app from the account, returning whether it's gone. An app the service has already
* forgotten counts as gone, since that's the outcome the user asked for.
*/
suspend fun removeTotpApp(appId: Long): Boolean {
return when (totpRepository.removeTotpApp(appId)) {
TotpRepository.UpdateResult.Success, TotpRepository.UpdateResult.AppNotFound -> true
TotpRepository.UpdateResult.NetworkFailure -> false
}
}
fun verifyLocalPin(pin: String): Boolean {
val localPinHash = SignalStore.svr.localPinHash
@@ -83,4 +102,14 @@ class AccountSettingsRepository {
false
}
}
private fun TotpApp.toTwoFactorMethod(): TwoFactorMethod {
return TwoFactorMethod(id = id, kind = TwoFactorMethod.Kind.AUTHENTICATOR_APP, name = name, createdAt = createdAt)
}
sealed interface TwoFactorMethodsResult {
data class Success(val methods: List<TwoFactorMethod>) : TwoFactorMethodsResult
data object NetworkFailure : TwoFactorMethodsResult
}
}
@@ -18,6 +18,10 @@ import org.signal.appsettings.account.AccountSettingsAction
import org.signal.appsettings.account.AccountSettingsEvent
import org.signal.appsettings.account.AccountSettingsState
import org.signal.appsettings.account.AccountSettingsState.Dialog
import org.signal.appsettings.account.AccountSettingsState.LoadState
import org.signal.appsettings.account.AccountSettingsState.SignalLogin
import org.signal.appsettings.account.TwoFactorMethod
import org.signal.appsettings.totp.TotpApp
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
@@ -61,13 +65,7 @@ class AccountSettingsViewModel(
_actions.send(AccountSettingsAction.ShowPinCreatedConfirmation)
}
is AccountSettingsEvent.PinRemindersToggled -> {
if (event.enabled) {
repository.setPinRemindersEnabled(true)
refresh()
} else {
val keyboardType = repository.getPinKeyboardType()
_state.update { it.copy(dialog = Dialog.ConfirmPinToDisableReminders(isAlphanumericKeyboard = keyboardType == PinKeyboardType.ALPHA_NUMERIC)) }
}
applyPinRemindersToggled(event.enabled)
}
is AccountSettingsEvent.PinEntryChanged -> {
updatePinDialog { it.copy(pin = event.pin, incorrectPin = false, canSubmit = canSubmit(event.pin)) }
@@ -76,41 +74,31 @@ class AccountSettingsViewModel(
updatePinDialog { it.copy(pin = "", isAlphanumericKeyboard = !it.isAlphanumericKeyboard, incorrectPin = false, canSubmit = false) }
}
AccountSettingsEvent.DisablePinRemindersConfirmed -> {
val dialog = _state.value.dialog as? Dialog.ConfirmPinToDisableReminders ?: return
if (repository.verifyLocalPin(dialog.pin)) {
repository.setPinRemindersEnabled(false)
_state.update { it.copy(dialog = Dialog.None) }
refresh()
} else {
updatePinDialog { it.copy(incorrectPin = true) }
}
applyDisablePinRemindersConfirmed()
}
is AccountSettingsEvent.RegistrationLockToggled -> {
_state.update { it.copy(dialog = Dialog.ConfirmRegistrationLock(enable = event.enabled)) }
}
AccountSettingsEvent.RegistrationLockConfirmed -> {
val dialog = _state.value.dialog as? Dialog.ConfirmRegistrationLock ?: return
_state.update { it.copy(dialog = dialog.copy(inProgress = true)) }
val success = repository.setRegistrationLockEnabled(dialog.enable)
_state.update { it.copy(dialog = Dialog.None) }
refresh()
if (!success) {
_actions.send(
if (dialog.enable) AccountSettingsAction.ShowRegistrationLockEnableFailed else AccountSettingsAction.ShowRegistrationLockDisableFailed
)
}
applyRegistrationLockConfirmed()
}
AccountSettingsEvent.AccountAndRecoveryClicked -> {
_actions.send(AccountSettingsAction.NavigateToSignalLoginDetails)
}
AccountSettingsEvent.TotpAppClicked -> {
_actions.send(AccountSettingsAction.NavigateToTotpAppList)
AccountSettingsEvent.AddTotpAppClicked -> {
applyAddTotpAppClicked()
}
AccountSettingsEvent.PasskeysClicked -> {
_actions.send(AccountSettingsAction.NavigateToPasskeys)
AccountSettingsEvent.LearnMoreClicked -> {
_actions.send(AccountSettingsAction.OpenLearnMore)
}
is AccountSettingsEvent.RenameMethodClicked -> {
applyRenameMethodClicked(event.method)
}
is AccountSettingsEvent.RemoveMethodClicked -> {
applyRemoveMethodClicked(event.method)
}
AccountSettingsEvent.RemoveTotpAppConfirmed -> {
applyRemoveTotpAppConfirmed()
}
AccountSettingsEvent.AdvancedPinSettingsClicked -> {
_actions.send(AccountSettingsAction.NavigateToAdvancedPinSettings)
@@ -149,9 +137,93 @@ class AccountSettingsViewModel(
}
}
private suspend fun applyPinRemindersToggled(enabled: Boolean) {
if (enabled) {
repository.setPinRemindersEnabled(true)
refresh()
} else {
val keyboardType = repository.getPinKeyboardType()
_state.update { it.copy(dialog = Dialog.ConfirmPinToDisableReminders(isAlphanumericKeyboard = keyboardType == PinKeyboardType.ALPHA_NUMERIC)) }
}
}
private suspend fun applyDisablePinRemindersConfirmed() {
val dialog = _state.value.dialog as? Dialog.ConfirmPinToDisableReminders ?: return
if (repository.verifyLocalPin(dialog.pin)) {
repository.setPinRemindersEnabled(false)
_state.update { it.copy(dialog = Dialog.None) }
refresh()
} else {
updatePinDialog { it.copy(incorrectPin = true) }
}
}
private suspend fun applyRegistrationLockConfirmed() {
val dialog = _state.value.dialog as? Dialog.ConfirmRegistrationLock ?: return
_state.update { it.copy(dialog = dialog.copy(inProgress = true)) }
val success = repository.setRegistrationLockEnabled(dialog.enable)
_state.update { it.copy(dialog = Dialog.None) }
refresh()
if (!success) {
_actions.send(
if (dialog.enable) AccountSettingsAction.ShowRegistrationLockEnableFailed else AccountSettingsAction.ShowRegistrationLockDisableFailed
)
}
}
private suspend fun applyAddTotpAppClicked() {
if (_state.value.signalLogin?.atMaxTotpApps == true) {
_state.update { it.copy(dialog = Dialog.MaxTotpAppsReached) }
} else {
_actions.send(AccountSettingsAction.NavigateToTotpSetup)
}
}
private suspend fun applyRenameMethodClicked(method: TwoFactorMethod) {
when (method.kind) {
TwoFactorMethod.Kind.AUTHENTICATOR_APP -> {
val app = TotpApp(id = method.id, name = method.name, createdAt = method.createdAt)
_actions.send(AccountSettingsAction.NavigateToRenameTotpApp(app))
}
TwoFactorMethod.Kind.PASSKEY -> {
Log.w(TAG, "Passkey renaming isn't implemented yet.")
}
}
}
private fun applyRemoveMethodClicked(method: TwoFactorMethod) {
when (method.kind) {
TwoFactorMethod.Kind.AUTHENTICATOR_APP -> {
_state.update { it.copy(dialog = Dialog.ConfirmRemoveTotpApp(method.id)) }
}
TwoFactorMethod.Kind.PASSKEY -> {
Log.w(TAG, "Passkey removal isn't implemented yet.")
}
}
}
private suspend fun applyRemoveTotpAppConfirmed() {
val dialog = _state.value.dialog as? Dialog.ConfirmRemoveTotpApp ?: return
_state.update { it.copy(dialog = Dialog.None) }
removeTotpApp(dialog.appId)
}
private suspend fun removeTotpApp(appId: Long) {
if (repository.removeTotpApp(appId)) {
_actions.send(AccountSettingsAction.ShowTotpAppRemoved)
refreshTwoFactorMethods()
} else {
Log.w(TAG, "Couldn't remove the authenticator app. Leaving it in the list, where it still is.")
_actions.send(AccountSettingsAction.ShowTotpAppRemovalFailed)
}
}
private suspend fun refresh() {
val isPhoneNumberless = repository.isPhoneNumberless()
val totpAppCount = repository.getTotpAppCount()
_state.update {
it.copy(
@@ -162,16 +234,29 @@ class AccountSettingsViewModel(
userUnregistered = repository.isUserUnregistered(),
clientDeprecated = repository.isClientDeprecated(),
isPhoneNumberless = isPhoneNumberless,
signalLogin = if (isPhoneNumberless) {
AccountSettingsState.SignalLogin(
totpAppCount = totpAppCount,
passkeyCount = repository.getPasskeyCount()
)
} else {
null
}
// Held onto across refreshes so a resume doesn't drop the list back to its loading state.
signalLogin = if (isPhoneNumberless) it.signalLogin ?: SignalLogin(maxTotpApps = repository.getMaxTotpApps()) else null
)
}
if (isPhoneNumberless) {
refreshTwoFactorMethods()
}
}
private suspend fun refreshTwoFactorMethods() {
val (methods, loadState) = when (val result = repository.getTwoFactorMethods()) {
is AccountSettingsRepository.TwoFactorMethodsResult.Success -> result.methods to LoadState.LOADED
AccountSettingsRepository.TwoFactorMethodsResult.NetworkFailure -> {
Log.w(TAG, "Couldn't reach the service to list the account's second factors.")
emptyList<TwoFactorMethod>() to LoadState.NETWORK_FAILURE
}
}
_state.update { state ->
val signalLogin = state.signalLogin ?: return@update state
state.copy(signalLogin = signalLogin.copy(twoFactorMethods = methods, loadState = loadState))
}
}
private fun canSubmit(pin: String): Boolean = pin.length >= SvrConstants.MINIMUM_PIN_LENGTH
@@ -1,75 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
import android.os.Bundle
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import org.signal.appsettings.totpapplist.TotpAppListAction
import org.signal.appsettings.totpapplist.TotpAppListEvent
import org.signal.appsettings.totpapplist.TotpAppListScreen
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.signal.appsettings.R as AppSettingsR
/**
* Lists the authenticator apps on the account. Carries out the [TotpAppListAction]s that need the nav graph.
*/
class TotpAppListFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(TotpAppListFragment::class)
}
private val viewModel: TotpAppListViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
CollectActions(viewModel.actions) { action -> handleAction(action) }
TotpAppListScreen(
state = state,
onEvent = viewModel::onEvent
)
}
override fun onResume() {
super.onResume()
viewModel.onEvent(TotpAppListEvent.ScreenResumed)
}
private fun handleAction(action: TotpAppListAction) {
when (action) {
TotpAppListAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
TotpAppListAction.NavigateToSetup -> {
findNavController().safeNavigate(R.id.action_authenticatorAppsFragment_to_authenticatorSetupFragment)
}
is TotpAppListAction.NavigateToRename -> {
findNavController().safeNavigate(
R.id.action_authenticatorAppsFragment_to_authenticatorNameFragment,
Bundle().apply { TotpNavArgs.putRenamedApp(this, action.app) }
)
}
TotpAppListAction.ShowTotpAppRemoved -> toast(AppSettingsR.string.TotpAppListScreen__authenticator_app_removed)
TotpAppListAction.ShowRemovalFailed -> toast(AppSettingsR.string.TotpAppListScreen__couldnt_remove_authenticator_app)
TotpAppListAction.OpenLearnMore -> Log.w(TAG, "There's no support article to open yet.")
}
}
private fun toast(@StringRes message: Int) {
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
}
}
@@ -1,109 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.appsettings.totpapplist.TotpAppListAction
import org.signal.appsettings.totpapplist.TotpAppListEvent
import org.signal.appsettings.totpapplist.TotpAppListState
import org.signal.appsettings.totpapplist.TotpAppListState.Dialog
import org.signal.appsettings.totpapplist.TotpAppListState.LoadState
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
/**
* Drives the screen that lists the authenticator apps on the account.
*/
class TotpAppListViewModel(
private val repository: TotpRepository = TotpRepository()
) : EventDrivenViewModel<TotpAppListEvent>(TAG) {
companion object {
private val TAG = Log.tag(TotpAppListViewModel::class)
}
private val _state = MutableStateFlow(TotpAppListState(maxApps = repository.getMaxApps()))
private val _actions = Channel<TotpAppListAction>(Channel.BUFFERED)
val state: StateFlow<TotpAppListState> = _state.asStateFlow()
val actions: Flow<TotpAppListAction> = _actions.receiveAsFlow()
init {
viewModelScope.launch { refresh() }
}
override suspend fun processEvent(event: TotpAppListEvent) {
when (event) {
TotpAppListEvent.ScreenResumed -> {
refresh()
}
TotpAppListEvent.NavigateBackClicked -> {
_actions.send(TotpAppListAction.NavigateBack)
}
TotpAppListEvent.AddTotpAppClicked -> {
if (_state.value.atMaxApps) {
_state.update { it.copy(dialog = Dialog.MaxAppsReached) }
} else {
_actions.send(TotpAppListAction.NavigateToSetup)
}
}
TotpAppListEvent.LearnMoreClicked -> {
_actions.send(TotpAppListAction.OpenLearnMore)
}
is TotpAppListEvent.RenameAppClicked -> {
val app = _state.value.apps.firstOrNull { it.id == event.appId }
if (app == null) {
Log.w(TAG, "Asked to rename an app that isn't in the list.")
} else {
_actions.send(TotpAppListAction.NavigateToRename(app))
}
}
is TotpAppListEvent.RemoveAppClicked -> {
_state.update { it.copy(dialog = Dialog.ConfirmRemove(event.appId)) }
}
is TotpAppListEvent.RemoveAppConfirmed -> {
_state.update { it.copy(dialog = Dialog.None) }
removeApp(event.appId)
}
TotpAppListEvent.DialogDismissed -> {
_state.update { it.copy(dialog = Dialog.None) }
}
}
}
private suspend fun removeApp(appId: Long) {
when (repository.removeTotpApp(appId)) {
TotpRepository.UpdateResult.Success, TotpRepository.UpdateResult.AppNotFound -> {
_actions.send(TotpAppListAction.ShowTotpAppRemoved)
refresh()
}
TotpRepository.UpdateResult.NetworkFailure -> {
Log.w(TAG, "Couldn't remove the authenticator app. Leaving it in the list, where it still is.")
_actions.send(TotpAppListAction.ShowRemovalFailed)
}
}
}
private suspend fun refresh() {
when (val result = repository.getTotpApps()) {
is TotpRepository.AppsResult.Success -> {
_state.update { it.copy(apps = result.apps, loadState = LoadState.LOADED) }
}
TotpRepository.AppsResult.NetworkFailure -> {
Log.w(TAG, "Couldn't reach the service to list authenticator apps.")
_state.update { it.copy(apps = emptyList(), loadState = LoadState.NETWORK_FAILURE) }
}
}
}
}
@@ -49,7 +49,7 @@ class TotpNameEntryFragment : ComposeFragment() {
private fun handleAction(action: TotpNameEntryAction) {
when (action) {
TotpNameEntryAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
TotpNameEntryAction.NavigateToTotpAppList -> findNavController().popBackStack(R.id.authenticatorAppsFragment, false)
TotpNameEntryAction.NavigateToAccountSettings -> findNavController().popBackStack(R.id.accountSettingsFragment, false)
TotpNameEntryAction.ShowTotpAppSetUp -> toast(AppSettingsR.string.TotpNameEntryScreen__authenticator_app_set_up)
TotpNameEntryAction.ShowTotpAppRenamed -> toast(AppSettingsR.string.TotpNameEntryScreen__authenticator_app_renamed)
TotpNameEntryAction.ShowNameNotSaved -> toast(AppSettingsR.string.TotpNameEntryScreen__couldnt_save_name)
@@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import org.signal.appsettings.totpapplist.TotpApp
import org.signal.appsettings.totp.TotpApp
import org.signal.appsettings.totpnameentry.TotpNameEntryAction
import org.signal.appsettings.totpnameentry.TotpNameEntryEvent
import org.signal.appsettings.totpnameentry.TotpNameEntryState
@@ -72,11 +72,11 @@ class TotpNameEntryViewModel(
when (result) {
TotpRepository.UpdateResult.Success -> {
_actions.send(if (renamedApp != null) TotpNameEntryAction.ShowTotpAppRenamed else TotpNameEntryAction.ShowTotpAppSetUp)
_actions.send(TotpNameEntryAction.NavigateToTotpAppList)
_actions.send(TotpNameEntryAction.NavigateToAccountSettings)
}
TotpRepository.UpdateResult.AppNotFound -> {
Log.w(TAG, "Asked to name an app the service doesn't have. Going back to the list rather than stranding the user here.")
_actions.send(TotpNameEntryAction.NavigateToTotpAppList)
Log.w(TAG, "Asked to name an app the service doesn't have. Going back to account settings rather than stranding the user here.")
_actions.send(TotpNameEntryAction.NavigateToAccountSettings)
}
TotpRepository.UpdateResult.NetworkFailure -> {
_state.update { it.copy(submitting = false) }
@@ -6,7 +6,7 @@
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
import android.os.Bundle
import org.signal.appsettings.totpapplist.TotpApp
import org.signal.appsettings.totp.TotpApp
/**
* The nav arguments the authenticator app screens pass between each other, and the parsing that turns them back into
@@ -5,7 +5,7 @@
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
import org.signal.appsettings.totpapplist.TotpApp
import org.signal.appsettings.totp.TotpApp
import org.signal.core.models.MasterKey
import org.signal.core.util.Base32
import org.signal.core.util.logging.Log
@@ -5,21 +5,18 @@
package org.thoughtcrime.securesms.components.settings.app.account.passkeys
import org.signal.appsettings.passkeys.Passkey
import org.signal.appsettings.passkeys.PasskeysRepository
import org.signal.appsettings.account.TwoFactorMethod
/**
* Stand-in for wherever passkeys will eventually be read from. Nothing is fetched from the service yet, so the
* passkeys are mocked.
* Stand-in for wherever passkeys will eventually be read from. Nothing is fetched from the service yet, so there are
* never any passkeys.
*/
class AppPasskeysRepository : PasskeysRepository {
class AppPasskeysRepository {
companion object {
private val MOCK_PASSKEYS = listOf(
Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()),
Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis())
)
/** Empty so nothing fake reaches a real account. Fill it in locally to see passkey rows while testing. */
private val MOCK_PASSKEYS = emptyList<TwoFactorMethod>()
}
override fun getPasskeys(): List<Passkey> = MOCK_PASSKEYS
fun getPasskeys(): List<TwoFactorMethod> = MOCK_PASSKEYS
}
@@ -1,50 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.passkeys
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.appsettings.passkeys.PasskeysAction
import org.signal.appsettings.passkeys.PasskeysScreen
import org.signal.appsettings.passkeys.PasskeysViewModel
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.util.viewModel
/**
* Explains passkeys and lets the user start creating one. Carries out the [PasskeysAction]s that need an Activity or
* the nav graph.
*/
class PasskeysFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(PasskeysFragment::class)
}
private val viewModel: PasskeysViewModel by viewModel { PasskeysViewModel(AppPasskeysRepository()) }
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
CollectActions(viewModel.actions) { action -> handleAction(action) }
PasskeysScreen(
state = state,
onEvent = viewModel::onEvent
)
}
private fun handleAction(action: PasskeysAction) {
when (action) {
PasskeysAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
PasskeysAction.LaunchPasskeyCreation -> Log.w(TAG, "Passkey creation isn't implemented yet.")
PasskeysAction.OpenLearnMore -> Log.w(TAG, "There's no support article to open yet.")
}
}
}
@@ -224,15 +224,15 @@
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_accountSettingsFragment_to_authenticatorAppsFragment"
app:destination="@id/authenticatorAppsFragment"
android:id="@+id/action_accountSettingsFragment_to_authenticatorSetupFragment"
app:destination="@id/authenticatorSetupFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_accountSettingsFragment_to_passkeysFragment"
app:destination="@id/passkeysFragment"
android:id="@+id/action_accountSettingsFragment_to_authenticatorNameFragment"
app:destination="@id/authenticatorNameFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
@@ -244,26 +244,6 @@
android:name="org.thoughtcrime.securesms.components.settings.app.account.signallogin.SignalLoginViewDetailsFragment"
android:label="signal_login_view_details_fragment" />
<fragment
android:id="@+id/authenticatorAppsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpAppListFragment"
android:label="authenticator_apps_fragment">
<action
android:id="@+id/action_authenticatorAppsFragment_to_authenticatorSetupFragment"
app:destination="@id/authenticatorSetupFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_authenticatorAppsFragment_to_authenticatorNameFragment"
app:destination="@id/authenticatorNameFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/authenticatorSetupFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpSetupFragment"
@@ -311,11 +291,6 @@
app:argType="long" />
</fragment>
<fragment
android:id="@+id/passkeysFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.passkeys.PasskeysFragment"
android:label="passkeys_fragment" />
<fragment
android:id="@+id/linkedDeviceAccountSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.LinkedDeviceAccountSettingsFragment"
@@ -6,6 +6,8 @@
package org.thoughtcrime.securesms.components.settings.app.account
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
@@ -18,6 +20,7 @@ import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
@@ -33,6 +36,9 @@ import org.junit.Test
import org.signal.appsettings.account.AccountSettingsAction
import org.signal.appsettings.account.AccountSettingsEvent
import org.signal.appsettings.account.AccountSettingsState.Dialog
import org.signal.appsettings.account.AccountSettingsState.LoadState
import org.signal.appsettings.account.TwoFactorMethod
import org.signal.appsettings.totp.TotpApp
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@@ -42,6 +48,10 @@ class AccountSettingsViewModelTest {
companion object {
private const val CORRECT_PIN = "1234"
private const val INCORRECT_PIN = "9999"
private val TOTP_APP = TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.AUTHENTICATOR_APP, name = "Bitwarden Authenticator", createdAt = 0)
private val OTHER_TOTP_APP = TwoFactorMethod(id = 2, kind = TwoFactorMethod.Kind.AUTHENTICATOR_APP, name = "Twilio Authy", createdAt = 0)
private val PASSKEY = TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.PASSKEY, name = "Pixel Phone", createdAt = 0)
}
private val testDispatcher = UnconfinedTestDispatcher()
@@ -63,8 +73,9 @@ class AccountSettingsViewModelTest {
every { repository.isClientDeprecated() } returns false
every { repository.getPinKeyboardType() } returns PinKeyboardType.NUMERIC
every { repository.isPhoneNumberless() } returns false
coEvery { repository.getTotpAppCount() } returns 0
every { repository.getPasskeyCount() } returns 0
every { repository.getMaxTotpApps() } returns 2
coEvery { repository.getTwoFactorMethods() } returns AccountSettingsRepository.TwoFactorMethodsResult.Success(emptyList())
coEvery { repository.removeTotpApp(any()) } returns true
every { repository.verifyLocalPin(any()) } answers { firstArg<String>() == CORRECT_PIN }
coEvery { repository.setRegistrationLockEnabled(any()) } returns true
}
@@ -303,25 +314,206 @@ class AccountSettingsViewModelTest {
@Test
fun `the Signal Login section is filled in when the account is phone-numberless`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTotpAppCount() } returns 2
every { repository.getPasskeyCount() } returns 8
coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP, PASSKEY)
val viewModel = createViewModel()
assertThat(viewModel.state.value.isPhoneNumberless).isTrue()
assertThat(viewModel.state.value.signalLogin?.totpAppCount).isEqualTo(2)
assertThat(viewModel.state.value.signalLogin?.passkeyCount).isEqualTo(8)
assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).containsExactly(TOTP_APP, PASSKEY)
assertThat(viewModel.state.value.signalLogin?.loadState).isEqualTo(LoadState.LOADED)
assertThat(viewModel.state.value.signalLogin?.maxTotpApps).isEqualTo(2)
}
/** Zero would render as "no authenticator apps", which is a claim we can't make when we couldn't reach the service. */
/** An empty list says nothing on its own, so the screen leans on the load state to know we haven't heard back yet. */
@Test
fun `a count we couldn't fetch is null rather than zero`() = runTest(testDispatcher) {
fun `the two-factor list is LOADING until we've heard back about the account`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTotpAppCount() } returns null
coEvery { repository.getTwoFactorMethods() } coAnswers { awaitCancellation() }
val viewModel = createViewModel()
assertThat(viewModel.state.value.signalLogin?.totpAppCount).isNull()
assertThat(viewModel.state.value.signalLogin?.loadState).isEqualTo(LoadState.LOADING)
assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).isEmpty()
}
/** An account we couldn't ask about is not an account with no second factors. */
@Test
fun `a service we couldn't reach clears the two-factor list and says so`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTwoFactorMethods() } returns AccountSettingsRepository.TwoFactorMethodsResult.NetworkFailure
val viewModel = createViewModel()
assertThat(viewModel.state.value.signalLogin?.loadState).isEqualTo(LoadState.NETWORK_FAILURE)
assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).isEmpty()
}
@Test
fun `ScreenResumed picks up second factors added elsewhere`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
val viewModel = createViewModel()
coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP)
viewModel.onEvent(AccountSettingsEvent.ScreenResumed)
assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).containsExactly(TOTP_APP)
}
@Test
fun `AddTotpAppClicked opens setup when there's room for another app`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP)
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.AddTotpAppClicked)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToTotpSetup)
}
/** Passkeys share the list but not the limit, so they can't be what stops another app from being added. */
@Test
fun `AddTotpAppClicked explains the limit when there's no room for another app`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP, OTHER_TOTP_APP, PASSKEY)
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.AddTotpAppClicked)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.MaxTotpAppsReached)
assertThat(actions).isEmpty()
}
@Test
fun `RenameMethodClicked opens the naming screen for that app`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP)
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.RenameMethodClicked(TOTP_APP))
val expected = TotpApp(id = TOTP_APP.id, name = TOTP_APP.name, createdAt = TOTP_APP.createdAt)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToRenameTotpApp(expected))
}
/** Ids only mean anything within a kind, so a passkey sharing an id with an app must not be mistaken for it. */
@Test
fun `RenameMethodClicked for an unsupported passkey does nothing`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP, PASSKEY)
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.RenameMethodClicked(PASSKEY))
assertThat(actions).isEmpty()
}
@Test
fun `RemoveMethodClicked asks the user to confirm first`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP)
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.RemoveMethodClicked(TOTP_APP))
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmRemoveTotpApp(TOTP_APP.id))
assertThat(actions).isEmpty()
}
@Test
fun `RemoveMethodClicked for an unsupported passkey does nothing`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTwoFactorMethods() } returns methods(PASSKEY)
val viewModel = createViewModel()
viewModel.onEvent(AccountSettingsEvent.RemoveMethodClicked(PASSKEY))
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
}
@Test
fun `RemoveTotpAppConfirmed removes the app and says so`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP)
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.RemoveMethodClicked(TOTP_APP))
viewModel.onEvent(AccountSettingsEvent.RemoveTotpAppConfirmed)
coVerify { repository.removeTotpApp(TOTP_APP.id) }
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.ShowTotpAppRemoved)
}
/** The open dialog is what says which app is being removed, so a confirmation without one has no app to act on. */
@Test
fun `RemoveTotpAppConfirmed without the confirmation dialog removes nothing`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP)
val viewModel = createViewModel()
viewModel.onEvent(AccountSettingsEvent.RemoveTotpAppConfirmed)
coVerify(exactly = 0) { repository.removeTotpApp(any()) }
assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).containsExactly(TOTP_APP)
}
/** The list is what tells the user the app is gone, so it has to be read again rather than assumed. */
@Test
fun `a removal re-reads the list`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP)
val viewModel = createViewModel()
viewModel.onEvent(AccountSettingsEvent.RemoveMethodClicked(TOTP_APP))
coEvery { repository.getTwoFactorMethods() } returns methods()
viewModel.onEvent(AccountSettingsEvent.RemoveTotpAppConfirmed)
assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).isEmpty()
}
@Test
fun `a removal that didn't go through says so rather than pretending the app is gone`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTwoFactorMethods() } returns methods(TOTP_APP)
coEvery { repository.removeTotpApp(any()) } returns false
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.RemoveMethodClicked(TOTP_APP))
viewModel.onEvent(AccountSettingsEvent.RemoveTotpAppConfirmed)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.ShowTotpAppRemovalFailed)
assertThat(viewModel.state.value.signalLogin!!.twoFactorMethods).containsExactly(TOTP_APP)
}
@Test
fun `LearnMoreClicked opens the support article`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.LearnMoreClicked)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.OpenLearnMore)
}
@Test
@@ -336,29 +528,7 @@ class AccountSettingsViewModelTest {
assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToSignalLoginDetails)
}
@Test
fun `TotpAppClicked opens the authenticator apps screen`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.TotpAppClicked)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToTotpAppList)
}
@Test
fun `PasskeysClicked opens the passkeys screen`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.PasskeysClicked)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToPasskeys)
}
private fun methods(vararg methods: TwoFactorMethod) = AccountSettingsRepository.TwoFactorMethodsResult.Success(methods.toList())
private fun createViewModel(): AccountSettingsViewModel = AccountSettingsViewModel(repository)
@@ -1,280 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.appsettings.totpapplist.TotpApp
import org.signal.appsettings.totpapplist.TotpAppListAction
import org.signal.appsettings.totpapplist.TotpAppListEvent
import org.signal.appsettings.totpapplist.TotpAppListState.Dialog
import org.signal.appsettings.totpapplist.TotpAppListState.LoadState
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@OptIn(ExperimentalCoroutinesApi::class)
class TotpAppListViewModelTest {
companion object {
private val APP_ONE = TotpApp(id = 1, name = "Bitwarden Authenticator", createdAt = 0)
private val APP_TWO = TotpApp(id = 2, name = "Twilio Authy", createdAt = 0)
}
private val testDispatcher = UnconfinedTestDispatcher()
private val repository: TotpRepository = mockk(relaxed = true)
@get:Rule
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { repository.getMaxApps() } returns 2
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(emptyList())
coEvery { repository.removeTotpApp(any()) } returns TotpRepository.UpdateResult.Success
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `the configured apps are read on creation`() = runTest(testDispatcher) {
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
val viewModel = createViewModel()
assertThat(viewModel.state.value.apps).containsExactly(APP_ONE)
}
/** An empty list says nothing on its own, so the screen leans on [LoadState] to know we haven't heard back yet. */
@Test
fun `the state is LOADING until we've heard back about the account`() = runTest(testDispatcher) {
coEvery { repository.getTotpApps() } coAnswers { awaitCancellation() }
val viewModel = createViewModel()
assertThat(viewModel.state.value.loadState).isEqualTo(LoadState.LOADING)
assertThat(viewModel.state.value.apps).isEmpty()
}
@Test
fun `a service we couldn't reach clears the list and says so`() = runTest(testDispatcher) {
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.NetworkFailure
val viewModel = createViewModel()
assertThat(viewModel.state.value.loadState).isEqualTo(LoadState.NETWORK_FAILURE)
assertThat(viewModel.state.value.apps).isEmpty()
}
@Test
fun `a load that succeeds after one that failed clears the failure`() = runTest(testDispatcher) {
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.NetworkFailure
val viewModel = createViewModel()
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
viewModel.onEvent(TotpAppListEvent.ScreenResumed)
assertThat(viewModel.state.value.loadState).isEqualTo(LoadState.LOADED)
assertThat(viewModel.state.value.apps).containsExactly(APP_ONE)
}
@Test
fun `ScreenResumed picks up apps added elsewhere`() = runTest(testDispatcher) {
val viewModel = createViewModel()
assertThat(viewModel.state.value.apps).isEmpty()
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
viewModel.onEvent(TotpAppListEvent.ScreenResumed)
assertThat(viewModel.state.value.apps).containsExactly(APP_ONE)
}
@Test
fun `AddTotpAppClicked opens setup when there's room for another app`() = runTest(testDispatcher) {
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpAppListEvent.AddTotpAppClicked)
assertThat(actions.last()).isEqualTo(TotpAppListAction.NavigateToSetup)
}
@Test
fun `AddTotpAppClicked explains the limit when there's no room for another app`() = runTest(testDispatcher) {
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE, APP_TWO))
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpAppListEvent.AddTotpAppClicked)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.MaxAppsReached)
assertThat(actions).isEmpty()
}
@Test
fun `RenameAppClicked opens the naming screen for that app`() = runTest(testDispatcher) {
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpAppListEvent.RenameAppClicked(APP_ONE.id))
assertThat(actions.last()).isEqualTo(TotpAppListAction.NavigateToRename(APP_ONE))
}
@Test
fun `RemoveAppClicked asks the user to confirm first`() = runTest(testDispatcher) {
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpAppListEvent.RemoveAppClicked(APP_ONE.id))
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmRemove(APP_ONE.id))
assertThat(actions).isEmpty()
}
@Test
fun `RemoveAppConfirmed removes the app and says so`() = runTest(testDispatcher) {
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpAppListEvent.RemoveAppClicked(APP_ONE.id))
viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id))
coVerify { repository.removeTotpApp(APP_ONE.id) }
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
assertThat(actions.last()).isEqualTo(TotpAppListAction.ShowTotpAppRemoved)
}
/** The list is what tells the user the app is gone, so it has to be read again rather than assumed. */
@Test
fun `a removal re-reads the list`() = runTest(testDispatcher) {
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
val viewModel = createViewModel()
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(emptyList())
viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id))
assertThat(viewModel.state.value.apps).isEmpty()
}
/** Removing a key the service has already forgotten is the outcome the user wanted, so it isn't an error. */
@Test
fun `removing an app the service doesn't have still counts as removed`() = runTest(testDispatcher) {
coEvery { repository.removeTotpApp(any()) } returns TotpRepository.UpdateResult.AppNotFound
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id))
assertThat(actions.last()).isEqualTo(TotpAppListAction.ShowTotpAppRemoved)
}
@Test
fun `a removal that didn't go through says so rather than pretending the app is gone`() = runTest(testDispatcher) {
coEvery { repository.removeTotpApp(any()) } returns TotpRepository.UpdateResult.NetworkFailure
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id))
assertThat(actions.last()).isEqualTo(TotpAppListAction.ShowRemovalFailed)
}
/**
* What the confirm button actually does: the dialog dismisses itself before it reports the confirmation, so the
* removal has to survive arriving after the dialog is already gone.
*/
@Test
fun `RemoveAppConfirmed removes the app even though the dialog dismissed itself first`() = runTest(testDispatcher) {
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
val viewModel = createViewModel()
viewModel.onEvent(TotpAppListEvent.RemoveAppClicked(APP_ONE.id))
viewModel.onEvent(TotpAppListEvent.DialogDismissed)
viewModel.onEvent(TotpAppListEvent.RemoveAppConfirmed(APP_ONE.id))
coVerify { repository.removeTotpApp(APP_ONE.id) }
}
@Test
fun `DialogDismissed clears the dialog`() = runTest(testDispatcher) {
coEvery { repository.getTotpApps() } returns TotpRepository.AppsResult.Success(listOf(APP_ONE))
val viewModel = createViewModel()
viewModel.onEvent(TotpAppListEvent.RemoveAppClicked(APP_ONE.id))
viewModel.onEvent(TotpAppListEvent.DialogDismissed)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
}
@Test
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpAppListEvent.NavigateBackClicked)
assertThat(actions.last()).isEqualTo(TotpAppListAction.NavigateBack)
}
@Test
fun `LearnMoreClicked opens the support article`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpAppListEvent.LearnMoreClicked)
assertThat(actions.last()).isEqualTo(TotpAppListAction.OpenLearnMore)
}
private fun createViewModel() = TotpAppListViewModel(repository)
private fun TestScope.collectActions(actions: Flow<TotpAppListAction>): List<TotpAppListAction> {
val collected = mutableListOf<TotpAppListAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}
@@ -29,7 +29,7 @@ import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.appsettings.totpapplist.TotpApp
import org.signal.appsettings.totp.TotpApp
import org.signal.appsettings.totpnameentry.TotpNameEntryAction
import org.signal.appsettings.totpnameentry.TotpNameEntryEvent
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@@ -104,7 +104,7 @@ class TotpNameEntryViewModelTest {
coVerify { repository.nameNewTotpApp(NEW_APP_ID, "Bitwarden Authenticator") }
assertThat(actions).contains(TotpNameEntryAction.ShowTotpAppSetUp)
assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateToTotpAppList)
assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateToAccountSettings)
}
@Test
@@ -117,7 +117,7 @@ class TotpNameEntryViewModelTest {
coVerify { repository.renameTotpApp(EXISTING_APP, "Work Authenticator") }
assertThat(actions).contains(TotpNameEntryAction.ShowTotpAppRenamed)
assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateToTotpAppList)
assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateToAccountSettings)
}
@Test
@@ -14,7 +14,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.appsettings.totpapplist.TotpApp
import org.signal.appsettings.totp.TotpApp
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
@@ -19,7 +19,7 @@ import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.signal.appsettings.totpapplist.TotpApp
import org.signal.appsettings.totp.TotpApp
import org.signal.core.models.MasterKey
import org.signal.libsignal.net.ConfirmedMfaKey
import org.signal.libsignal.net.MfaKeyKind
@@ -5,6 +5,8 @@
package org.signal.appsettings.account
import org.signal.appsettings.totp.TotpApp
/**
* One-shot side effects that need an Activity or the legacy nav graph, and therefore have to be carried out by
* [AccountSettingsFragment] rather than the screen itself.
@@ -28,11 +30,20 @@ sealed interface AccountSettingsAction {
/** Open the screen that shows the account and recovery keys that make up the Signal Login. */
data object NavigateToSignalLoginDetails : AccountSettingsAction
/** Open the screen listing the account's authenticator apps. */
data object NavigateToTotpAppList : AccountSettingsAction
/** Open the flow that pairs a new authenticator app. */
data object NavigateToTotpSetup : AccountSettingsAction
/** Open the passkeys screen. */
data object NavigateToPasskeys : AccountSettingsAction
/** Open the screen that renames [app]. */
data class NavigateToRenameTotpApp(val app: TotpApp) : AccountSettingsAction
/** Tell the user their authenticator app was removed. */
data object ShowTotpAppRemoved : AccountSettingsAction
/** Tell the user the removal didn't go through, so they know the app is still on the account. */
data object ShowTotpAppRemovalFailed : AccountSettingsAction
/** Send the user to a support article about two-factor authentication. */
data object OpenLearnMore : AccountSettingsAction
/** Open the advanced PIN settings screen. */
data object NavigateToAdvancedPinSettings : AccountSettingsAction
@@ -45,11 +45,20 @@ sealed interface AccountSettingsEvent {
/** The user confirmed turning registration lock on or off. */
data object RegistrationLockConfirmed : AccountSettingsEvent
/** The user tapped the authenticator app row in the two-factor authentication section. */
data object TotpAppClicked : AccountSettingsEvent
/** The user tapped the authenticator app option in the two-factor set-up menu. */
data object AddTotpAppClicked : AccountSettingsEvent
/** The user tapped the passkeys row in the two-factor authentication section. */
data object PasskeysClicked : AccountSettingsEvent
/** The user tapped the learn more link on the dialog explaining the authenticator app limit. */
data object LearnMoreClicked : AccountSettingsEvent
/** The user tapped the rename option in [method]'s overflow menu. */
data class RenameMethodClicked(val method: TwoFactorMethod) : AccountSettingsEvent
/** The user tapped the remove option in [method]'s overflow menu, which asks them to confirm first. */
data class RemoveMethodClicked(val method: TwoFactorMethod) : AccountSettingsEvent
/** The user confirmed removing the authenticator app named by the open dialog, which removes it. */
data object RemoveTotpAppConfirmed : AccountSettingsEvent
/** The user tapped the advanced PIN settings row. */
data object AdvancedPinSettingsClicked : AccountSettingsEvent
@@ -5,21 +5,27 @@
package org.signal.appsettings.account
import android.text.format.DateUtils
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -32,10 +38,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -46,12 +52,15 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.signal.appsettings.R
import org.signal.appsettings.account.AccountSettingsState.Dialog
import org.signal.appsettings.account.AccountSettingsState.LoadState
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.PinVisualTransformation
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Rows.TextAndLabel
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Texts
@@ -62,8 +71,14 @@ import org.signal.core.ui.R as CoreUiR
object AccountSettingsTestTags {
const val SCROLLER = "scroller"
const val CARD_SIGNAL_LOGIN = "card-signal-login"
const val ROW_TOTP_APP = "row-totp-app"
const val ROW_PASSKEYS = "row-passkeys"
const val ROW_SET_UP_TWO_FACTOR = "row-set-up-two-factor"
const val MENU_ITEM_AUTHENTICATOR_APP = "menu-item-authenticator-app"
const val ROW_TWO_FACTOR_METHOD = "row-two-factor-method"
const val BUTTON_METHOD_MENU = "button-method-menu"
const val MENU_ITEM_RENAME = "menu-item-rename"
const val MENU_ITEM_REMOVE = "menu-item-remove"
const val TWO_FACTOR_LOADING = "two-factor-loading"
const val TWO_FACTOR_LOAD_FAILED_MESSAGE = "two-factor-load-failed-message"
const val ROW_MODIFY_PIN = "row-modify-pin"
const val ROW_PIN_REMINDER = "row-pin-reminder"
const val ROW_REGISTRATION_LOCK = "row-registration-lock"
@@ -78,6 +93,8 @@ object AccountSettingsTestTags {
const val DIALOG_CONFIRM_DELETE_ALL_DATA = "dialog-confirm-delete-all-data"
const val DIALOG_CONFIRM_PIN = "dialog-confirm-pin"
const val DIALOG_CONFIRM_REGISTRATION_LOCK = "dialog-confirm-registration-lock"
const val DIALOG_CONFIRM_REMOVE_TOTP_APP = "dialog-confirm-remove-totp-app"
const val DIALOG_MAX_TOTP_APPS_REACHED = "dialog-max-totp-apps-reached"
const val PIN_INPUT = "pin-input"
const val PIN_KEYBOARD_TOGGLE = "pin-keyboard-toggle"
}
@@ -125,34 +142,42 @@ fun AccountSettingsScreen(
}
item {
// A null count means we couldn't find out, which reads as the generic subtitle rather than as "none configured".
val totpAppCount = state.signalLogin.totpAppCount
Rows.TextRow(
icon = SignalIcons.DevicePhone.imageVector,
text = stringResource(R.string.AccountSettingsFragment__authenticator_app),
label = if (totpAppCount != null && totpAppCount > 0) {
pluralStringResource(R.plurals.AccountSettingsFragment__d_configured, totpAppCount, totpAppCount)
} else {
stringResource(R.string.AccountSettingsFragment__one_time_verification_codes)
},
onClick = { onEvent(AccountSettingsEvent.TotpAppClicked) },
modifier = Modifier.testTag(AccountSettingsTestTags.ROW_TOTP_APP)
)
SetUpTwoFactorRow(onEvent = onEvent)
}
item {
Rows.TextRow(
icon = SignalIcons.Key.imageVector,
text = stringResource(R.string.AccountSettingsFragment__passkeys),
label = if (state.signalLogin.passkeyCount > 0) {
pluralStringResource(R.plurals.AccountSettingsFragment__d_passkeys, state.signalLogin.passkeyCount, state.signalLogin.passkeyCount)
} else {
stringResource(R.string.AccountSettingsFragment__device_biometrics_or_fido2_security_key)
},
onClick = { onEvent(AccountSettingsEvent.PasskeysClicked) },
modifier = Modifier.testTag(AccountSettingsTestTags.ROW_PASSKEYS)
)
when (state.signalLogin.loadState) {
LoadState.LOADING -> item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
) {
CircularProgressIndicator(
modifier = Modifier
.size(24.dp)
.testTag(AccountSettingsTestTags.TWO_FACTOR_LOADING)
)
}
}
LoadState.NETWORK_FAILURE -> item {
Text(
text = stringResource(R.string.AccountSettingsFragment__couldnt_load_your_two_factor_methods),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter), vertical = 16.dp)
.testTag(AccountSettingsTestTags.TWO_FACTOR_LOAD_FAILED_MESSAGE)
)
}
LoadState.LOADED -> items(state.signalLogin.twoFactorMethods, key = { "${it.kind}:${it.id}" }) { method ->
TwoFactorMethodRow(
method = method,
onEvent = onEvent
)
}
}
item {
@@ -325,6 +350,155 @@ fun AccountSettingsScreen(
RegistrationLockConfirmationDialog(dialog, onEvent)
}
}
is Dialog.ConfirmRemoveTotpApp -> ConfirmRemoveTotpAppDialog(onEvent = onEvent)
Dialog.MaxTotpAppsReached -> MaxTotpAppsReachedDialog(maxApps = state.signalLogin?.maxTotpApps ?: 0, onEvent = onEvent)
}
}
/**
* The row that starts adding a second factor, which offers a choice of what to add. Passkeys aren't supported yet, so
* an authenticator app is the only thing there is to choose.
*/
@Composable
private fun SetUpTwoFactorRow(
onEvent: (AccountSettingsEvent) -> Unit,
modifier: Modifier = Modifier
) {
val menuController = remember { DropdownMenus.MenuController() }
Box(modifier = modifier) {
Rows.TextRow(
icon = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Icon(
imageVector = SignalIcons.Plus.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
}
},
text = {
TextAndLabel(text = stringResource(R.string.AccountSettingsFragment__set_up))
},
onClick = menuController::show,
modifier = Modifier.testTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR)
)
DropdownMenus.Menu(controller = menuController) { controller ->
DropdownMenus.Item(
leadingIconResId = CoreUiR.drawable.symbol_device_phone_24,
text = {
Column {
Text(text = stringResource(R.string.AccountSettingsFragment__authenticator_app))
Text(
text = stringResource(R.string.AccountSettingsFragment__one_time_verification_codes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = {
onEvent(AccountSettingsEvent.AddTotpAppClicked)
controller.hide()
},
modifier = Modifier.testTag(AccountSettingsTestTags.MENU_ITEM_AUTHENTICATOR_APP)
)
}
}
}
@Composable
private fun TwoFactorMethodRow(
method: TwoFactorMethod,
onEvent: (AccountSettingsEvent) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val addedTime = remember(method.createdAt) {
DateUtils.getRelativeDateTimeString(context, method.createdAt, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString()
}
val icon = when (method.kind) {
TwoFactorMethod.Kind.AUTHENTICATOR_APP -> SignalIcons.DevicePhone
TwoFactorMethod.Kind.PASSKEY -> SignalIcons.Key
}
val kindName = when (method.kind) {
TwoFactorMethod.Kind.AUTHENTICATOR_APP -> stringResource(R.string.AccountSettingsFragment__authenticator_app)
TwoFactorMethod.Kind.PASSKEY -> stringResource(R.string.AccountSettingsFragment__passkey)
}
Rows.TextRow(
icon = {
Icon(
painter = icon.painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
},
text = {
TextAndLabel(
text = method.name,
label = stringResource(R.string.AccountSettingsFragment__s_added_s, kindName, addedTime)
)
TwoFactorMethodMenuButton(
method = method,
onEvent = onEvent
)
},
modifier = modifier.testTag(AccountSettingsTestTags.ROW_TWO_FACTOR_METHOD)
)
}
@Composable
private fun TwoFactorMethodMenuButton(
method: TwoFactorMethod,
onEvent: (AccountSettingsEvent) -> Unit,
modifier: Modifier = Modifier
) {
val menuController = remember { DropdownMenus.MenuController() }
Box(modifier = modifier) {
IconButton(
onClick = menuController::show,
modifier = Modifier.testTag(AccountSettingsTestTags.BUTTON_METHOD_MENU)
) {
Icon(
imageVector = SignalIcons.MoreVertical.imageVector,
contentDescription = stringResource(R.string.AccountSettingsFragment__open_two_factor_options),
tint = MaterialTheme.colorScheme.onSurface
)
}
DropdownMenus.Menu(controller = menuController) { controller ->
DropdownMenus.Item(
leadingIconResId = CoreUiR.drawable.symbol_edit_24,
text = { Text(text = stringResource(R.string.AccountSettingsFragment__rename)) },
onClick = {
onEvent(AccountSettingsEvent.RenameMethodClicked(method))
controller.hide()
},
modifier = Modifier.testTag(AccountSettingsTestTags.MENU_ITEM_RENAME)
)
DropdownMenus.Item(
leadingIconResId = CoreUiR.drawable.symbol_x_circle_24,
text = { Text(text = stringResource(R.string.AccountSettingsFragment__remove)) },
onClick = {
onEvent(AccountSettingsEvent.RemoveMethodClicked(method))
controller.hide()
},
modifier = Modifier.testTag(AccountSettingsTestTags.MENU_ITEM_REMOVE)
)
}
}
}
@@ -412,6 +586,40 @@ private fun DeleteAllDataConfirmationDialog(
)
}
@Composable
private fun ConfirmRemoveTotpAppDialog(
onEvent: (AccountSettingsEvent) -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.AccountSettingsFragment__remove_authenticator_app),
body = stringResource(R.string.AccountSettingsFragment__you_wont_be_able_to_use_this_app),
confirm = stringResource(R.string.AccountSettingsFragment__remove),
onConfirm = { onEvent(AccountSettingsEvent.RemoveTotpAppConfirmed) },
onDismiss = { onEvent(AccountSettingsEvent.DialogDismissed) },
dismiss = stringResource(android.R.string.cancel),
onDismissRequest = { onEvent(AccountSettingsEvent.DialogDismissed) },
modifier = Modifier.testTag(AccountSettingsTestTags.DIALOG_CONFIRM_REMOVE_TOTP_APP)
)
}
@Composable
private fun MaxTotpAppsReachedDialog(
maxApps: Int,
onEvent: (AccountSettingsEvent) -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.AccountSettingsFragment__cant_add_authenticator_app),
body = stringResource(R.string.AccountSettingsFragment__you_cant_add_more_than_d, maxApps),
confirm = stringResource(android.R.string.ok),
onConfirm = {},
onDismiss = { onEvent(AccountSettingsEvent.DialogDismissed) },
dismiss = stringResource(R.string.AccountSettingsFragment__learn_more),
onDeny = { onEvent(AccountSettingsEvent.LearnMoreClicked) },
onDismissRequest = { onEvent(AccountSettingsEvent.DialogDismissed) },
modifier = Modifier.testTag(AccountSettingsTestTags.DIALOG_MAX_TOTP_APPS_REACHED)
)
}
@Composable
private fun RegistrationLockConfirmationDialog(
dialog: Dialog.ConfirmRegistrationLock,
@@ -564,7 +772,11 @@ private fun AccountSettingsScreenSignalLoginPreview() {
AccountSettingsScreen(
state = AccountSettingsState(
isPhoneNumberless = true,
signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 2, passkeyCount = 8)
signalLogin = AccountSettingsState.SignalLogin(
twoFactorMethods = PREVIEW_TWO_FACTOR_METHODS,
loadState = LoadState.LOADED,
maxTotpApps = 2
)
),
onEvent = {}
)
@@ -582,6 +794,34 @@ private fun AccountSettingsScreenDeprecatedPreview() {
}
}
@DayNightPreviews
@Composable
private fun AccountSettingsScreenNoTwoFactorMethodsPreview() {
Previews.Preview {
AccountSettingsScreen(
state = AccountSettingsState(
isPhoneNumberless = true,
signalLogin = AccountSettingsState.SignalLogin(loadState = LoadState.LOADED, maxTotpApps = 2)
),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun AccountSettingsScreenTwoFactorLoadFailedPreview() {
Previews.Preview {
AccountSettingsScreen(
state = AccountSettingsState(
isPhoneNumberless = true,
signalLogin = AccountSettingsState.SignalLogin(loadState = LoadState.NETWORK_FAILURE, maxTotpApps = 2)
),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun DeleteAllDataConfirmationDialogPreview() {
@@ -611,3 +851,25 @@ private fun ConfirmPinToDisableRemindersDialogPreview() {
)
}
}
@DayNightPreviews
@Composable
private fun ConfirmRemoveTotpAppDialogPreview() {
Previews.Preview {
ConfirmRemoveTotpAppDialog(onEvent = {})
}
}
@DayNightPreviews
@Composable
private fun MaxTotpAppsReachedDialogPreview() {
Previews.Preview {
MaxTotpAppsReachedDialog(maxApps = 2, onEvent = {})
}
}
private val PREVIEW_TWO_FACTOR_METHODS = listOf(
TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.AUTHENTICATOR_APP, name = "Bitwarden Authenticator", createdAt = System.currentTimeMillis()),
TwoFactorMethod(id = 2, kind = TwoFactorMethod.Kind.AUTHENTICATOR_APP, name = "Twilio Authy", createdAt = System.currentTimeMillis()),
TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.PASSKEY, name = "Pixel Phone", createdAt = System.currentTimeMillis())
)
@@ -26,10 +26,28 @@ data class AccountSettingsState(
* the sections aren't shown at all.
*/
data class SignalLogin(
/** How many authenticator apps are on the account, or null while we don't know -- see `getTotpAppCount`. */
val totpAppCount: Int?,
val passkeyCount: Int
)
/** The second factors on the account, which only mean anything once [loadState] is [LoadState.LOADED]. */
val twoFactorMethods: List<TwoFactorMethod> = emptyList(),
/** How the last look at the account went, which decides what the two-factor list shows in place of rows. */
val loadState: LoadState = LoadState.LOADING,
/** How many authenticator apps the account is allowed to have at once. */
val maxTotpApps: Int = 0
) {
val atMaxTotpApps: Boolean
get() = twoFactorMethods.count { it.kind == TwoFactorMethod.Kind.AUTHENTICATOR_APP } >= maxTotpApps
}
/** How the last attempt to read the account's second factors went, since an empty list can't say on its own. */
enum class LoadState {
/** We haven't heard back about the account yet. */
LOADING,
LOADED,
/** We couldn't reach the service, which is worth another try. */
NETWORK_FAILURE
}
/** Whichever dialog the screen is showing, if any. Only one is ever up at a time. */
sealed interface Dialog {
@@ -56,5 +74,11 @@ data class AccountSettingsState(
val enable: Boolean,
val inProgress: Boolean = false
) : Dialog
/** Confirms removing [appId], which still has to be backed up by a code from the app itself. */
data class ConfirmRemoveTotpApp(val appId: Long) : Dialog
/** Explains that the account already has as many authenticator apps as it's allowed. */
data object MaxTotpAppsReached : Dialog
}
}
@@ -0,0 +1,31 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.account
import org.signal.core.util.censor
/**
* One second factor on the user's account, as shown in the unified two-factor list on [AccountSettingsScreen].
*
* Ids are only unique within a [kind], since each kind comes from a different place on the account, so anything
* identifying a method has to carry both.
*/
data class TwoFactorMethod(
val id: Long,
val kind: Kind,
val name: String,
/** When the method was added to the account, in epoch milliseconds. */
val createdAt: Long
) {
/** What sort of second factor this is, which decides its icon, its subtitle, and what its menu can do. */
enum class Kind {
AUTHENTICATOR_APP,
PASSKEY
}
override fun toString(): String = "TwoFactorMethod(id=$id, kind=$kind, name=${name.censor()}, createdAt=$createdAt)"
}
@@ -1,18 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.passkeys
/**
* A single passkey on the user's account, as shown on [PasskeysScreen].
*/
data class Passkey(
val id: Long,
val name: String,
/** When the passkey was added, in epoch milliseconds. */
val createdAt: Long
) {
override fun toString(): String = "Passkey(id=$id)"
}
@@ -1,24 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.passkeys
/**
* One-shot side effects that need an Activity or the nav graph, and therefore have to be carried out by the fragment
* hosting [PasskeysScreen] rather than the screen itself.
*
* Actions are logged, so be sure `toString()` contains nothing sensitive.
*/
sealed interface PasskeysAction {
/** Leave the screen. */
data object NavigateBack : PasskeysAction
/** Kick off creating a new passkey. */
data object LaunchPasskeyCreation : PasskeysAction
/** Send the user to a support article about passkeys. */
data object OpenLearnMore : PasskeysAction
}
@@ -1,27 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.passkeys
/**
* Reminder that these events are logged, so don't include anything sensitive in the toString.
*/
sealed interface PasskeysEvent {
/** The user tapped the navigation (back) icon. */
data object NavigateBackClicked : PasskeysEvent
/** The user tapped the button that starts creating a passkey. */
data object SetUpPasskeyClicked : PasskeysEvent
/** The user tapped the learn more link. */
data object LearnMoreClicked : PasskeysEvent
/** The user tapped the rename option in a passkey's overflow menu. */
data class RenamePasskeyClicked(val passkeyId: Long) : PasskeysEvent
/** The user tapped the remove option in a passkey's overflow menu. */
data class RemovePasskeyClicked(val passkeyId: Long) : PasskeysEvent
}
@@ -1,14 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.passkeys
/**
* Where [PasskeysScreen] reads passkeys from.
*/
interface PasskeysRepository {
fun getPasskeys(): List<Passkey>
}
@@ -1,392 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.passkeys
import android.text.format.DateUtils
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import org.signal.appsettings.R
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Rows.TextAndLabel
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Texts
import org.signal.core.ui.R as CoreUiR
@VisibleForTesting
object PasskeysTestTags {
const val SCROLLER = "scroller"
const val LEARN_MORE = "learn-more"
const val BUTTON_SET_UP = "button-set-up"
const val ROW_PASSKEY = "row-passkey"
const val BUTTON_PASSKEY_MENU = "button-passkey-menu"
const val MENU_ITEM_RENAME = "menu-item-rename"
const val MENU_ITEM_REMOVE = "menu-item-remove"
}
/**
* Explains what passkeys are and lets the user start creating one. Once passkeys exist, they're listed here with
* management options instead.
*/
@Composable
fun PasskeysScreen(
state: PasskeysState,
onEvent: (PasskeysEvent) -> Unit
) {
Scaffolds.Settings(
title = stringResource(R.string.PasskeysScreen__passkeys),
onNavigationClick = { onEvent(PasskeysEvent.NavigateBackClicked) },
navigationIcon = SignalIcons.ArrowStart.imageVector
) { contentPadding ->
if (state.passkeys.isEmpty()) {
NoPasskeysContent(
onEvent = onEvent,
modifier = Modifier.padding(contentPadding)
)
} else {
PasskeyListContent(
passkeys = state.passkeys,
onEvent = onEvent,
modifier = Modifier.padding(contentPadding)
)
}
}
}
/**
* Shown before any passkeys exist: an explanation of what passkeys are with a button to create the first one.
*/
@Composable
private fun NoPasskeysContent(
onEvent: (PasskeysEvent) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier.fillMaxSize()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.testTag(PasskeysTestTags.SCROLLER)
) {
Image(
painter = painterResource(R.drawable.image_passkeys_phone),
contentDescription = null,
modifier = Modifier.padding(top = 32.dp)
)
DescriptionWithLearnMore(
text = stringResource(R.string.PasskeysScreen__with_passkeys_you_can_easily_add),
onEvent = onEvent,
modifier = Modifier
.padding(top = 24.dp)
.padding(horizontal = 28.dp)
.testTag(PasskeysTestTags.LEARN_MORE)
)
Column(
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier
.fillMaxWidth()
.padding(top = 40.dp)
.padding(horizontal = 64.dp)
) {
BulletRow(
icon = SignalIcons.CheckCircle,
text = stringResource(R.string.PasskeysScreen__give_your_passkey_a_friendly_name)
)
BulletRow(
icon = SignalIcons.Lock,
text = stringResource(R.string.PasskeysScreen__use_your_devices_biometrics)
)
BulletRow(
icon = SignalIcons.Trash,
text = stringResource(R.string.PasskeysScreen__add_or_remove_passkeys_at_anytime)
)
}
}
SetUpPasskeyButton(
text = stringResource(R.string.PasskeysScreen__set_up_a_passkey),
onEvent = onEvent,
modifier = Modifier.padding(vertical = 16.dp)
)
}
}
/**
* Shown once passkeys exist: a shorter explanation with a button to add another, followed by the list of passkeys.
*/
@Composable
private fun PasskeyListContent(
passkeys: List<Passkey>,
onEvent: (PasskeysEvent) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxSize()
.testTag(PasskeysTestTags.SCROLLER)
) {
item {
Image(
painter = painterResource(R.drawable.image_passkeys_phone),
contentDescription = null,
modifier = Modifier.padding(top = 32.dp)
)
}
item {
DescriptionWithLearnMore(
text = stringResource(R.string.PasskeysScreen__set_up_a_passkey_with),
onEvent = onEvent,
modifier = Modifier
.padding(top = 24.dp)
.padding(horizontal = 34.dp)
.testTag(PasskeysTestTags.LEARN_MORE)
)
}
item {
SetUpPasskeyButton(
text = stringResource(R.string.PasskeysScreen__add_a_new_passkey),
onEvent = onEvent,
modifier = Modifier.padding(top = 24.dp, bottom = 20.dp)
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(
text = stringResource(R.string.PasskeysScreen__passkeys),
modifier = Modifier.fillMaxWidth()
)
}
items(passkeys, key = { it.id }) { passkey ->
PasskeyRow(
passkey = passkey,
onEvent = onEvent
)
}
}
}
@Composable
private fun PasskeyRow(
passkey: Passkey,
onEvent: (PasskeysEvent) -> Unit
) {
val context = LocalContext.current
val addedTime = remember(passkey.createdAt) {
DateUtils.getRelativeDateTimeString(context, passkey.createdAt, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString()
}
Rows.TextRow(
icon = {
Icon(
painter = SignalIcons.Key.painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
},
text = {
TextAndLabel(
text = passkey.name,
label = stringResource(R.string.PasskeysScreen__added_s, addedTime)
)
PasskeyMenuButton(
passkey = passkey,
onEvent = onEvent
)
},
modifier = Modifier.testTag(PasskeysTestTags.ROW_PASSKEY)
)
}
@Composable
private fun PasskeyMenuButton(
passkey: Passkey,
onEvent: (PasskeysEvent) -> Unit
) {
val menuController = remember { DropdownMenus.MenuController() }
Box {
IconButton(
onClick = menuController::show,
modifier = Modifier.testTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)
) {
Icon(
imageVector = SignalIcons.MoreVertical.imageVector,
contentDescription = stringResource(R.string.PasskeysScreen__open_passkey_options),
tint = MaterialTheme.colorScheme.onSurface
)
}
DropdownMenus.Menu(controller = menuController) { controller ->
DropdownMenus.Item(
leadingIconResId = CoreUiR.drawable.symbol_edit_24,
text = { Text(text = stringResource(R.string.PasskeysScreen__rename)) },
onClick = {
onEvent(PasskeysEvent.RenamePasskeyClicked(passkey.id))
controller.hide()
},
modifier = Modifier.testTag(PasskeysTestTags.MENU_ITEM_RENAME)
)
DropdownMenus.Item(
leadingIconResId = CoreUiR.drawable.symbol_x_circle_24,
text = { Text(text = stringResource(R.string.PasskeysScreen__remove)) },
onClick = {
onEvent(PasskeysEvent.RemovePasskeyClicked(passkey.id))
controller.hide()
},
modifier = Modifier.testTag(PasskeysTestTags.MENU_ITEM_REMOVE)
)
}
}
}
@Composable
private fun DescriptionWithLearnMore(
text: String,
onEvent: (PasskeysEvent) -> Unit,
modifier: Modifier = Modifier
) {
Text(
text = buildAnnotatedString {
append(text)
append(' ')
withLink(
LinkAnnotation.Clickable(
tag = "learn-more",
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)),
linkInteractionListener = { onEvent(PasskeysEvent.LearnMoreClicked) }
)
) {
append(stringResource(R.string.PasskeysScreen__learn_more))
}
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier
)
}
@Composable
private fun SetUpPasskeyButton(
text: String,
onEvent: (PasskeysEvent) -> Unit,
modifier: Modifier = Modifier
) {
Buttons.MediumTonal(
onClick = { onEvent(PasskeysEvent.SetUpPasskeyClicked) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 40.dp)
.testTag(PasskeysTestTags.BUTTON_SET_UP)
) {
Text(text = text)
}
}
@Composable
private fun BulletRow(
icon: SignalIcons,
text: String
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(
painter = icon.painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@DayNightPreviews
@Composable
private fun PasskeysScreenPreview() {
Previews.Preview {
PasskeysScreen(
state = PasskeysState(),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun PasskeysScreenWithPasskeysPreview() {
Previews.Preview {
PasskeysScreen(
state = PasskeysState(
passkeys = listOf(
Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()),
Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis())
)
),
onEvent = {}
)
}
}
@@ -1,11 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.passkeys
data class PasskeysState(
/** The passkeys on the account. When empty, the screen explains passkeys instead of listing them. */
val passkeys: List<Passkey> = emptyList()
)
@@ -1,50 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.passkeys
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
/**
* Drives the screen that explains passkeys and lists any that already exist.
*/
class PasskeysViewModel(
repository: PasskeysRepository
) : EventDrivenViewModel<PasskeysEvent>(TAG) {
companion object {
private val TAG = Log.tag(PasskeysViewModel::class)
}
private val _state = MutableStateFlow(PasskeysState(passkeys = repository.getPasskeys()))
private val _actions = Channel<PasskeysAction>(Channel.BUFFERED)
val state: StateFlow<PasskeysState> = _state.asStateFlow()
val actions: Flow<PasskeysAction> = _actions.receiveAsFlow()
override suspend fun processEvent(event: PasskeysEvent) {
when (event) {
PasskeysEvent.NavigateBackClicked -> {
_actions.send(PasskeysAction.NavigateBack)
}
PasskeysEvent.SetUpPasskeyClicked -> {
_actions.send(PasskeysAction.LaunchPasskeyCreation)
}
PasskeysEvent.LearnMoreClicked -> {
_actions.send(PasskeysAction.OpenLearnMore)
}
is PasskeysEvent.RenamePasskeyClicked, is PasskeysEvent.RemovePasskeyClicked -> {
// Nothing to do yet -- rename and remove haven't been built.
}
}
}
}
@@ -3,10 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.totpapplist
package org.signal.appsettings.totp
/**
* A single authenticator app configured on the user's account, as shown on [TotpAppListScreen].
* A single authenticator app configured on the user's account, as shown on the account settings screen.
*/
data class TotpApp(
val id: Long,
@@ -1,33 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.totpapplist
/**
* One-shot side effects that need the nav graph, and therefore have to be carried out by the fragment hosting
* [TotpAppListScreen] rather than the screen itself.
*
* Actions are logged, so be sure `toString()` contains nothing sensitive.
*/
sealed interface TotpAppListAction {
/** Leave the screen. */
data object NavigateBack : TotpAppListAction
/** Open the flow that pairs a new authenticator app. */
data object NavigateToSetup : TotpAppListAction
/** Open the screen that renames [app]. */
data class NavigateToRename(val app: TotpApp) : TotpAppListAction
/** Tell the user their authenticator app was removed. */
data object ShowTotpAppRemoved : TotpAppListAction
/** Tell the user the removal didn't go through, so they know the app is still on the account. */
data object ShowRemovalFailed : TotpAppListAction
/** Send the user to a support article about authenticator apps. */
data object OpenLearnMore : TotpAppListAction
}
@@ -1,36 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.totpapplist
/**
* Reminder that these events are logged, so don't include anything sensitive in the toString.
*/
sealed interface TotpAppListEvent {
/** The screen came back to the foreground, so the list we read out of storage may be stale. */
data object ScreenResumed : TotpAppListEvent
/** The user tapped the navigation (back) icon. */
data object NavigateBackClicked : TotpAppListEvent
/** The user tapped the button that starts setting up another authenticator app. */
data object AddTotpAppClicked : TotpAppListEvent
/** The user tapped the learn more link. */
data object LearnMoreClicked : TotpAppListEvent
/** The user tapped the rename option in an app's overflow menu. */
data class RenameAppClicked(val appId: Long) : TotpAppListEvent
/** The user tapped the remove option in an app's overflow menu, which asks them to confirm first. */
data class RemoveAppClicked(val appId: Long) : TotpAppListEvent
/** The user confirmed removing the app, which removes it. */
data class RemoveAppConfirmed(val appId: Long) : TotpAppListEvent
/** Dismisses whatever is in [TotpAppListState.dialog]. */
data object DialogDismissed : TotpAppListEvent
}
@@ -1,398 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.totpapplist
import android.text.format.DateUtils
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import org.signal.appsettings.R
import org.signal.appsettings.totpapplist.TotpAppListState.Dialog
import org.signal.appsettings.totpapplist.TotpAppListState.LoadState
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Rows.TextAndLabel
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Texts
import org.signal.core.ui.R as CoreUiR
@VisibleForTesting
object TotpAppListTestTags {
const val SCROLLER = "scroller"
const val LEARN_MORE = "learn-more"
const val BUTTON_ADD = "button-add"
const val ROW_APP = "row-app"
const val BUTTON_APP_MENU = "button-app-menu"
const val MENU_ITEM_RENAME = "menu-item-rename"
const val MENU_ITEM_REMOVE = "menu-item-remove"
const val EMPTY_MESSAGE = "empty-message"
const val LOAD_FAILED_MESSAGE = "load-failed-message"
const val DIALOG_CONFIRM_REMOVE = "dialog-confirm-remove"
const val DIALOG_MAX_APPS_REACHED = "dialog-max-apps-reached"
const val LOADING = "loading"
}
/**
* Lists the authenticator apps configured on the account and lets the user add, rename, or remove one.
*/
@Composable
fun TotpAppListScreen(
state: TotpAppListState,
onEvent: (TotpAppListEvent) -> Unit
) {
Scaffolds.Settings(
title = stringResource(R.string.TotpAppListScreen__authenticator_app),
onNavigationClick = { onEvent(TotpAppListEvent.NavigateBackClicked) },
navigationIcon = SignalIcons.ArrowStart.imageVector
) { contentPadding ->
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(contentPadding)
.testTag(TotpAppListTestTags.SCROLLER)
) {
item {
Image(
painter = painterResource(R.drawable.image_authenticator_open_app),
contentDescription = null,
modifier = Modifier
.padding(top = 32.dp)
.size(width = 55.dp, height = 105.dp)
)
}
item {
DescriptionWithLearnMore(
onEvent = onEvent,
modifier = Modifier
.padding(top = 24.dp)
.padding(horizontal = 34.dp)
.testTag(TotpAppListTestTags.LEARN_MORE)
)
}
item {
Buttons.MediumTonal(
onClick = { onEvent(TotpAppListEvent.AddTotpAppClicked) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp, bottom = 20.dp)
.padding(horizontal = 40.dp)
.testTag(TotpAppListTestTags.BUTTON_ADD)
) {
Text(text = stringResource(R.string.TotpAppListScreen__add_authenticator_app))
}
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(
text = stringResource(R.string.TotpAppListScreen__authenticator_apps),
modifier = Modifier.fillMaxWidth()
)
}
when (state.loadState) {
LoadState.LOADING -> item {
CircularProgressIndicator(
modifier = Modifier
.padding(top = 40.dp)
.size(24.dp)
.testTag(TotpAppListTestTags.LOADING)
)
}
LoadState.NETWORK_FAILURE -> item {
SectionMessage(
text = stringResource(R.string.TotpAppListScreen__couldnt_load_authenticator_apps),
modifier = Modifier.testTag(TotpAppListTestTags.LOAD_FAILED_MESSAGE)
)
}
LoadState.LOADED -> if (state.apps.isEmpty()) {
item {
SectionMessage(
text = stringResource(R.string.TotpAppListScreen__no_authenticator_apps),
modifier = Modifier.testTag(TotpAppListTestTags.EMPTY_MESSAGE)
)
}
} else {
items(state.apps, key = { it.id }) { app ->
TotpAppRow(
app = app,
onEvent = onEvent
)
}
}
}
}
}
when (val dialog = state.dialog) {
Dialog.None -> Unit
is Dialog.ConfirmRemove -> ConfirmRemoveDialog(appId = dialog.appId, onEvent = onEvent)
Dialog.MaxAppsReached -> MaxAppsReachedDialog(maxApps = state.maxApps, onEvent = onEvent)
}
}
/** Whatever the list section has to say when it has no rows to show. */
@Composable
private fun SectionMessage(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier
.padding(top = 40.dp)
.padding(horizontal = 34.dp)
)
}
@Composable
private fun TotpAppRow(
app: TotpApp,
onEvent: (TotpAppListEvent) -> Unit
) {
val context = LocalContext.current
val addedTime = remember(app.createdAt) {
DateUtils.getRelativeDateTimeString(context, app.createdAt, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString()
}
Rows.TextRow(
icon = {
Icon(
painter = SignalIcons.DevicePhone.painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
},
text = {
TextAndLabel(
text = app.name,
label = stringResource(R.string.TotpAppListScreen__added_s, addedTime)
)
TotpAppMenuButton(
app = app,
onEvent = onEvent
)
},
modifier = Modifier.testTag(TotpAppListTestTags.ROW_APP)
)
}
@Composable
private fun TotpAppMenuButton(
app: TotpApp,
onEvent: (TotpAppListEvent) -> Unit
) {
val menuController = remember { DropdownMenus.MenuController() }
Box {
IconButton(
onClick = menuController::show,
modifier = Modifier.testTag(TotpAppListTestTags.BUTTON_APP_MENU)
) {
Icon(
imageVector = SignalIcons.MoreVertical.imageVector,
contentDescription = stringResource(R.string.TotpAppListScreen__open_authenticator_app_options),
tint = MaterialTheme.colorScheme.onSurface
)
}
DropdownMenus.Menu(controller = menuController) { controller ->
DropdownMenus.Item(
leadingIconResId = CoreUiR.drawable.symbol_edit_24,
text = { Text(text = stringResource(R.string.TotpAppListScreen__rename)) },
onClick = {
onEvent(TotpAppListEvent.RenameAppClicked(app.id))
controller.hide()
},
modifier = Modifier.testTag(TotpAppListTestTags.MENU_ITEM_RENAME)
)
DropdownMenus.Item(
leadingIconResId = CoreUiR.drawable.symbol_x_circle_24,
text = { Text(text = stringResource(R.string.TotpAppListScreen__remove)) },
onClick = {
onEvent(TotpAppListEvent.RemoveAppClicked(app.id))
controller.hide()
},
modifier = Modifier.testTag(TotpAppListTestTags.MENU_ITEM_REMOVE)
)
}
}
}
@Composable
private fun DescriptionWithLearnMore(
onEvent: (TotpAppListEvent) -> Unit,
modifier: Modifier = Modifier
) {
Text(
text = buildAnnotatedString {
append(stringResource(R.string.TotpAppListScreen__set_up_an_authenticator_app))
append(' ')
withLink(
LinkAnnotation.Clickable(
tag = "learn-more",
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)),
linkInteractionListener = { onEvent(TotpAppListEvent.LearnMoreClicked) }
)
) {
append(stringResource(R.string.TotpAppListScreen__learn_more))
}
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier
)
}
@Composable
private fun ConfirmRemoveDialog(
appId: Long,
onEvent: (TotpAppListEvent) -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.TotpAppListScreen__remove_authenticator_app),
body = stringResource(R.string.TotpAppListScreen__you_wont_be_able_to_use_this_app),
confirm = stringResource(R.string.TotpAppListScreen__remove),
onConfirm = { onEvent(TotpAppListEvent.RemoveAppConfirmed(appId)) },
onDismiss = { onEvent(TotpAppListEvent.DialogDismissed) },
dismiss = stringResource(android.R.string.cancel),
onDismissRequest = { onEvent(TotpAppListEvent.DialogDismissed) },
modifier = Modifier.testTag(TotpAppListTestTags.DIALOG_CONFIRM_REMOVE)
)
}
@Composable
private fun MaxAppsReachedDialog(
maxApps: Int,
onEvent: (TotpAppListEvent) -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.TotpAppListScreen__cant_add_authenticator_app),
body = stringResource(R.string.TotpAppListScreen__you_cant_add_more_than_d, maxApps),
confirm = stringResource(android.R.string.ok),
onConfirm = {},
onDismiss = { onEvent(TotpAppListEvent.DialogDismissed) },
dismiss = stringResource(R.string.TotpAppListScreen__learn_more),
onDeny = { onEvent(TotpAppListEvent.LearnMoreClicked) },
onDismissRequest = { onEvent(TotpAppListEvent.DialogDismissed) },
modifier = Modifier.testTag(TotpAppListTestTags.DIALOG_MAX_APPS_REACHED)
)
}
@DayNightPreviews
@Composable
private fun TotpAppListScreenPreview() {
Previews.Preview {
TotpAppListScreen(
state = TotpAppListState(),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun TotpAppListScreenEmptyPreview() {
Previews.Preview {
TotpAppListScreen(
state = TotpAppListState(loadState = LoadState.LOADED),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun TotpAppListScreenLoadFailedPreview() {
Previews.Preview {
TotpAppListScreen(
state = TotpAppListState(loadState = LoadState.NETWORK_FAILURE),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun TotpAppListPreview() {
Previews.Preview {
TotpAppListScreen(
state = TotpAppListState(apps = PREVIEW_APPS, loadState = LoadState.LOADED),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun ConfirmRemoveDialogPreview() {
Previews.Preview {
ConfirmRemoveDialog(appId = 1, onEvent = {})
}
}
@DayNightPreviews
@Composable
private fun MaxAppsReachedDialogPreview() {
Previews.Preview {
MaxAppsReachedDialog(maxApps = 2, onEvent = {})
}
}
private val PREVIEW_APPS = listOf(
TotpApp(id = 1, name = "Bitwarden Authenticator", createdAt = System.currentTimeMillis()),
TotpApp(id = 2, name = "Twilio Authy", createdAt = System.currentTimeMillis())
)
@@ -1,42 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.totpapplist
data class TotpAppListState(
/** The authenticator apps configured on the account, which only means anything once [loadState] is [LoadState.LOADED]. */
val apps: List<TotpApp> = emptyList(),
/** How many authenticator apps the account is allowed to have at once. */
val maxApps: Int = 0,
/** How the last look at the account went, which decides what the list section shows in place of rows. */
val loadState: LoadState = LoadState.LOADING,
val dialog: Dialog = Dialog.None
) {
val atMaxApps: Boolean
get() = apps.size >= maxApps
/** How the last attempt to read the account's authenticator apps went, since an empty list can't say on its own. */
enum class LoadState {
/** We haven't heard back about the account yet. */
LOADING,
LOADED,
/** We couldn't reach the service, which is worth another try. */
NETWORK_FAILURE
}
/** Whichever dialog the screen is showing, if any. Only one is ever up at a time. */
sealed interface Dialog {
data object None : Dialog
/** Confirms removing [appId], which still has to be backed up by a code from the app itself. */
data class ConfirmRemove(val appId: Long) : Dialog
/** Explains that the account already has as many authenticator apps as it's allowed. */
data object MaxAppsReached : Dialog
}
}
@@ -16,8 +16,8 @@ sealed interface TotpNameEntryAction {
/** Leave the screen. */
data object NavigateBack : TotpNameEntryAction
/** The app has a name now, so go back to the list of authenticator apps. */
data object NavigateToTotpAppList : TotpNameEntryAction
/** The app has a name now, so go back to the account settings screen that lists it. */
data object NavigateToAccountSettings : TotpNameEntryAction
/** Tell the user their authenticator app was set up. */
data object ShowTotpAppSetUp : TotpNameEntryAction
@@ -216,7 +216,7 @@ private fun SetupDialog(
) {
val message = when (dialog) {
TotpSetupState.Dialog.None -> return
is TotpSetupState.Dialog.MaxAppsReached -> stringResource(R.string.TotpAppListScreen__you_cant_add_more_than_d, dialog.maxApps)
is TotpSetupState.Dialog.MaxAppsReached -> stringResource(R.string.TotpSetupScreen__you_cant_add_more_than_d, dialog.maxApps)
TotpSetupState.Dialog.NetworkFailure -> stringResource(R.string.TotpSetupScreen__couldnt_reach_signal)
}
@@ -1,67 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="52dp"
android:height="92dp"
android:viewportWidth="52"
android:viewportHeight="92">
<path
android:fillColor="#D9E0EE"
android:strokeColor="#4A5775"
android:strokeWidth="2"
android:pathData="M10,1h28a9,9 0 0 1 9,9v72a9,9 0 0 1 -9,9h-28a9,9 0 0 1 -9,-9v-72a9,9 0 0 1 9,-9z"/>
<path
android:fillColor="#EAEEF6"
android:pathData="M26.3418,2C24.2815,15.0299 14.5971,25.5234 2,28.7656V10C2,5.58172 5.58172,2 10,2H26.3418Z"/>
<path
android:fillColor="#C0CBE2"
android:pathData="M41.4021,2.94279C41.4021,2.85734 41.4931,2.80233 41.5695,2.84051C44.1958,4.15231 45.9998,6.86514 45.9998,10.0004V82.0004C45.9995,86.4185 42.4179,90.0004 37.9998,90.0004H9.99975C6.63198,90.0003 5.96246,85.4672 9.18582,84.4915C9.92001,84.2693 10.6988,84.1498 11.5056,84.1498H33.4021C37.8204,84.1498 41.4021,80.5681 41.4021,76.1498V2.94279Z"/>
<path
android:fillColor="#4A5775"
android:pathData="M19.7695,5.38446h8.4616a1.69231,1.69231 0 0 1 0,3.38461h-8.4616a1.69231,1.69231 0 0 1 0,-3.38461z"/>
<group
android:translateX="8.11"
android:translateY="29.25">
<path
android:fillColor="#FFFFFF"
android:pathData="M7.20447,7.28906C7.20447,3.27305 9.85894,0 13.392,0C16.925,0 19.5795,3.27305 19.5795,7.28906C19.5795,9.31067 18.9208,11.214 17.8305,12.6259C16.7405,14.0373 15.1791,15 13.392,15C11.6048,15 10.0434,14.0373 8.95346,12.6259C7.86315,11.214 7.20447,9.31067 7.20447,7.28906Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M0.0309865,27.6291C0.989446,22.0849 6.70416,18 13.3921,18C20.08,18 25.7947,22.0849 26.7532,27.6291C26.9887,28.9916 25.8423,30 24.6421,30H2.14209C0.941895,30 -0.204559,28.9916 0.0309865,27.6291Z"/>
</group>
<group
android:translateX="7.73"
android:translateY="28.88">
<path
android:fillColor="#4A5775"
android:fillType="evenOdd"
android:pathData="M13.7678,20.25C7.7345,20.25 3.01725,23.9103 2.25433,28.3235C2.25019,28.3474 2.25312,28.3589 2.25515,28.3653C2.25791,28.3738 2.2653,28.3901 2.28473,28.4106C2.32654,28.4547 2.40827,28.5 2.51783,28.5H25.0178C25.1274,28.5 25.2091,28.4547 25.2509,28.4106C25.2704,28.3901 25.2778,28.3738 25.2805,28.3653C25.2825,28.3589 25.2855,28.3474 25.2813,28.3235C24.5184,23.9103 19.8012,20.25 13.7678,20.25ZM0.0372122,27.9402C1.03478,22.1698 6.94899,18 13.7678,18C20.5867,18 26.5009,22.1698 27.4985,27.9402C27.7812,29.5758 26.4039,30.75 25.0178,30.75H2.51783C1.13176,30.75 -0.245544,29.5758 0.0372122,27.9402Z"/>
<path
android:fillColor="#4A5775"
android:fillType="evenOdd"
android:pathData="M13.7678,2.25C11.5597,2.25 9.45533,4.36827 9.45533,7.66406C9.45533,9.29155 9.98883,10.7872 10.8133,11.8549C11.6384,12.9234 12.6947,13.5 13.7678,13.5C14.8409,13.5 15.8972,12.9234 16.7223,11.8549C17.5468,10.7872 18.0803,9.29155 18.0803,7.66406C18.0803,4.36827 15.9759,2.25 13.7678,2.25ZM7.20533,7.66406C7.20533,3.50401 9.96982,0 13.7678,0C17.5658,0 20.3303,3.50401 20.3303,7.66406C20.3303,9.76449 19.6466,11.7493 18.5031,13.2301C17.3602,14.7101 15.6978,15.75 13.7678,15.75C11.8379,15.75 10.1754,14.7101 9.03252,13.2301C7.88905,11.7493 7.20533,9.76449 7.20533,7.66406Z"/>
</group>
<group
android:translateX="28.97"
android:translateY="41.52"
android:scaleX="-1"
android:pivotX="5.5"
android:pivotY="11.21">
<path
android:fillColor="#FFFFFF"
android:pathData="M1.61077,1.61091C3.75865,-0.536972 7.24121,-0.536968 9.38909,1.61091C11.537,3.75879 11.537,7.24135 9.38909,9.38923C8.89219,9.88606 8.32292,10.2658 7.71624,10.5328L7.62151,21.7633L5.2553,22.4273L3.02483,19.9957L3.73284,17.1676L3.02483,15.0455V10.4107C2.51536,10.1538 2.03626,9.81472 1.61077,9.38923C-0.537013,7.24144 -0.536835,3.75881 1.61077,1.61091Z"/>
</group>
<group
android:translateX="23.72"
android:translateY="41.97"
android:rotation="45"
android:scaleY="-1"
android:pivotX="10.75"
android:pivotY="10">
<path
android:fillColor="#4A5775"
android:pathData="M7.5,14.7498C7.5,13.8524 6.77246,13.1248 5.875,13.1248C4.97754,13.1248 4.25,13.8524 4.25,14.7498C4.25,15.6473 4.97754,16.3748 5.875,16.3748C6.77246,16.3748 7.5,15.6473 7.5,14.7498Z"/>
<path
android:fillColor="#4A5775"
android:fillType="evenOdd"
android:pathData="M16.4557,0C16.0576,0 15.6758,0.158282 15.3944,0.439973L8.94364,6.89844C8.32568,6.72014 7.67331,6.6248 7,6.6248C3.99436,6.6248 1.43356,8.51865 0.441029,11.1748C0.155587,11.9386 0,12.7647 0,13.6248C0,14.3886 0.122692,15.1257 0.350204,15.8164C1.2697,18.6077 3.89799,20.6248 7,20.6248C10.866,20.6248 14,17.4908 14,13.6248C14,12.8152 13.8622,12.0362 13.6083,11.311L14.4144,10.5H15.75C16.5784,10.5 17.25,9.82843 17.25,9V7.00141H19.2482C20.0766,7.00141 20.7482,6.32984 20.7482,5.50141V1.5C20.7482,0.671574 20.0766,0 19.2482,0L16.4557,0ZM9.45405,9.21754L16.663,2L18.7482,2V5.00141H16.75C15.9216,5.00141 15.25,5.67298 15.25,6.50141V8.5H14.2064C13.807,8.5 13.4241,8.65929 13.1425,8.94256L11.2299,10.8668L11.5279,11.5005C11.8304,12.144 12,12.8632 12,13.6248C12,16.3862 9.76142,18.6248 7,18.6248C4.78691,18.6248 2.90719,17.1863 2.2498,15.1906C2.08797,14.6994 2,14.1733 2,13.6248C2,13.0073 2.11147,12.4182 2.3145,11.8748C3.02445,9.97495 4.85581,8.6248 7,8.6248C7.65263,8.6248 8.27375,8.74933 8.84263,8.97499L9.45405,9.21754Z"/>
</group>
</vector>
@@ -52,25 +52,37 @@
<string name="AccountSettingsFragment__your_signal_login_is_used_to_recover">Your Signal Login is used to recover and restore your account. Keep these keys stored safely in a password manager you trust.</string>
<!-- Section header for the two-factor authentication section of account settings -->
<string name="AccountSettingsFragment__two_factor_authentication">Two-factor authentication</string>
<!-- Account setting that takes the user to the authenticator app setup flow -->
<!-- Row that opens the menu of second factors the user can add -->
<string name="AccountSettingsFragment__set_up">Set up</string>
<!-- Menu option that starts setting up an authenticator app -->
<string name="AccountSettingsFragment__authenticator_app">Authenticator app</string>
<!-- Description of the authenticator app setting when none are configured -->
<!-- Description of the authenticator app menu option -->
<string name="AccountSettingsFragment__one_time_verification_codes">One-time verification codes</string>
<!-- Description of the authenticator app setting saying how many are configured -->
<plurals name="AccountSettingsFragment__d_configured">
<item quantity="one">%1$d configured</item>
<item quantity="other">%1$d configured</item>
</plurals>
<!-- Account setting that takes the user to the passkeys screen -->
<string name="AccountSettingsFragment__passkeys">Passkeys</string>
<!-- Description of the passkeys setting when none exist -->
<string name="AccountSettingsFragment__device_biometrics_or_fido2_security_key">Device biometrics or FIDO2 security key</string>
<!-- Description of the passkeys setting saying how many exist -->
<plurals name="AccountSettingsFragment__d_passkeys">
<item quantity="one">%1$d passkey</item>
<item quantity="other">%1$d passkeys</item>
</plurals>
<!-- Description of two-factor authentication, shown below the two-factor authentication rows -->
<!-- Name of a passkey in the list of second factors on the account -->
<string name="AccountSettingsFragment__passkey">Passkey</string>
<!-- Subtitle of a second factor saying what kind it is and when it was added. First placeholder is the kind, second is a date or time. -->
<string name="AccountSettingsFragment__s_added_s">%1$s · Added %2$s</string>
<!-- Content description of the button that opens a second factor\'s options -->
<string name="AccountSettingsFragment__open_two_factor_options">Open two-factor authentication options</string>
<!-- Menu option that renames a second factor -->
<string name="AccountSettingsFragment__rename">Rename</string>
<!-- Menu option that removes a second factor -->
<string name="AccountSettingsFragment__remove">Remove</string>
<!-- Title of the dialog confirming removal of an authenticator app -->
<string name="AccountSettingsFragment__remove_authenticator_app">Remove authenticator app?</string>
<!-- Body of the dialog confirming removal of an authenticator app -->
<string name="AccountSettingsFragment__you_wont_be_able_to_use_this_app">You won\'t be able to use this app for one-time verification codes when you sign in.</string>
<!-- Title of the dialog shown when the account already has as many authenticator apps as it\'s allowed -->
<string name="AccountSettingsFragment__cant_add_authenticator_app">Can\'t add authenticator app</string>
<!-- Body of the dialog shown when the account already has as many authenticator apps as it\'s allowed -->
<string name="AccountSettingsFragment__you_cant_add_more_than_d">You can\'t add more than %1$d authenticator apps. Try removing one first.</string>
<!-- Shown in place of the list when we couldn\'t work out which second factors are on the account -->
<string name="AccountSettingsFragment__couldnt_load_your_two_factor_methods">Couldn\'t load your two-factor authentication methods. Check your connection and try again.</string>
<!-- Toast shown after an authenticator app has been removed -->
<string name="AccountSettingsFragment__authenticator_app_removed">Authenticator app removed</string>
<!-- Toast shown when an authenticator app couldn\'t be removed -->
<string name="AccountSettingsFragment__couldnt_remove_authenticator_app">Couldn\'t remove authenticator app. Check your connection and try again.</string>
<!-- Description of two-factor authentication, shown below the two-factor authentication list -->
<string name="AccountSettingsFragment__use_a_second_form_of_authentication">Use a second form of authentication to protect your account when using your Signal Login on a new device.</string>
<!-- Clickable text that takes the user to a support article -->
<string name="AccountSettingsFragment__learn_more">Learn more</string>
@@ -100,6 +112,8 @@
<string name="TotpSetupScreen__copied_to_clipboard">Copied to clipboard</string>
<!-- Toast shown when there is no app installed that can handle the authenticator setup link -->
<string name="TotpSetupScreen__no_authenticator_app_found">No authenticator app found</string>
<!-- Dialog message shown when the account already has as many authenticator apps as it\'s allowed -->
<string name="TotpSetupScreen__you_cant_add_more_than_d">You can\'t add more than %1$d authenticator apps. Try removing one first.</string>
<!-- Dialog message shown when we couldn\'t reach the service to start setting up an authenticator app -->
<string name="TotpSetupScreen__couldnt_reach_signal">Couldn\'t reach Signal. Check your connection and try again.</string>
<!-- Header of the third step of authenticator app setup -->
@@ -109,34 +123,6 @@
<!-- Button that advances from the setup instructions to entering a code -->
<string name="TotpSetupScreen__continue">Continue</string>
<!-- PasskeysScreen -->
<!-- Title of the screen where the user sets up passkeys -->
<string name="PasskeysScreen__passkeys">Passkeys</string>
<!-- Description of passkeys shown at the top of the screen -->
<string name="PasskeysScreen__with_passkeys_you_can_easily_add">With passkeys you can easily add a secure second form of authentication to your account. Passkeys can use your device\'s biometrics or a compatible FIDO2 security key.</string>
<!-- Clickable text that takes the user to a support article -->
<string name="PasskeysScreen__learn_more">Learn more</string>
<!-- Bullet point explaining that a passkey can be given a name -->
<string name="PasskeysScreen__give_your_passkey_a_friendly_name">Give your passkey a friendly name to identify it</string>
<!-- Bullet point explaining how a passkey is stored -->
<string name="PasskeysScreen__use_your_devices_biometrics">Use your device\'s biometrics or a FIDO2 security key to store your passkey</string>
<!-- Bullet point explaining that passkeys can be added and removed -->
<string name="PasskeysScreen__add_or_remove_passkeys_at_anytime">Add or remove passkeys at anytime.</string>
<!-- Button that starts creating a passkey -->
<string name="PasskeysScreen__set_up_a_passkey">Set up a passkey</string>
<!-- Description of passkeys shown at the top of the screen once some already exist -->
<string name="PasskeysScreen__set_up_a_passkey_with">Set up a passkey with your device\'s biometrics or a compatible FIDO2 security key.</string>
<!-- Button that starts creating a passkey once some already exist -->
<string name="PasskeysScreen__add_a_new_passkey">Add a new passkey</string>
<!-- Label under a passkey\'s name saying when it was added. The placeholder is a relative time like "today, 11:00 AM" -->
<string name="PasskeysScreen__added_s">Added %1$s</string>
<!-- Accessibility description of the button that opens a passkey\'s menu -->
<string name="PasskeysScreen__open_passkey_options">Open passkey options</string>
<!-- Menu option that renames a passkey -->
<string name="PasskeysScreen__rename">Rename</string>
<!-- Menu option that removes a passkey -->
<string name="PasskeysScreen__remove">Remove</string>
<!-- TotpCodeEntryScreen -->
<!-- Title of the screen where the user enters the code from their authenticator app -->
<string name="TotpCodeEntryScreen__enter_your_code">Enter your code</string>
@@ -150,42 +136,6 @@
<string name="TotpCodeEntryScreen__incorrect_code">Incorrect code. Enter the code showing in your authenticator app now.</string>
<!-- Error shown under the code field when we couldn\'t reach the service to check the code -->
<string name="TotpCodeEntryScreen__couldnt_reach_signal">Couldn\'t reach Signal. Check your connection and try again.</string>
<!-- TotpAppListScreen -->
<!-- Title of the screen listing the authenticator apps on the account -->
<string name="TotpAppListScreen__authenticator_app">Authenticator app</string>
<!-- Description of what an authenticator app is for, shown at the top of the screen -->
<string name="TotpAppListScreen__set_up_an_authenticator_app">Set up an authenticator app to generate one-time verification codes</string>
<!-- Clickable text that takes the user to a support article -->
<string name="TotpAppListScreen__learn_more">Learn more</string>
<!-- Button that starts setting up another authenticator app -->
<string name="TotpAppListScreen__add_authenticator_app">Add authenticator app</string>
<!-- Section header above the list of configured authenticator apps -->
<string name="TotpAppListScreen__authenticator_apps">Authenticator apps</string>
<!-- Shown in place of the list when no authenticator apps are configured -->
<string name="TotpAppListScreen__no_authenticator_apps">No authenticator apps</string>
<!-- Subtitle of an authenticator app row saying when it was configured. Placeholder is a date or time. -->
<string name="TotpAppListScreen__added_s">Added %1$s</string>
<!-- Content description of the button that opens an authenticator app\'s options -->
<string name="TotpAppListScreen__open_authenticator_app_options">Open authenticator app options</string>
<!-- Menu option that renames an authenticator app -->
<string name="TotpAppListScreen__rename">Rename</string>
<!-- Menu option that removes an authenticator app -->
<string name="TotpAppListScreen__remove">Remove</string>
<!-- Title of the dialog confirming removal of an authenticator app -->
<string name="TotpAppListScreen__remove_authenticator_app">Remove authenticator app?</string>
<!-- Body of the dialog confirming removal of an authenticator app -->
<string name="TotpAppListScreen__you_wont_be_able_to_use_this_app">You won\'t be able to use this app for one-time verification codes when you sign in.</string>
<!-- Title of the dialog shown when the account already has as many authenticator apps as it\'s allowed -->
<string name="TotpAppListScreen__cant_add_authenticator_app">Can\'t add authenticator app</string>
<!-- Body of the dialog shown when the account already has as many authenticator apps as it\'s allowed -->
<string name="TotpAppListScreen__you_cant_add_more_than_d">You can\'t add more than %1$d authenticator apps. Try removing one first.</string>
<!-- Dialog message shown when we couldn\'t work out which authenticator apps are on the account -->
<string name="TotpAppListScreen__couldnt_load_authenticator_apps">Couldn\'t load your authenticator apps. Check your connection and try again.</string>
<!-- Toast shown after an authenticator app has been removed -->
<string name="TotpAppListScreen__authenticator_app_removed">Authenticator app removed</string>
<!-- Toast shown when an authenticator app couldn\'t be removed -->
<string name="TotpAppListScreen__couldnt_remove_authenticator_app">Couldn\'t remove authenticator app. Check your connection and try again.</string>
<!-- TotpNameEntryScreen -->
<!-- Title of the screen where the user names an authenticator app -->
<string name="TotpNameEntryScreen__choose_a_name">Choose a name</string>
@@ -6,12 +6,15 @@
package org.signal.appsettings.account
import android.app.Application
import android.text.format.DateUtils
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -26,12 +29,30 @@ import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.signal.appsettings.R
import org.signal.appsettings.account.AccountSettingsState.Dialog
import org.signal.appsettings.account.AccountSettingsState.LoadState
import org.signal.core.ui.compose.Dialogs
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class AccountSettingsScreenTest {
companion object {
/** Fixed so the "Added ..." subtitle a row renders is something the test can predict. */
private const val CREATED_AT = 1_700_000_000_000L
private val ADDED_TIME: String = DateUtils.getRelativeDateTimeString(
RuntimeEnvironment.getApplication(),
CREATED_AT,
DateUtils.DAY_IN_MILLIS,
DateUtils.WEEK_IN_MILLIS,
0
).toString()
private val METHODS = listOf(
TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.AUTHENTICATOR_APP, name = "Bitwarden Authenticator", createdAt = CREATED_AT),
TwoFactorMethod(id = 1, kind = TwoFactorMethod.Kind.PASSKEY, name = "Pixel Phone", createdAt = CREATED_AT)
)
}
private val context: Application = RuntimeEnvironment.getApplication()
@get:Rule
@@ -299,12 +320,12 @@ class AccountSettingsScreenTest {
setContent(createState())
composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertDoesNotExist()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TOTP_APP).assertDoesNotExist()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR).assertDoesNotExist()
}
@Test
fun givenASignalLogin_whenIClickTheSignalLoginCard_thenIExpectAccountAndRecoveryEvent() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0)))
setContent(createState(signalLogin = signalLogin()))
composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).performClick()
@@ -312,40 +333,106 @@ class AccountSettingsScreenTest {
}
@Test
fun givenASignalLogin_whenIClickTotpApp_thenIExpectTotpAppEvent() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0)))
fun givenASignalLogin_whenIPickAuthenticatorAppFromTheSetUpMenu_thenIExpectAddTotpAppEvent() {
setContent(createState(signalLogin = signalLogin()))
composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertIsDisplayed()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR).performClick()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.MENU_ITEM_AUTHENTICATOR_APP).performClick()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_TOTP_APP).performClick()
assertThat(events).contains(AccountSettingsEvent.TotpAppClicked)
assertThat(events).contains(AccountSettingsEvent.AddTotpAppClicked)
}
/** A count we couldn't fetch has to read the same as no count at all, rather than as "0 configured". */
/** Passkeys aren't supported yet, so the menu can't offer to set one up. */
@Test
fun givenAnUnknownTotpAppCount_whenScreenDisplayed_thenTheRowDoesNotClaimACount() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = null, passkeyCount = 0)))
fun givenTheSetUpMenu_whenItIsOpen_thenPasskeyIsNotOffered() {
setContent(createState(signalLogin = signalLogin()))
composeTestRule.onNodeWithText(context.getString(R.string.AccountSettingsFragment__one_time_verification_codes)).assertIsDisplayed()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_SET_UP_TWO_FACTOR).performClick()
composeTestRule.onNodeWithText(context.getString(R.string.AccountSettingsFragment__passkey)).assertDoesNotExist()
}
@Test
fun givenConfiguredTotpApps_whenScreenDisplayed_thenTheRowShowsTheCount() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 2, passkeyCount = 0)))
fun givenTwoFactorMethods_whenScreenDisplayed_thenIExpectARowPerMethod() {
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS)))
composeTestRule.onNodeWithText(context.resources.getQuantityString(R.plurals.AccountSettingsFragment__d_configured, 2, 2)).assertIsDisplayed()
for (method in METHODS) {
composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER).performScrollToNode(hasText(method.name))
composeTestRule.onNodeWithText(method.name).assertIsDisplayed()
}
}
/** Authenticator apps and passkeys share one list, so a row's subtitle is what says which kind it is. */
@Test
fun givenTwoFactorMethods_whenScreenDisplayed_thenEachRowSaysWhatKindItIs() {
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS)))
for (kind in listOf(R.string.AccountSettingsFragment__authenticator_app, R.string.AccountSettingsFragment__passkey)) {
val label = context.getString(R.string.AccountSettingsFragment__s_added_s, context.getString(kind), ADDED_TIME)
composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER).performScrollToNode(hasText(label))
composeTestRule.onNodeWithText(label).assertIsDisplayed()
}
}
@Test
fun givenASignalLogin_whenIClickPasskeys_thenIExpectPasskeysEvent() {
setContent(createState(signalLogin = AccountSettingsState.SignalLogin(totpAppCount = 0, passkeyCount = 0)))
fun givenTwoFactorMethods_whenIClickRenameInTheMenu_thenIExpectRenameMethodEvent() {
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS)))
scrollTo(AccountSettingsTestTags.ROW_PASSKEYS)
scrollTo(AccountSettingsTestTags.ROW_TWO_FACTOR_METHOD)
composeTestRule.onAllNodesWithTag(AccountSettingsTestTags.BUTTON_METHOD_MENU)[0].performClick()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.MENU_ITEM_RENAME).performClick()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_PASSKEYS).performClick()
assertThat(events).contains(AccountSettingsEvent.RenameMethodClicked(METHODS[0]))
}
assertThat(events).contains(AccountSettingsEvent.PasskeysClicked)
@Test
fun givenTwoFactorMethods_whenIClickRemoveInTheMenu_thenIExpectRemoveMethodEvent() {
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS)))
scrollTo(AccountSettingsTestTags.ROW_TWO_FACTOR_METHOD)
composeTestRule.onAllNodesWithTag(AccountSettingsTestTags.BUTTON_METHOD_MENU)[0].performClick()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.MENU_ITEM_REMOVE).performClick()
assertThat(events).contains(AccountSettingsEvent.RemoveMethodClicked(METHODS[0]))
}
@Test
fun givenTheConfirmRemoveDialog_whenIConfirm_thenIExpectRemoveTotpAppConfirmedForThatApp() {
setContent(createState(signalLogin = signalLogin(twoFactorMethods = METHODS), dialog = Dialog.ConfirmRemoveTotpApp(METHODS[0].id)))
composeTestRule.onNodeWithTag(AccountSettingsTestTags.DIALOG_CONFIRM_REMOVE_TOTP_APP).assertIsDisplayed()
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick()
assertThat(events).contains(AccountSettingsEvent.RemoveTotpAppConfirmed)
}
@Test
fun givenTheMaxAppsDialog_whenIClickLearnMore_thenIExpectLearnMoreAndDismissEvents() {
setContent(createState(signalLogin = signalLogin(), dialog = Dialog.MaxTotpAppsReached))
composeTestRule.onNodeWithTag(AccountSettingsTestTags.DIALOG_MAX_TOTP_APPS_REACHED).assertIsDisplayed()
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick()
assertThat(events).contains(AccountSettingsEvent.LearnMoreClicked)
assertThat(events).contains(AccountSettingsEvent.DialogDismissed)
}
@Test
fun whenTheTwoFactorListHasntArrived_thenIExpectASpinnerRatherThanAnEmptyList() {
setContent(createState(signalLogin = signalLogin(loadState = LoadState.LOADING)))
scrollTo(AccountSettingsTestTags.TWO_FACTOR_LOADING)
composeTestRule.onNodeWithTag(AccountSettingsTestTags.TWO_FACTOR_LOADING).assertIsDisplayed()
}
/** An account we couldn't ask about is not an account with no second factors. */
@Test
fun givenTheTwoFactorListCouldntBeLoaded_whenScreenDisplayed_thenIExpectTheFailureMessage() {
setContent(createState(signalLogin = signalLogin(loadState = LoadState.NETWORK_FAILURE)))
scrollTo(AccountSettingsTestTags.TWO_FACTOR_LOAD_FAILED_MESSAGE)
composeTestRule.onNodeWithTag(AccountSettingsTestTags.TWO_FACTOR_LOAD_FAILED_MESSAGE).assertIsDisplayed()
composeTestRule.onNodeWithTag(AccountSettingsTestTags.TWO_FACTOR_LOADING).assertDoesNotExist()
}
@Test
@@ -374,6 +461,14 @@ class AccountSettingsScreenTest {
}
}
private fun signalLogin(
twoFactorMethods: List<TwoFactorMethod> = emptyList(),
loadState: LoadState = LoadState.LOADED,
maxTotpApps: Int = 2
): AccountSettingsState.SignalLogin {
return AccountSettingsState.SignalLogin(twoFactorMethods = twoFactorMethods, loadState = loadState, maxTotpApps = maxTotpApps)
}
private fun scrollTo(testTag: String) {
composeTestRule.onNodeWithTag(AccountSettingsTestTags.SCROLLER)
.performScrollToNode(hasTestTag(testTag))
@@ -1,111 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.passkeys
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import assertk.assertThat
import assertk.assertions.contains
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class PasskeysScreenTest {
companion object {
private val PASSKEYS = listOf(
Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()),
Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis())
)
}
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<PasskeysEvent>()
@Test
fun givenNoPasskeys_whenIClickSetUpAPasskey_thenIExpectSetUpPasskeyEvent() {
setContent(PasskeysState())
composeTestRule.onNodeWithTag(PasskeysTestTags.BUTTON_SET_UP)
.assertIsDisplayed()
.performClick()
assertThat(events).contains(PasskeysEvent.SetUpPasskeyClicked)
}
@Test
fun givenPasskeys_whenIDisplayScreen_thenIExpectARowPerPasskey() {
setContent(PasskeysState(passkeys = PASSKEYS))
for (passkey in PASSKEYS) {
composeTestRule.onNodeWithTag(PasskeysTestTags.SCROLLER).performScrollToNode(hasText(passkey.name))
composeTestRule.onNodeWithText(passkey.name).assertIsDisplayed()
}
}
@Test
fun givenPasskeys_whenIClickAddANewPasskey_thenIExpectSetUpPasskeyEvent() {
setContent(PasskeysState(passkeys = PASSKEYS))
composeTestRule.onNodeWithTag(PasskeysTestTags.BUTTON_SET_UP)
.assertIsDisplayed()
.performClick()
assertThat(events).contains(PasskeysEvent.SetUpPasskeyClicked)
}
@Test
fun givenPasskeys_whenIClickRenameInAPasskeysMenu_thenIExpectRenameEvent() {
setContent(PasskeysState(passkeys = PASSKEYS))
scrollTo(PasskeysTestTags.BUTTON_PASSKEY_MENU)
composeTestRule.onAllNodesWithTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)[0].performClick()
composeTestRule.onNodeWithTag(PasskeysTestTags.MENU_ITEM_RENAME).performClick()
assertThat(events).contains(PasskeysEvent.RenamePasskeyClicked(passkeyId = PASSKEYS[0].id))
}
@Test
fun givenPasskeys_whenIClickRemoveInAPasskeysMenu_thenIExpectRemoveEvent() {
setContent(PasskeysState(passkeys = PASSKEYS))
scrollTo(PasskeysTestTags.BUTTON_PASSKEY_MENU)
composeTestRule.onAllNodesWithTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)[0].performClick()
composeTestRule.onNodeWithTag(PasskeysTestTags.MENU_ITEM_REMOVE).performClick()
assertThat(events).contains(PasskeysEvent.RemovePasskeyClicked(passkeyId = PASSKEYS[0].id))
}
private fun setContent(state: PasskeysState) {
composeTestRule.setContent {
PasskeysScreen(
state = state,
onEvent = { events += it }
)
}
}
private fun scrollTo(testTag: String) {
composeTestRule.onNodeWithTag(PasskeysTestTags.SCROLLER)
.performScrollToNode(hasTestTag(testTag))
}
}
@@ -1,104 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.passkeys
import assertk.assertThat
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class PasskeysViewModelTest {
companion object {
private val PASSKEYS = listOf(
Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()),
Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis())
)
}
private val testDispatcher = UnconfinedTestDispatcher()
private val repository = object : PasskeysRepository {
override fun getPasskeys(): List<Passkey> = PASSKEYS
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `the passkeys are available as soon as the screen opens`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
assertThat(viewModel.state.value.passkeys).isEqualTo(PASSKEYS)
}
@Test
fun `SetUpPasskeyClicked launches passkey creation`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(PasskeysEvent.SetUpPasskeyClicked)
assertThat(actions.last()).isEqualTo(PasskeysAction.LaunchPasskeyCreation)
}
@Test
fun `LearnMoreClicked opens the learn more article`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(PasskeysEvent.LearnMoreClicked)
assertThat(actions.last()).isEqualTo(PasskeysAction.OpenLearnMore)
}
@Test
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(PasskeysEvent.NavigateBackClicked)
assertThat(actions.last()).isEqualTo(PasskeysAction.NavigateBack)
}
@Test
fun `rename and remove produce no actions yet`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(PasskeysEvent.RenamePasskeyClicked(passkeyId = 1))
viewModel.onEvent(PasskeysEvent.RemovePasskeyClicked(passkeyId = 1))
assertThat(actions).isEmpty()
}
private fun TestScope.collectActions(actions: Flow<PasskeysAction>): List<PasskeysAction> {
val collected = mutableListOf<PasskeysAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}
@@ -1,172 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.totpapplist
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import assertk.assertThat
import assertk.assertions.contains
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.appsettings.totpapplist.TotpAppListState.Dialog
import org.signal.appsettings.totpapplist.TotpAppListState.LoadState
import org.signal.core.ui.compose.Dialogs
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class TotpAppListScreenTest {
companion object {
private val APPS = listOf(
TotpApp(id = 1, name = "Bitwarden Authenticator", createdAt = System.currentTimeMillis()),
TotpApp(id = 2, name = "Twilio Authy", createdAt = System.currentTimeMillis())
)
}
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<TotpAppListEvent>()
@Test
fun givenNoApps_whenIDisplayScreen_thenIExpectTheEmptyMessage() {
setContent(TotpAppListState(loadState = LoadState.LOADED))
scrollTo(TotpAppListTestTags.EMPTY_MESSAGE)
composeTestRule.onNodeWithTag(TotpAppListTestTags.EMPTY_MESSAGE).assertIsDisplayed()
}
@Test
fun givenApps_whenIDisplayScreen_thenIExpectARowPerApp() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED))
for (app in APPS) {
composeTestRule.onNodeWithTag(TotpAppListTestTags.SCROLLER).performScrollToNode(hasText(app.name))
composeTestRule.onNodeWithText(app.name).assertIsDisplayed()
}
}
@Test
fun whenIClickAddTotpApp_thenIExpectAddTotpAppClickedEvent() {
setContent(TotpAppListState(loadState = LoadState.LOADED))
composeTestRule.onNodeWithTag(TotpAppListTestTags.BUTTON_ADD)
.assertIsDisplayed()
.performClick()
assertThat(events).contains(TotpAppListEvent.AddTotpAppClicked)
}
@Test
fun givenApps_whenIClickRenameInTheMenu_thenIExpectRenameAppClickedEvent() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED))
scrollTo(TotpAppListTestTags.ROW_APP)
composeTestRule.onAllNodesWithTag(TotpAppListTestTags.BUTTON_APP_MENU)[0].performClick()
composeTestRule.onNodeWithTag(TotpAppListTestTags.MENU_ITEM_RENAME).performClick()
assertThat(events).contains(TotpAppListEvent.RenameAppClicked(appId = APPS[0].id))
}
@Test
fun givenApps_whenIClickRemoveInTheMenu_thenIExpectRemoveAppClickedEvent() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED))
scrollTo(TotpAppListTestTags.ROW_APP)
composeTestRule.onAllNodesWithTag(TotpAppListTestTags.BUTTON_APP_MENU)[0].performClick()
composeTestRule.onNodeWithTag(TotpAppListTestTags.MENU_ITEM_REMOVE).performClick()
assertThat(events).contains(TotpAppListEvent.RemoveAppClicked(appId = APPS[0].id))
}
@Test
fun givenTheConfirmRemoveDialog_whenIDisplayScreen_thenIExpectTheDialog() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.ConfirmRemove(APPS[0].id)))
composeTestRule.onNodeWithTag(TotpAppListTestTags.DIALOG_CONFIRM_REMOVE).assertIsDisplayed()
}
/** The event has to carry the id, since the dialog holding it is dismissed before the confirmation is reported. */
@Test
fun givenTheConfirmRemoveDialog_whenIConfirm_thenIExpectRemoveAppConfirmedForThatApp() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.ConfirmRemove(APPS[1].id)))
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_CONFIRM_BUTTON).performClick()
assertThat(events).contains(TotpAppListEvent.RemoveAppConfirmed(APPS[1].id))
}
@Test
fun givenTheConfirmRemoveDialog_whenICancel_thenIExpectDialogDismissedEvent() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.ConfirmRemove(APPS[0].id)))
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick()
assertThat(events).contains(TotpAppListEvent.DialogDismissed)
}
@Test
fun givenTheMaxAppsDialog_whenIDisplayScreen_thenIExpectTheDialog() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.MaxAppsReached))
composeTestRule.onNodeWithTag(TotpAppListTestTags.DIALOG_MAX_APPS_REACHED).assertIsDisplayed()
}
@Test
fun givenTheMaxAppsDialog_whenIClickLearnMore_thenIExpectLearnMoreAndDismissEvents() {
setContent(TotpAppListState(apps = APPS, loadState = LoadState.LOADED, dialog = Dialog.MaxAppsReached))
composeTestRule.onNodeWithTag(Dialogs.TEST_TAG_ALERT_DIALOG_DISMISS_BUTTON).performClick()
assertThat(events).contains(TotpAppListEvent.LearnMoreClicked)
assertThat(events).contains(TotpAppListEvent.DialogDismissed)
}
@Test
fun whenTheListHasntArrived_thenIExpectASpinnerRatherThanNoTotpApps() {
setContent(TotpAppListState(loadState = LoadState.LOADING))
scrollTo(TotpAppListTestTags.LOADING)
composeTestRule.onNodeWithTag(TotpAppListTestTags.LOADING).assertIsDisplayed()
composeTestRule.onNodeWithTag(TotpAppListTestTags.EMPTY_MESSAGE).assertDoesNotExist()
}
/** An account we couldn't ask about is not an account with no authenticator apps. */
@Test
fun givenTheListCouldntBeLoaded_whenIDisplayScreen_thenIExpectTheFailureMessageRatherThanTheEmptyOne() {
setContent(TotpAppListState(loadState = LoadState.NETWORK_FAILURE))
scrollTo(TotpAppListTestTags.LOAD_FAILED_MESSAGE)
composeTestRule.onNodeWithTag(TotpAppListTestTags.LOAD_FAILED_MESSAGE).assertIsDisplayed()
composeTestRule.onNodeWithTag(TotpAppListTestTags.EMPTY_MESSAGE).assertDoesNotExist()
composeTestRule.onNodeWithTag(TotpAppListTestTags.LOADING).assertDoesNotExist()
}
private fun setContent(state: TotpAppListState) {
composeTestRule.setContent {
TotpAppListScreen(
state = state,
onEvent = { events += it }
)
}
}
private fun scrollTo(testTag: String) {
composeTestRule.onNodeWithTag(TotpAppListTestTags.SCROLLER)
.performScrollToNode(hasTestTag(testTag))
}
}