Add additional authenticator UI scaffolding.

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