Add basic TOTP support with mocked creation.

This commit is contained in:
Greyson Parrelli
2026-09-02 16:11:32 -03:00
committed by Alex Hart
parent 6a032e4f27
commit 62897a309c
163 changed files with 6688 additions and 5119 deletions
@@ -75,7 +75,7 @@ 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.NavigateToAuthenticatorApps -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_authenticatorAppsFragment)
AccountSettingsAction.NavigateToTotpAppList -> 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)
@@ -8,7 +8,7 @@ package org.thoughtcrime.securesms.components.settings.app.account
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.authenticator.TotpRepository
import org.thoughtcrime.securesms.components.settings.app.account.passkeys.AppPasskeysRepository
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -27,7 +27,7 @@ class AccountSettingsRepository {
private val TAG = Log.tag(AccountSettingsRepository::class)
}
private val authenticatorRepository = AuthenticatorRepository()
private val totpRepository = TotpRepository()
private val passkeysRepository = AppPasskeysRepository()
fun hasPin(): Boolean = SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()
@@ -48,7 +48,12 @@ class AccountSettingsRepository {
fun isPhoneNumberless(): Boolean = SignalStore.account.isPhoneNumberless
fun getAuthenticatorAppCount(): Int = authenticatorRepository.getAuthenticatorApps().size
/**
* How many authenticator apps are on the account, or null if we couldn't find out.
*/
suspend fun getTotpAppCount(): Int? {
return (totpRepository.getTotpApps() as? TotpRepository.AppsResult.Success)?.apps?.size
}
fun getPasskeyCount(): Int = passkeysRepository.getPasskeys().size
@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.settings.app.account
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -12,6 +13,7 @@ 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.account.AccountSettingsAction
import org.signal.appsettings.account.AccountSettingsEvent
import org.signal.appsettings.account.AccountSettingsState
@@ -40,7 +42,7 @@ class AccountSettingsViewModel(
val actions: Flow<AccountSettingsAction> = _actions.receiveAsFlow()
init {
refresh()
viewModelScope.launch { refresh() }
}
override suspend fun processEvent(event: AccountSettingsEvent) {
@@ -104,8 +106,8 @@ class AccountSettingsViewModel(
AccountSettingsEvent.AccountAndRecoveryClicked -> {
_actions.send(AccountSettingsAction.NavigateToSignalLoginDetails)
}
AccountSettingsEvent.AuthenticatorAppClicked -> {
_actions.send(AccountSettingsAction.NavigateToAuthenticatorApps)
AccountSettingsEvent.TotpAppClicked -> {
_actions.send(AccountSettingsAction.NavigateToTotpAppList)
}
AccountSettingsEvent.PasskeysClicked -> {
_actions.send(AccountSettingsAction.NavigateToPasskeys)
@@ -147,8 +149,9 @@ class AccountSettingsViewModel(
}
}
private fun refresh() {
private suspend fun refresh() {
val isPhoneNumberless = repository.isPhoneNumberless()
val totpAppCount = repository.getTotpAppCount()
_state.update {
it.copy(
@@ -161,7 +164,7 @@ class AccountSettingsViewModel(
isPhoneNumberless = isPhoneNumberless,
signalLogin = if (isPhoneNumberless) {
AccountSettingsState.SignalLogin(
authenticatorAppCount = repository.getAuthenticatorAppCount(),
totpAppCount = totpAppCount,
passkeyCount = repository.getPasskeyCount()
)
} else {
@@ -1,46 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
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.
*/
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"
/** 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 } }
}
@@ -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 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.")
}
}
}
@@ -1,81 +0,0 @@
/*
* 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()) }
}
}
@@ -1,56 +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.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryAction
import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryScreen
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, 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 viewModel {
AuthenticatorCodeEntryViewModel(AuthenticatorNavArgs.purpose(arguments))
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
CollectActions(viewModel.actions) { action -> handleAction(action) }
AuthenticatorCodeEntryScreen(
state = state,
onEvent = viewModel::onEvent
)
}
private fun handleAction(action: AuthenticatorCodeEntryAction) {
when (action) {
AuthenticatorCodeEntryAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
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()
}
}
}
}
@@ -1,72 +0,0 @@
/*
* 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.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, 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) {
companion object {
private val TAG = Log.tag(AuthenticatorCodeEntryViewModel::class)
}
private val _state = MutableStateFlow(AuthenticatorCodeEntryState(purpose = purpose))
private val _actions = Channel<AuthenticatorCodeEntryAction>(Channel.BUFFERED)
val state: StateFlow<AuthenticatorCodeEntryState> = _state.asStateFlow()
val actions: Flow<AuthenticatorCodeEntryAction> = _actions.receiveAsFlow()
override suspend fun processEvent(event: AuthenticatorCodeEntryEvent) {
when (event) {
AuthenticatorCodeEntryEvent.NavigateBackClicked -> {
_actions.send(AuthenticatorCodeEntryAction.NavigateBack)
}
is AuthenticatorCodeEntryEvent.CodeChanged -> {
val digits = event.code.filter { it.isDigit() }.take(AuthenticatorCodeEntryState.CODE_LENGTH)
_state.update { it.copy(code = digits) }
}
AuthenticatorCodeEntryEvent.DoneClicked -> {
if (!_state.value.canSubmit) {
return
}
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) }
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)
}
}
}
}
}
}
@@ -1,56 +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.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()
}
}
@@ -1,73 +0,0 @@
/*
* 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)
}
}
}
}
@@ -1,47 +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 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)
}
}
@@ -1,25 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
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 getMaxApps(): Int = AuthenticatorAppStore.MAX_APPS
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)
}
@@ -1,71 +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.net.Uri
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.appsettings.authenticatorsetup.AuthenticatorSetupAction
import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupEvent
import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupState
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
/**
* Drives the screen that walks the user through pairing an authenticator app. The setup key is mocked up for now,
* since there's nothing to fetch it from yet.
*/
class AuthenticatorSetupViewModel(
repository: AuthenticatorRepository = AuthenticatorRepository()
) : EventDrivenViewModel<AuthenticatorSetupEvent>(TAG) {
companion object {
private val TAG = Log.tag(AuthenticatorSetupViewModel::class)
private const val ACCOUNT_LABEL = "Signal"
}
private val _state = MutableStateFlow(AuthenticatorSetupState(setupKey = repository.getSetupKey()))
private val _actions = Channel<AuthenticatorSetupAction>(Channel.BUFFERED)
val state: StateFlow<AuthenticatorSetupState> = _state.asStateFlow()
val actions: Flow<AuthenticatorSetupAction> = _actions.receiveAsFlow()
override suspend fun processEvent(event: AuthenticatorSetupEvent) {
when (event) {
AuthenticatorSetupEvent.NavigateBackClicked -> {
_actions.send(AuthenticatorSetupAction.NavigateBack)
}
AuthenticatorSetupEvent.OpenAuthenticatorAppClicked -> {
_actions.send(AuthenticatorSetupAction.LaunchAuthenticatorApp(buildSetupUri(_state.value.setupKey)))
}
AuthenticatorSetupEvent.CopyKeyClicked -> {
_actions.send(AuthenticatorSetupAction.CopyKeyToClipboard(_state.value.setupKey))
_actions.send(AuthenticatorSetupAction.ShowKeyCopied)
}
AuthenticatorSetupEvent.NoAuthenticatorAppFound -> {
_actions.send(AuthenticatorSetupAction.ShowNoAuthenticatorAppFound)
}
AuthenticatorSetupEvent.ContinueClicked -> {
_actions.send(AuthenticatorSetupAction.NavigateToCodeEntry)
}
}
}
private fun buildSetupUri(setupKey: String): String {
return Uri.Builder()
.scheme("otpauth")
.authority("totp")
.appendPath(ACCOUNT_LABEL)
.appendQueryParameter("secret", setupKey)
.appendQueryParameter("issuer", ACCOUNT_LABEL)
.build()
.toString()
}
}
@@ -0,0 +1,92 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
import org.signal.libsignal.net.RequestResult
import java.security.SecureRandom
/**
* Stands in for the service until the gRPC methods land. Nothing here is persisted or sent anywhere, so it lasts only
* as long as the process does.
*
* It doesn't verify one-time passwords -- any code confirms the pending key. It does copy the service's behaviour where
* that behaviour shapes the screens: one pending key at a time, [TotpApi.MAX_KEYS] confirmed keys, ids drawn from the
* lowest free slot, and name length enforced.
*/
class InMemoryTotpApi : TotpApi {
companion object {
/** What the service generates: a 256-bit key. */
private const val KEY_LENGTH_BYTES = 32
}
private val lock = Any()
private val secureRandom = SecureRandom()
private var hasPendingKey = false
private val confirmedKeys = mutableMapOf<Int, TotpApi.Metadata>()
override suspend fun generateKey(): RequestResult<TotpApi.GeneratedKey, TotpApi.GenerateKeyError> = synchronized(lock) {
if (confirmedKeys.size >= TotpApi.MAX_KEYS) {
return RequestResult.NonSuccess(TotpApi.GenerateKeyError.TooManyKeys)
}
hasPendingKey = true
RequestResult.Success(TotpApi.GeneratedKey(key = ByteArray(KEY_LENGTH_BYTES).also { secureRandom.nextBytes(it) }))
}
override suspend fun confirmKey(oneTimePassword: Int, metadata: TotpApi.Metadata): RequestResult<Int, TotpApi.ConfirmKeyError> = synchronized(lock) {
requireNameFits(metadata)
if (!hasPendingKey) {
return RequestResult.NonSuccess(TotpApi.ConfirmKeyError.NotVerified)
}
if (confirmedKeys.size >= TotpApi.MAX_KEYS) {
return RequestResult.NonSuccess(TotpApi.ConfirmKeyError.TooManyKeys)
}
val keyId = nextKeyId()
confirmedKeys[keyId] = metadata
hasPendingKey = false
RequestResult.Success(keyId)
}
override suspend fun listKeys(): RequestResult<List<TotpApi.RemoteKey>, Nothing> = synchronized(lock) {
RequestResult.Success(
confirmedKeys.entries
.sortedBy { it.key }
.map { (keyId, metadata) -> TotpApi.RemoteKey(keyId = keyId, metadata = metadata) }
)
}
override suspend fun setKeyMetadata(keyId: Int, metadata: TotpApi.Metadata): RequestResult<Unit, TotpApi.SetKeyMetadataError> = synchronized(lock) {
requireNameFits(metadata)
if (keyId !in confirmedKeys) {
return RequestResult.NonSuccess(TotpApi.SetKeyMetadataError.KeyNotFound)
}
confirmedKeys[keyId] = metadata
RequestResult.Success(Unit)
}
override suspend fun removeKey(keyId: Int): RequestResult<Unit, Nothing> = synchronized(lock) {
confirmedKeys.remove(keyId)
RequestResult.Success(Unit)
}
/** The service hands out the lowest free id rather than counting upwards, so removing a key frees its id for reuse. */
private fun nextKeyId(): Int = TotpApi.KEY_ID_RANGE.first { it !in confirmedKeys }
private fun requireNameFits(metadata: TotpApi.Metadata) {
require(metadata.name.toByteArray(Charsets.UTF_8).size <= TotpApi.Metadata.NAME_MAX_LENGTH) {
"Name must be at most ${TotpApi.Metadata.NAME_MAX_LENGTH} bytes of UTF-8"
}
}
}
@@ -0,0 +1,86 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
import org.signal.core.util.censor
import org.signal.libsignal.net.BadRequestError
import org.signal.libsignal.net.RequestResult
import java.time.Instant
/**
* All TOTP operations.
*/
interface TotpApi {
companion object {
/** How many confirmed keys an account may have, which the service enforces. */
const val MAX_KEYS = 2
/** The ids the service will assign, which fit in a byte with the sign bit clear. */
val KEY_ID_RANGE = 0..127
}
/**
* Generates a new pending key, replacing any pending key already on the account. The key doesn't take effect, or show
* up in [listKeys], until [confirmKey] proves the caller kept a copy of it.
*/
suspend fun generateKey(): RequestResult<GeneratedKey, GenerateKeyError>
/**
* Confirms the pending key by proving a one-time password can be derived from it, and attaches [metadata] to it,
* returning the id the service assigned.
*/
suspend fun confirmKey(oneTimePassword: Int, metadata: Metadata): RequestResult<Int, ConfirmKeyError>
/** The confirmed keys on the account. Key material is never returned, only metadata and parameters. */
suspend fun listKeys(): RequestResult<List<RemoteKey>, Nothing>
/** Replaces the metadata attached to a confirmed key. */
suspend fun setKeyMetadata(keyId: Int, metadata: Metadata): RequestResult<Unit, SetKeyMetadataError>
/** Removes a key, which also succeeds when there's no key with that id, so retries look the same as the first try. */
suspend fun removeKey(keyId: Int): RequestResult<Unit, Nothing>
data class Metadata(val name: String, val createdAt: Instant) {
override fun toString(): String = "Metadata(name=${name.censor()}, createdAt=$createdAt)"
companion object {
/** How long [name] may be, in bytes of UTF-8, which is the room the service's metadata blob leaves for it. */
const val NAME_MAX_LENGTH = 98
}
}
data class GeneratedKey(val key: ByteArray) {
override fun equals(other: Any?): Boolean = other is GeneratedKey && key.contentEquals(other.key)
override fun hashCode(): Int = key.contentHashCode()
override fun toString(): String = "GeneratedKey()"
}
data class RemoteKey(
/** The account-specific id, in [KEY_ID_RANGE]. */
val keyId: Int,
val metadata: Metadata
) {
override fun toString(): String = "RemoteKey(keyId=$keyId)"
}
sealed interface GenerateKeyError : BadRequestError {
/** The account already has [MAX_KEYS] keys. */
data object TooManyKeys : GenerateKeyError
}
sealed interface ConfirmKeyError : BadRequestError {
/** The password was wrong, the clocks are too far apart, or there was no pending key. The service can't tell us which. */
data object NotVerified : ConfirmKeyError
/** The account filled up with keys between generating this one and confirming it. */
data object TooManyKeys : ConfirmKeyError
}
sealed interface SetKeyMetadataError : BadRequestError {
data object KeyNotFound : SetKeyMetadataError
}
}
@@ -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 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()
}
}
@@ -0,0 +1,109 @@
/*
* 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) }
}
}
}
}
@@ -0,0 +1,53 @@
/*
* 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.totpcodeentry.TotpCodeEntryAction
import org.signal.appsettings.totpcodeentry.TotpCodeEntryScreen
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
/**
* Wrapper around [org.signal.appsettings.totpcodeentry.TotpCodeEntryScreen]
*/
class TotpCodeEntryFragment : ComposeFragment() {
/** A one-time code is not a credential, and a password manager offering to save one every time is pure noise. */
override val autofillEnabled: Boolean = false
private val viewModel: TotpCodeEntryViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
CollectActions(viewModel.actions) { action -> handleAction(action) }
TotpCodeEntryScreen(
state = state,
onEvent = viewModel::onEvent
)
}
private fun handleAction(action: TotpCodeEntryAction) {
when (action) {
TotpCodeEntryAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
is TotpCodeEntryAction.NavigateToNaming -> {
val args = Bundle().apply { putLong(TotpNavArgs.ARG_APP_ID, action.appId) }
findNavController().safeNavigate(R.id.action_authenticatorCodeEntryFragment_to_authenticatorNameFragment, args)
}
TotpCodeEntryAction.NavigateToSetup -> findNavController().popBackStack(R.id.authenticatorSetupFragment, false)
}
}
}
@@ -0,0 +1,83 @@
/*
* 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.totpcodeentry.TotpCodeEntryAction
import org.signal.appsettings.totpcodeentry.TotpCodeEntryEvent
import org.signal.appsettings.totpcodeentry.TotpCodeEntryState
import org.signal.appsettings.totpcodeentry.TotpCodeEntryState.Error
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, which is how the service learns the user
* kept a copy of the key it just handed out.
*/
class TotpCodeEntryViewModel(
private val repository: TotpRepository = TotpRepository()
) : EventDrivenViewModel<TotpCodeEntryEvent>(TAG) {
companion object {
private val TAG = Log.tag(TotpCodeEntryViewModel::class)
}
private val _state = MutableStateFlow(TotpCodeEntryState())
private val _actions = Channel<TotpCodeEntryAction>(Channel.BUFFERED)
val state: StateFlow<TotpCodeEntryState> = _state.asStateFlow()
val actions: Flow<TotpCodeEntryAction> = _actions.receiveAsFlow()
override suspend fun processEvent(event: TotpCodeEntryEvent) {
when (event) {
TotpCodeEntryEvent.NavigateBackClicked -> {
_actions.send(TotpCodeEntryAction.NavigateBack)
}
is TotpCodeEntryEvent.CodeChanged -> {
val digits = event.code.filter { it.isDigit() }.take(TotpCodeEntryState.CODE_LENGTH)
_state.update { it.copy(code = digits, error = Error.None) }
}
TotpCodeEntryEvent.DoneClicked -> {
if (!_state.value.canSubmit) {
return
}
_state.update { it.copy(submitting = true, error = Error.None) }
confirmNewApp()
}
}
}
private suspend fun confirmNewApp() {
when (val result = repository.confirmPendingApp(_state.value.code)) {
is TotpRepository.ConfirmResult.Success -> {
_actions.send(TotpCodeEntryAction.NavigateToNaming(result.appId))
}
TotpRepository.ConfirmResult.IncorrectCode -> {
fail(Error.IncorrectCode)
}
TotpRepository.ConfirmResult.TooManyApps -> {
Log.w(TAG, "The account filled up while this key was pending. Going back to setup, which will explain the limit.")
_state.update { it.copy(submitting = false) }
_actions.send(TotpCodeEntryAction.NavigateToSetup)
}
TotpRepository.ConfirmResult.NetworkFailure -> {
fail(Error.NetworkFailure)
}
}
}
private fun fail(error: Error) {
_state.update { it.copy(submitting = false, error = error) }
}
}
@@ -0,0 +1,62 @@
/*
* 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.totpnameentry.TotpNameEntryAction
import org.signal.appsettings.totpnameentry.TotpNameEntryScreen
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
/**
* Wrapper around [TotpNameEntryScreen].
*/
class TotpNameEntryFragment : ComposeFragment() {
/** The name the user gives an authenticator app is not a credential, so there's nothing here worth offering to save. */
override val autofillEnabled: Boolean = false
private val viewModel: TotpNameEntryViewModel by viewModel {
TotpNameEntryViewModel(
appId = TotpNavArgs.appId(arguments) ?: TotpNavArgs.NO_APP_ID,
renamedApp = TotpNavArgs.renamedApp(arguments)
)
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
CollectActions(viewModel.actions) { action -> handleAction(action) }
TotpNameEntryScreen(
state = state,
onEvent = viewModel::onEvent
)
}
private fun handleAction(action: TotpNameEntryAction) {
when (action) {
TotpNameEntryAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
TotpNameEntryAction.NavigateToTotpAppList -> findNavController().popBackStack(R.id.authenticatorAppsFragment, 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)
}
}
private fun toast(@StringRes message: Int) {
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
}
}
@@ -0,0 +1,100 @@
/*
* 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.totpapplist.TotpApp
import org.signal.appsettings.totpnameentry.TotpNameEntryAction
import org.signal.appsettings.totpnameentry.TotpNameEntryEvent
import org.signal.appsettings.totpnameentry.TotpNameEntryState
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.StringUtil
import org.signal.core.util.logging.Log
/**
* Handles both naming and renaming TOTP apps.
*/
class TotpNameEntryViewModel(
private val appId: Long,
private val renamedApp: TotpApp? = null,
private val repository: TotpRepository = TotpRepository()
) : EventDrivenViewModel<TotpNameEntryEvent>(TAG) {
companion object {
private val TAG = Log.tag(TotpNameEntryViewModel::class)
}
private val breakIterator = BreakIteratorCompat.getInstance()
private val _state = MutableStateFlow(
TotpNameEntryState(
renaming = renamedApp != null,
name = renamedApp?.name?.trimNameToLengthLimits() ?: ""
)
)
private val _actions = Channel<TotpNameEntryAction>(Channel.BUFFERED)
val state: StateFlow<TotpNameEntryState> = _state.asStateFlow()
val actions: Flow<TotpNameEntryAction> = _actions.receiveAsFlow()
override suspend fun processEvent(event: TotpNameEntryEvent) {
when (event) {
TotpNameEntryEvent.NavigateBackClicked -> {
_actions.send(TotpNameEntryAction.NavigateBack)
}
is TotpNameEntryEvent.NameChanged -> {
_state.update { it.copy(name = event.name.trimNameToLengthLimits()) }
}
TotpNameEntryEvent.NextClicked -> {
if (!_state.value.canSubmit) {
return
}
val name = _state.value.name.trim()
_state.update { it.copy(submitting = true) }
val result = if (renamedApp != null) {
repository.renameTotpApp(renamedApp, name)
} else {
repository.nameNewTotpApp(appId, name)
}
when (result) {
TotpRepository.UpdateResult.Success -> {
_actions.send(if (renamedApp != null) TotpNameEntryAction.ShowTotpAppRenamed else TotpNameEntryAction.ShowTotpAppSetUp)
_actions.send(TotpNameEntryAction.NavigateToTotpAppList)
}
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)
}
TotpRepository.UpdateResult.NetworkFailure -> {
_state.update { it.copy(submitting = false) }
_actions.send(TotpNameEntryAction.ShowNameNotSaved)
}
}
}
}
}
/** Keeps totp name within length limits */
private fun String.trimNameToLengthLimits(): String {
val input = this
val graphemeTruncated = breakIterator
.apply { setText(input) }
.take(TotpRepository.MAX_NAME_LENGTH_GRAPHEMES)
.toString()
return StringUtil.trimToFit(graphemeTruncated, TotpRepository.MAX_NAME_LENGTH_BYTES)
}
}
@@ -0,0 +1,44 @@
/*
* 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.totpapplist.TotpApp
/**
* 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 TotpNavArgs {
/** The app being acted on. [NO_APP_ID] when nothing identified one. */
const val ARG_APP_ID = "app_id"
const val NO_APP_ID = -1L
/** The rest of the app being renamed. Absent when the screen is naming a newly paired app instead. */
const val ARG_APP_NAME = "app_name"
const val ARG_APP_CREATED_AT = "app_created_at"
const val NO_CREATED_AT = -1L
/** The app id in [arguments], or null when there isn't one. */
fun appId(arguments: Bundle?): Long? = arguments?.getLong(ARG_APP_ID, NO_APP_ID)?.takeIf { it != NO_APP_ID }
/** Packs [app] into [bundle] for the rename flow, so the name screen doesn't have to fetch what the list already had. */
fun putRenamedApp(bundle: Bundle, app: TotpApp) {
bundle.putLong(ARG_APP_ID, app.id)
bundle.putString(ARG_APP_NAME, app.name)
bundle.putLong(ARG_APP_CREATED_AT, app.createdAt)
}
/** The app being renamed, or null when [arguments] describe naming a newly paired app rather than a rename. */
fun renamedApp(arguments: Bundle?): TotpApp? {
val appId = appId(arguments) ?: return null
val createdAt = arguments?.getLong(ARG_APP_CREATED_AT, NO_CREATED_AT)?.takeIf { it != NO_CREATED_AT } ?: return null
return TotpApp(id = appId, name = arguments.getString(ARG_APP_NAME).orEmpty(), createdAt = createdAt)
}
}
@@ -0,0 +1,243 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
import org.signal.appsettings.totpapplist.TotpApp
import org.signal.core.util.Base32
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import java.net.URLEncoder
import java.time.Instant
/**
* Everything the authenticator app screens need, sitting between them and the TOTP operations on [TotpApi].
*/
class TotpRepository(
private val api: TotpApi = SHARED_API,
private val clock: () -> Long = System::currentTimeMillis
) {
companion object {
private val TAG = Log.tag(TotpRepository::class)
/** Shared so that every screen in the flow sees the same state until there's a service behind this. */
private val SHARED_API: TotpApi = InMemoryTotpApi()
private const val ISSUER = "Signal"
/** What the service uses, and what every authenticator app supports without reading a single URI parameter. */
private const val ALGORITHM = "SHA1"
private const val DIGITS = 6
private const val PERIOD_SECONDS = 30
/** How many characters of the display form go between spaces. */
private const val DISPLAY_GROUP_SIZE = 4
const val MAX_NAME_LENGTH_BYTES = TotpApi.Metadata.NAME_MAX_LENGTH
const val MAX_NAME_LENGTH_GRAPHEMES = 30
}
fun getMaxApps(): Int {
return TotpApi.MAX_KEYS
}
/**
* Asks the service for a new key, returning what the setup screen needs to hand it to an authenticator app. The
* service holds the pending key from here until [confirmPendingApp], so nothing is kept on this side.
*/
suspend fun beginSetup(accountName: String): BeginSetupResult {
return when (val result = api.generateKey()) {
is RequestResult.Success -> {
val key = result.result.key
BeginSetupResult.Success(
setupUri = buildSetupUri(key = key, accountName = accountName),
displayKey = Base32.encode(key).chunked(DISPLAY_GROUP_SIZE).joinToString(" "),
clipboardKey = Base32.encode(key)
)
}
is RequestResult.NonSuccess -> {
when (result.error) {
TotpApi.GenerateKeyError.TooManyKeys -> BeginSetupResult.TooManyApps
}
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "Couldn't generate a key.", result.networkError)
BeginSetupResult.NetworkFailure
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "Couldn't generate a key.", result.cause)
BeginSetupResult.NetworkFailure
}
}
}
/**
* Confirms the pending key with a code from the user's authenticator app.
*
* The key is confirmed without a name, because the service wants metadata at confirmation time and the user doesn't
* name their app until the screen after this one. Naming it later means a brief window where a key has no name, which
* is a better failure than a window where the second factor isn't active yet.
*/
suspend fun confirmPendingApp(code: String): ConfirmResult {
val oneTimePassword = code.toIntOrNull() ?: return ConfirmResult.IncorrectCode
val metadata = TotpApi.Metadata(name = "", createdAt = Instant.ofEpochMilli(clock()))
return when (val result = api.confirmKey(oneTimePassword = oneTimePassword, metadata = metadata)) {
is RequestResult.Success -> {
ConfirmResult.Success(appId = result.result.toLong())
}
is RequestResult.NonSuccess -> when (result.error) {
TotpApi.ConfirmKeyError.NotVerified -> ConfirmResult.IncorrectCode
TotpApi.ConfirmKeyError.TooManyKeys -> {
Log.w(TAG, "The account filled up with keys between generating this one and confirming it.")
ConfirmResult.TooManyApps
}
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "Couldn't confirm the pending key.", result.networkError)
ConfirmResult.NetworkFailure
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "Couldn't confirm the pending key.", result.cause)
ConfirmResult.NetworkFailure
}
}
}
/** The authenticator apps on the account, newest id last. */
suspend fun getTotpApps(): AppsResult {
return when (val result = api.listKeys()) {
is RequestResult.Success -> {
AppsResult.Success(
result.result.map { key ->
TotpApp(
id = key.keyId.toLong(),
name = key.metadata.name,
createdAt = key.metadata.createdAt.toEpochMilli()
)
}
)
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "Couldn't list keys.", result.networkError)
AppsResult.NetworkFailure
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "Couldn't list keys.", result.cause)
AppsResult.NetworkFailure
}
is RequestResult.NonSuccess -> error("Code branch is unreachable")
}
}
/** Renames [app], which means handing the whole metadata blob back to the service. */
suspend fun renameTotpApp(app: TotpApp, name: String): UpdateResult {
return setMetadata(app.id, TotpApi.Metadata(name = name, createdAt = Instant.ofEpochMilli(app.createdAt)))
}
/** Names a newly confirmed app, which was confirmed without one moments ago. */
suspend fun nameNewTotpApp(appId: Long, name: String): UpdateResult {
return setMetadata(appId, TotpApi.Metadata(name = name, createdAt = Instant.ofEpochMilli(clock())))
}
suspend fun removeTotpApp(appId: Long): UpdateResult {
return when (val result = api.removeKey(appId.toInt())) {
is RequestResult.Success -> UpdateResult.Success
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "Couldn't remove the key.", result.networkError)
UpdateResult.NetworkFailure
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "Couldn't remove the key.", result.cause)
UpdateResult.NetworkFailure
}
is RequestResult.NonSuccess -> error("Code branch is unreachable")
}
}
private suspend fun setMetadata(appId: Long, metadata: TotpApi.Metadata): UpdateResult {
return when (val result = api.setKeyMetadata(keyId = appId.toInt(), metadata = metadata)) {
is RequestResult.Success -> UpdateResult.Success
is RequestResult.NonSuccess -> when (result.error) {
TotpApi.SetKeyMetadataError.KeyNotFound -> UpdateResult.AppNotFound
}
is RequestResult.RetryableNetworkError -> {
Log.w(TAG, "Couldn't set key metadata.", result.networkError)
UpdateResult.NetworkFailure
}
is RequestResult.ApplicationError -> {
Log.w(TAG, "Couldn't set key metadata.", result.cause)
UpdateResult.NetworkFailure
}
}
}
/**
* The `otpauth://` URI that hands the key to an authenticator app, following the de facto Key Uri Format every app
* implements. Note that a lot of apps ignore params like "algorithm", but we set them just in case.
*/
private fun buildSetupUri(key: ByteArray, accountName: String): String {
val label = if (accountName.isBlank()) encode(ISSUER) else "${encode(ISSUER)}:${encode(accountName)}"
val query = listOf(
"secret" to Base32.encode(key),
"issuer" to ISSUER,
"algorithm" to ALGORITHM,
"digits" to DIGITS.toString(),
"period" to PERIOD_SECONDS.toString()
).joinToString("&") { (name, value) -> "$name=${encode(value)}" }
return "otpauth://totp/$label?$query"
}
/**
* [URLEncoder] targets form encoding rather than URIs, so it renders a space as `+` where a URI needs `%20`, and
* escapes `~` where a URI leaves it alone.
*/
private fun encode(value: String): String {
return URLEncoder.encode(value, Charsets.UTF_8.name())
.replace("+", "%20")
.replace("%7E", "~")
}
sealed interface BeginSetupResult {
data class Success(val setupUri: String, val displayKey: String, val clipboardKey: String) : BeginSetupResult {
override fun toString(): String = "Success()"
}
/** The account already has as many authenticator apps as it's allowed. */
data object TooManyApps : BeginSetupResult
data object NetworkFailure : BeginSetupResult
}
sealed interface ConfirmResult {
data class Success(val appId: Long) : ConfirmResult
data object IncorrectCode : ConfirmResult
/** Another device filled the account up between generating the key and confirming it. */
data object TooManyApps : ConfirmResult
data object NetworkFailure : ConfirmResult
}
sealed interface AppsResult {
data class Success(val apps: List<TotpApp>) : AppsResult
data object NetworkFailure : AppsResult
}
sealed interface UpdateResult {
data object Success : UpdateResult
data object AppNotFound : UpdateResult
data object NetworkFailure : UpdateResult
}
}
@@ -15,9 +15,9 @@ import androidx.compose.runtime.getValue
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupAction
import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupEvent
import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupScreen
import org.signal.appsettings.totpsetup.TotpSetupAction
import org.signal.appsettings.totpsetup.TotpSetupEvent
import org.signal.appsettings.totpsetup.TotpSetupScreen
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.util.Util
@@ -27,16 +27,16 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.signal.appsettings.R as AppSettingsR
/**
* Walks the user through setting up an authenticator app. Carries out the [AuthenticatorSetupAction]s that need an
* Walks the user through setting up an authenticator app. Carries out the [TotpSetupAction]s that need an
* Activity or the nav graph.
*/
class AuthenticatorSetupFragment : ComposeFragment() {
class TotpSetupFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(AuthenticatorSetupFragment::class)
private val TAG = Log.tag(TotpSetupFragment::class)
}
private val viewModel: AuthenticatorSetupViewModel by viewModels()
private val viewModel: TotpSetupViewModel by viewModels()
@Composable
override fun FragmentContent() {
@@ -44,29 +44,29 @@ class AuthenticatorSetupFragment : ComposeFragment() {
CollectActions(viewModel.actions) { action -> handleAction(action) }
AuthenticatorSetupScreen(
TotpSetupScreen(
state = state,
onEvent = viewModel::onEvent
)
}
private fun handleAction(action: AuthenticatorSetupAction) {
private fun handleAction(action: TotpSetupAction) {
when (action) {
AuthenticatorSetupAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
is AuthenticatorSetupAction.LaunchAuthenticatorApp -> launchAuthenticatorApp(action.uri)
is AuthenticatorSetupAction.CopyKeyToClipboard -> Util.copyToClipboard(requireContext(), action.key)
AuthenticatorSetupAction.ShowKeyCopied -> toast(AppSettingsR.string.AuthenticatorSetupScreen__copied_to_clipboard)
AuthenticatorSetupAction.ShowNoAuthenticatorAppFound -> toast(AppSettingsR.string.AuthenticatorSetupScreen__no_authenticator_app_found)
AuthenticatorSetupAction.NavigateToCodeEntry -> findNavController().safeNavigate(R.id.action_authenticatorSetupFragment_to_authenticatorCodeEntryFragment)
TotpSetupAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed()
is TotpSetupAction.LaunchTotpApp -> launchTotpApp(action.uri)
is TotpSetupAction.CopyKeyToClipboard -> Util.copyToClipboard(requireContext(), action.key)
TotpSetupAction.ShowKeyCopied -> toast(AppSettingsR.string.TotpSetupScreen__copied_to_clipboard)
TotpSetupAction.ShowNoTotpAppFound -> toast(AppSettingsR.string.TotpSetupScreen__no_authenticator_app_found)
TotpSetupAction.NavigateToCodeEntry -> findNavController().safeNavigate(R.id.action_authenticatorSetupFragment_to_authenticatorCodeEntryFragment)
}
}
private fun launchAuthenticatorApp(uri: String) {
private fun launchTotpApp(uri: String) {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)))
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "No app is willing to handle the authenticator setup link.", e)
viewModel.onEvent(AuthenticatorSetupEvent.NoAuthenticatorAppFound)
viewModel.onEvent(TotpSetupEvent.NoTotpAppFound)
}
}
@@ -0,0 +1,95 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
import androidx.annotation.VisibleForTesting
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.totpsetup.TotpSetupAction
import org.signal.appsettings.totpsetup.TotpSetupEvent
import org.signal.appsettings.totpsetup.TotpSetupState
import org.signal.appsettings.totpsetup.TotpSetupState.Dialog
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.util.UUID
class TotpSetupViewModel(
private val repository: TotpRepository = TotpRepository(),
private val accountName: String = accountNameFor(SignalStore.account.aci?.rawUuid)
) : EventDrivenViewModel<TotpSetupEvent>(TAG) {
companion object {
private val TAG = Log.tag(TotpSetupViewModel::class)
@VisibleForTesting
fun accountNameFor(aci: UUID?): String = aci?.toString()?.substringBefore('-')?.uppercase().orEmpty()
}
private val _state = MutableStateFlow(TotpSetupState())
private val _actions = Channel<TotpSetupAction>(Channel.BUFFERED)
val state: StateFlow<TotpSetupState> = _state.asStateFlow()
val actions: Flow<TotpSetupAction> = _actions.receiveAsFlow()
/** The URI and clipboard forms of the key, which differ from the grouped form the screen shows. */
private var setupUri: String = ""
private var clipboardKey: String = ""
init {
viewModelScope.launch { beginSetup() }
}
override suspend fun processEvent(event: TotpSetupEvent) {
when (event) {
TotpSetupEvent.NavigateBackClicked -> {
_actions.send(TotpSetupAction.NavigateBack)
}
TotpSetupEvent.OpenTotpAppClicked -> {
_actions.send(TotpSetupAction.LaunchTotpApp(setupUri))
}
TotpSetupEvent.CopyKeyClicked -> {
_actions.send(TotpSetupAction.CopyKeyToClipboard(clipboardKey))
_actions.send(TotpSetupAction.ShowKeyCopied)
}
TotpSetupEvent.NoTotpAppFound -> {
_actions.send(TotpSetupAction.ShowNoTotpAppFound)
}
TotpSetupEvent.ContinueClicked -> {
_actions.send(TotpSetupAction.NavigateToCodeEntry)
}
TotpSetupEvent.DialogDismissed -> {
_state.update { it.copy(dialog = Dialog.None) }
_actions.send(TotpSetupAction.NavigateBack)
}
}
}
private suspend fun beginSetup() {
when (val result = repository.beginSetup(accountName)) {
is TotpRepository.BeginSetupResult.Success -> {
setupUri = result.setupUri
clipboardKey = result.clipboardKey
_state.update { it.copy(setupKey = result.displayKey, loading = false) }
}
TotpRepository.BeginSetupResult.TooManyApps -> {
Log.w(TAG, "The account already has as many authenticator apps as it's allowed.")
_state.update { it.copy(loading = false, dialog = Dialog.MaxAppsReached(repository.getMaxApps())) }
}
TotpRepository.BeginSetupResult.NetworkFailure -> {
Log.w(TAG, "Couldn't reach the service to start setup.")
_state.update { it.copy(loading = false, dialog = Dialog.NetworkFailure) }
}
}
}
}
@@ -183,7 +183,8 @@ class AppRegistrationNetworkController(
pniPreKeys: PreKeyCollection?,
fcmToken: String?,
skipDeviceTransfer: Boolean,
aci: ACI?
aci: ACI?,
totp: Int?
): RequestResult<RegisterAccountResponse, RegisterAccountError> {
return registrationApi.registerAccount(
e164 = e164,
@@ -196,7 +197,8 @@ class AppRegistrationNetworkController(
pniPreKeys = pniPreKeys,
fcmToken = fcmToken,
skipDeviceTransfer = skipDeviceTransfer,
aci = aci
aci = aci,
totp = totp
)
}
@@ -246,7 +246,7 @@
<fragment
android:id="@+id/authenticatorAppsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.authenticator.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"
@@ -262,18 +262,11 @@
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"
android:name="org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpSetupFragment"
android:label="authenticator_setup_fragment">
<action
android:id="@+id/action_authenticatorSetupFragment_to_authenticatorCodeEntryFragment"
@@ -286,7 +279,7 @@
<fragment
android:id="@+id/authenticatorCodeEntryFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.authenticator.AuthenticatorCodeEntryFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpCodeEntryFragment"
android:label="authenticator_code_entry_fragment">
<action
android:id="@+id/action_authenticatorCodeEntryFragment_to_authenticatorNameFragment"
@@ -295,25 +288,25 @@
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<argument
android:name="purpose"
android:defaultValue="ADD"
app:argType="string" />
<fragment
android:id="@+id/authenticatorNameFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpNameEntryFragment"
android:label="authenticator_name_fragment">
<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:name="app_name"
android:defaultValue=""
app:argType="string" />
<argument
android:name="app_created_at"
android:defaultValue="-1L"
app:argType="long" />
</fragment>
@@ -63,7 +63,7 @@ class AccountSettingsViewModelTest {
every { repository.isClientDeprecated() } returns false
every { repository.getPinKeyboardType() } returns PinKeyboardType.NUMERIC
every { repository.isPhoneNumberless() } returns false
every { repository.getAuthenticatorAppCount() } returns 0
coEvery { repository.getTotpAppCount() } returns 0
every { repository.getPasskeyCount() } returns 0
every { repository.verifyLocalPin(any()) } answers { firstArg<String>() == CORRECT_PIN }
coEvery { repository.setRegistrationLockEnabled(any()) } returns true
@@ -303,16 +303,27 @@ class AccountSettingsViewModelTest {
@Test
fun `the Signal Login section is filled in when the account is phone-numberless`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
every { repository.getAuthenticatorAppCount() } returns 2
coEvery { repository.getTotpAppCount() } returns 2
every { repository.getPasskeyCount() } returns 8
val viewModel = createViewModel()
assertThat(viewModel.state.value.isPhoneNumberless).isTrue()
assertThat(viewModel.state.value.signalLogin?.authenticatorAppCount).isEqualTo(2)
assertThat(viewModel.state.value.signalLogin?.totpAppCount).isEqualTo(2)
assertThat(viewModel.state.value.signalLogin?.passkeyCount).isEqualTo(8)
}
/** Zero would render as "no authenticator apps", which is a claim we can't make when we couldn't reach the service. */
@Test
fun `a count we couldn't fetch is null rather than zero`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
coEvery { repository.getTotpAppCount() } returns null
val viewModel = createViewModel()
assertThat(viewModel.state.value.signalLogin?.totpAppCount).isNull()
}
@Test
fun `AccountAndRecoveryClicked opens the Signal Login details screen`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
@@ -326,15 +337,15 @@ class AccountSettingsViewModelTest {
}
@Test
fun `AuthenticatorAppClicked opens the authenticator apps screen`() = runTest(testDispatcher) {
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.AuthenticatorAppClicked)
viewModel.onEvent(AccountSettingsEvent.TotpAppClicked)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToAuthenticatorApps)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToTotpAppList)
}
@Test
@@ -1,195 +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.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
}
}
@@ -1,125 +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.contains
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
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.authenticatorcodeentry.AuthenticatorCodeEntryAction
import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryEvent
import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryState.Purpose
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@OptIn(ExperimentalCoroutinesApi::class)
class AuthenticatorCodeEntryViewModelTest {
companion object {
private const val FULL_CODE = "123456"
}
private val testDispatcher = UnconfinedTestDispatcher()
@get:Rule
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
clearApps()
}
@After
fun tearDown() {
Dispatchers.resetMain()
clearApps()
}
@Test
fun `non-digits are dropped and the code is capped at six digits`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged("12a34 5678"))
assertThat(viewModel.state.value.code).isEqualTo(FULL_CODE)
}
@Test
fun `a partial code can't be submitted`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged("123"))
assertThat(viewModel.state.value.canSubmit).isFalse()
viewModel.onEvent(AuthenticatorCodeEntryEvent.DoneClicked)
assertThat(actions).isEmpty()
}
@Test
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(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 = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AuthenticatorCodeEntryEvent.NavigateBackClicked)
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) }
return collected
}
}
@@ -1,134 +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.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
}
}
@@ -1,60 +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.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)
}
}
@@ -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 android.app.Application
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
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.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupAction
import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupEvent
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class AuthenticatorSetupViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
@get:Rule
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `the setup key is available as soon as the screen opens`() = runTest(testDispatcher) {
val viewModel = AuthenticatorSetupViewModel()
assertThat(viewModel.state.value.setupKey).isEqualTo(AuthenticatorAppStore.MOCK_SETUP_KEY)
}
@Test
fun `OpenAuthenticatorAppClicked hands off a link carrying the setup key`() = runTest(testDispatcher) {
val viewModel = AuthenticatorSetupViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AuthenticatorSetupEvent.OpenAuthenticatorAppClicked)
val action = actions.last()
assertThat(action).isInstanceOf(AuthenticatorSetupAction.LaunchAuthenticatorApp::class)
assertThat((action as AuthenticatorSetupAction.LaunchAuthenticatorApp).uri).contains(AuthenticatorAppStore.MOCK_SETUP_KEY)
}
@Test
fun `CopyKeyClicked copies the key and tells the user`() = runTest(testDispatcher) {
val viewModel = AuthenticatorSetupViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AuthenticatorSetupEvent.CopyKeyClicked)
assertThat(actions).contains(AuthenticatorSetupAction.CopyKeyToClipboard(AuthenticatorAppStore.MOCK_SETUP_KEY))
assertThat(actions.last()).isEqualTo(AuthenticatorSetupAction.ShowKeyCopied)
}
@Test
fun `ContinueClicked moves on to code entry`() = runTest(testDispatcher) {
val viewModel = AuthenticatorSetupViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AuthenticatorSetupEvent.ContinueClicked)
assertThat(actions.last()).isEqualTo(AuthenticatorSetupAction.NavigateToCodeEntry)
}
@Test
fun `NoAuthenticatorAppFound reports the failure`() = runTest(testDispatcher) {
val viewModel = AuthenticatorSetupViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AuthenticatorSetupEvent.NoAuthenticatorAppFound)
assertThat(actions.last()).isEqualTo(AuthenticatorSetupAction.ShowNoAuthenticatorAppFound)
}
private fun TestScope.collectActions(actions: Flow<AuthenticatorSetupAction>): List<AuthenticatorSetupAction> {
val collected = mutableListOf<AuthenticatorSetupAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}
@@ -0,0 +1,129 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
import assertk.assertFailure
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.signal.libsignal.net.RequestResult
import java.time.Instant
/**
* Covers the parts of the stand-in that copy behaviour the service is strict about, since those are the parts most
* likely to be wrong once there's a real service behind [TotpApi].
*/
class InMemoryTotpApiTest {
companion object {
private const val NOW = 1_700_000_000_000L
private const val CODE = 123456
private val METADATA = TotpApi.Metadata(name = "Aegis", createdAt = Instant.ofEpochMilli(NOW))
private val OTHER_METADATA = TotpApi.Metadata(name = "Aegis on my tablet", createdAt = Instant.ofEpochMilli(NOW))
}
private val api = InMemoryTotpApi()
@Test
fun `a generated key is 32 bytes`() = runTest {
val result = api.generateKey()
assertThat(result).isInstanceOf(RequestResult.Success::class)
assertThat((result as RequestResult.Success).result.key.size).isEqualTo(32)
}
@Test
fun `a pending key doesn't show up until it's confirmed`() = runTest {
api.generateKey()
assertThat(listedKeys()).hasSize(0)
}
@Test
fun `a confirmed key is assigned the lowest free id`() = runTest {
val first = confirmNewKey()
val second = confirmNewKey()
assertThat(first).isEqualTo(0)
assertThat(second).isEqualTo(1)
}
@Test
fun `an id freed by a removal is handed out again rather than counting upwards`() = runTest {
confirmNewKey()
val second = confirmNewKey()
api.removeKey(second)
assertThat(confirmNewKey()).isEqualTo(second)
}
@Test
fun `confirming with no pending key doesn't confirm anything`() = runTest {
assertThat(api.confirmKey(oneTimePassword = CODE, metadata = METADATA)).isEqualTo(RequestResult.NonSuccess(TotpApi.ConfirmKeyError.NotVerified))
assertThat(listedKeys()).hasSize(0)
}
@Test
fun `an account at its limit can't generate another key`() = runTest {
repeat(TotpApi.MAX_KEYS) { confirmNewKey() }
assertThat(api.generateKey()).isEqualTo(RequestResult.NonSuccess(TotpApi.GenerateKeyError.TooManyKeys))
}
@Test
fun `removing a key makes room for another`() = runTest {
repeat(TotpApi.MAX_KEYS) { confirmNewKey() }
api.removeKey(0)
assertThat(api.generateKey()).isInstanceOf(RequestResult.Success::class)
}
@Test
fun `metadata can be replaced on a confirmed key`() = runTest {
val keyId = confirmNewKey()
assertThat(api.setKeyMetadata(keyId, OTHER_METADATA)).isEqualTo(RequestResult.Success(Unit))
assertThat(listedKeys().first().metadata).isEqualTo(OTHER_METADATA)
}
@Test
fun `metadata for a key that isn't there is reported rather than created`() = runTest {
assertThat(api.setKeyMetadata(7, METADATA)).isEqualTo(RequestResult.NonSuccess(TotpApi.SetKeyMetadataError.KeyNotFound))
}
/** The service leaves a fixed amount of room for the name, so a name that doesn't fit is the caller's bug. */
@Test
fun `a name longer than the room the service leaves is refused`() = runTest {
val keyId = confirmNewKey()
val tooLong = METADATA.copy(name = "a".repeat(TotpApi.Metadata.NAME_MAX_LENGTH + 1))
assertFailure { api.setKeyMetadata(keyId, tooLong) }.isInstanceOf(IllegalArgumentException::class)
}
/** The service reports success either way, so a retried removal looks like the original. */
@Test
fun `removing a key that isn't there still succeeds`() = runTest {
assertThat(api.removeKey(7)).isEqualTo(RequestResult.Success(Unit))
}
@Test
fun `keys are listed in ascending id order`() = runTest {
confirmNewKey()
confirmNewKey()
assertThat(listedKeys().map { it.keyId }).isEqualTo(listOf(0, 1))
}
private suspend fun confirmNewKey(): Int {
api.generateKey()
return (api.confirmKey(oneTimePassword = CODE, metadata = METADATA) as RequestResult.Success).result
}
private suspend fun listedKeys(): List<TotpApi.RemoteKey> = (api.listKeys() as RequestResult.Success).result
}
@@ -0,0 +1,280 @@
/*
* 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
}
}
@@ -0,0 +1,154 @@
/*
* 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.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import io.mockk.coEvery
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.totpcodeentry.TotpCodeEntryAction
import org.signal.appsettings.totpcodeentry.TotpCodeEntryEvent
import org.signal.appsettings.totpcodeentry.TotpCodeEntryState.Error
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@OptIn(ExperimentalCoroutinesApi::class)
class TotpCodeEntryViewModelTest {
companion object {
private const val FULL_CODE = "123456"
private const val APP_ID = 3L
}
private val testDispatcher = UnconfinedTestDispatcher()
private val repository: TotpRepository = mockk(relaxed = true)
@get:Rule
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
coEvery { repository.confirmPendingApp(any()) } returns TotpRepository.ConfirmResult.Success(APP_ID)
coEvery { repository.removeTotpApp(any()) } returns TotpRepository.UpdateResult.Success
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `non-digits are dropped and the code is capped at six digits`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged("12a34 5678"))
assertThat(viewModel.state.value.code).isEqualTo(FULL_CODE)
}
@Test
fun `a partial code can't be submitted`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged("123"))
assertThat(viewModel.state.value.canSubmit).isFalse()
viewModel.onEvent(TotpCodeEntryEvent.DoneClicked)
assertThat(actions).isEmpty()
}
@Test
fun `a confirmed code sends the user on to name the app the service just created`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
submit(viewModel)
assertThat(actions.last()).isEqualTo(TotpCodeEntryAction.NavigateToNaming(APP_ID))
}
@Test
fun `a rejected code is reported and the user stays put to try again`() = runTest(testDispatcher) {
coEvery { repository.confirmPendingApp(any()) } returns TotpRepository.ConfirmResult.IncorrectCode
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
submit(viewModel)
assertThat(viewModel.state.value.error).isEqualTo(Error.IncorrectCode)
assertThat(viewModel.state.value.submitting).isFalse()
assertThat(actions).isEmpty()
}
@Test
fun `an account that filled up mid-setup goes back to setup, which explains the limit`() = runTest(testDispatcher) {
coEvery { repository.confirmPendingApp(any()) } returns TotpRepository.ConfirmResult.TooManyApps
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
submit(viewModel)
assertThat(viewModel.state.value.submitting).isFalse()
assertThat(actions.last()).isEqualTo(TotpCodeEntryAction.NavigateToSetup)
}
@Test
fun `typing again clears the last error`() = runTest(testDispatcher) {
coEvery { repository.confirmPendingApp(any()) } returns TotpRepository.ConfirmResult.IncorrectCode
val viewModel = createViewModel()
submit(viewModel)
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged("1"))
assertThat(viewModel.state.value.error).isEqualTo(Error.None)
}
@Test
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpCodeEntryEvent.NavigateBackClicked)
assertThat(actions.last()).isEqualTo(TotpCodeEntryAction.NavigateBack)
}
private fun submit(viewModel: TotpCodeEntryViewModel) {
viewModel.onEvent(TotpCodeEntryEvent.CodeChanged(FULL_CODE))
viewModel.onEvent(TotpCodeEntryEvent.DoneClicked)
}
private fun createViewModel() = TotpCodeEntryViewModel(repository = repository)
private fun TestScope.collectActions(actions: Flow<TotpCodeEntryAction>): List<TotpCodeEntryAction> {
val collected = mutableListOf<TotpCodeEntryAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}
@@ -0,0 +1,191 @@
/*
* 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.coEvery
import io.mockk.coVerify
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.totpapplist.TotpApp
import org.signal.appsettings.totpnameentry.TotpNameEntryAction
import org.signal.appsettings.totpnameentry.TotpNameEntryEvent
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@OptIn(ExperimentalCoroutinesApi::class)
class TotpNameEntryViewModelTest {
companion object {
private val EXISTING_APP = TotpApp(id = 7, name = "Twilio Authy", createdAt = 0)
/** The id the service assigned when the code was confirmed, before the app had a name. */
private const val NEW_APP_ID = 1L
}
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.nameNewTotpApp(any(), any()) } returns TotpRepository.UpdateResult.Success
coEvery { repository.renameTotpApp(any(), any()) } returns TotpRepository.UpdateResult.Success
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `naming a new app starts empty and isn't renaming`() = runTest(testDispatcher) {
val viewModel = createViewModel(appId = NEW_APP_ID)
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, renamedApp = EXISTING_APP)
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 = NEW_APP_ID)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpNameEntryEvent.NameChanged(" "))
assertThat(viewModel.state.value.canSubmit).isFalse()
viewModel.onEvent(TotpNameEntryEvent.NextClicked)
assertThat(actions).isEmpty()
}
@Test
fun `NextClicked names the newly confirmed app and goes back to the list`() = runTest(testDispatcher) {
val viewModel = createViewModel(appId = NEW_APP_ID)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpNameEntryEvent.NameChanged(" Bitwarden Authenticator "))
viewModel.onEvent(TotpNameEntryEvent.NextClicked)
coVerify { repository.nameNewTotpApp(NEW_APP_ID, "Bitwarden Authenticator") }
assertThat(actions).contains(TotpNameEntryAction.ShowTotpAppSetUp)
assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateToTotpAppList)
}
@Test
fun `NextClicked renames an existing app and goes back to the list`() = runTest(testDispatcher) {
val viewModel = createViewModel(appId = EXISTING_APP.id, renamedApp = EXISTING_APP)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpNameEntryEvent.NameChanged("Work Authenticator"))
viewModel.onEvent(TotpNameEntryEvent.NextClicked)
coVerify { repository.renameTotpApp(EXISTING_APP, "Work Authenticator") }
assertThat(actions).contains(TotpNameEntryAction.ShowTotpAppRenamed)
assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateToTotpAppList)
}
@Test
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
val viewModel = createViewModel(appId = NEW_APP_ID)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpNameEntryEvent.NavigateBackClicked)
assertThat(actions.last()).isEqualTo(TotpNameEntryAction.NavigateBack)
}
@Test
fun `entry is capped at the grapheme limit rather than rejected`() = runTest(testDispatcher) {
val viewModel = createViewModel(appId = NEW_APP_ID)
viewModel.onEvent(TotpNameEntryEvent.NameChanged("a".repeat(TotpRepository.MAX_NAME_LENGTH_GRAPHEMES + 20)))
assertThat(viewModel.state.value.name).isEqualTo("a".repeat(TotpRepository.MAX_NAME_LENGTH_GRAPHEMES))
assertThat(viewModel.state.value.canSubmit).isTrue()
}
/**
* The case the byte trim exists for: thirty emoji are inside the grapheme cap and well past the 98 bytes the service
* leaves room for, so the grapheme cap alone would let an unencryptable name through.
*/
@Test
fun `entry is also capped in bytes, which the grapheme limit does not guarantee`() = runTest(testDispatcher) {
val viewModel = createViewModel(appId = NEW_APP_ID)
viewModel.onEvent(TotpNameEntryEvent.NameChanged("\uD83D\uDD10".repeat(TotpRepository.MAX_NAME_LENGTH_GRAPHEMES)))
val name = viewModel.state.value.name
assertThat(name.toByteArray(Charsets.UTF_8).size <= TotpRepository.MAX_NAME_LENGTH_BYTES).isTrue()
assertThat(name.isNotEmpty()).isTrue()
}
/** Trimming to a byte budget must not leave half a character behind. */
@Test
fun `capping in bytes does not split a character`() = runTest(testDispatcher) {
val viewModel = createViewModel(appId = NEW_APP_ID)
viewModel.onEvent(TotpNameEntryEvent.NameChanged("\uD83D\uDD10".repeat(TotpRepository.MAX_NAME_LENGTH_GRAPHEMES)))
val name = viewModel.state.value.name
assertThat(name.toByteArray(Charsets.UTF_8).toString(Charsets.UTF_8)).isEqualTo(name)
assertThat(name.length % 2).isEqualTo(0)
}
@Test
fun `a name that didn't save leaves the user on the screen to try again`() = runTest(testDispatcher) {
coEvery { repository.nameNewTotpApp(any(), any()) } returns TotpRepository.UpdateResult.NetworkFailure
val viewModel = createViewModel(appId = NEW_APP_ID)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpNameEntryEvent.NameChanged("Aegis"))
viewModel.onEvent(TotpNameEntryEvent.NextClicked)
assertThat(actions.last()).isEqualTo(TotpNameEntryAction.ShowNameNotSaved)
assertThat(viewModel.state.value.submitting).isFalse()
}
private fun createViewModel(appId: Long, renamedApp: TotpApp? = null) = TotpNameEntryViewModel(appId = appId, renamedApp = renamedApp, repository = repository)
private fun TestScope.collectActions(actions: Flow<TotpNameEntryAction>): List<TotpNameEntryAction> {
val collected = mutableListOf<TotpNameEntryAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}
@@ -0,0 +1,55 @@
/*
* 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.totpapplist.TotpApp
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class TotpNavArgsTest {
companion object {
private val APP = TotpApp(id = 7, name = "Aegis", createdAt = 1_700_000_000_000L)
}
@Test
fun `no arguments means the screen is acting on a newly paired app`() {
assertThat(TotpNavArgs.appId(null)).isNull()
assertThat(TotpNavArgs.renamedApp(null)).isNull()
}
@Test
fun `an unset app id reads as null`() {
val arguments = Bundle().apply { putLong(TotpNavArgs.ARG_APP_ID, TotpNavArgs.NO_APP_ID) }
assertThat(TotpNavArgs.appId(arguments)).isNull()
}
@Test
fun `a newly paired app carries its id but no renamed app`() {
val arguments = Bundle().apply { putLong(TotpNavArgs.ARG_APP_ID, 7) }
assertThat(TotpNavArgs.appId(arguments)).isEqualTo(7L)
assertThat(TotpNavArgs.renamedApp(arguments)).isNull()
}
@Test
fun `a rename carries the whole app the list already had`() {
val arguments = Bundle().apply { TotpNavArgs.putRenamedApp(this, APP) }
assertThat(TotpNavArgs.appId(arguments)).isEqualTo(APP.id)
assertThat(TotpNavArgs.renamedApp(arguments)).isEqualTo(APP)
}
}
@@ -0,0 +1,178 @@
/*
* 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.hasSize
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.startsWith
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.signal.appsettings.totpapplist.TotpApp
import org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpRepository.AppsResult
import org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpRepository.BeginSetupResult
import org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpRepository.ConfirmResult
import org.thoughtcrime.securesms.components.settings.app.account.authenticator.TotpRepository.UpdateResult
class TotpRepositoryTest {
companion object {
private const val NOW = 1_700_000_000_000L
private const val ACCOUNT_NAME = "8B4A1F0C"
private const val CODE = "123456"
}
private var now = NOW
private val api = InMemoryTotpApi()
private val repository = TotpRepository(api = api, clock = { now })
@Test
fun `beginSetup returns a link and a key in both the forms the screen needs`() = runTest {
val result = repository.beginSetup(ACCOUNT_NAME) as BeginSetupResult.Success
assertThat(result.setupUri).startsWith("otpauth://totp/Signal:$ACCOUNT_NAME?")
assertThat(result.setupUri).contains("secret=${result.clipboardKey}")
assertThat(result.displayKey).isEqualTo(result.clipboardKey.chunked(4).joinToString(" "))
}
/** The issuer and the account name have to differ, or an app that shows both renders "Signal: Signal". */
@Test
fun `beginSetup names the entry after the account, under the issuer`() = runTest {
val result = repository.beginSetup(ACCOUNT_NAME) as BeginSetupResult.Success
assertThat(result.setupUri).startsWith("otpauth://totp/Signal:$ACCOUNT_NAME?")
assertThat(result.setupUri).contains("issuer=Signal&")
}
/** Nothing should reach this without an ACI, but a bare issuer beats a label ending in a colon if anything does. */
@Test
fun `beginSetup falls back to the issuer alone when there's no account name`() = runTest {
val result = repository.beginSetup("") as BeginSetupResult.Success
assertThat(result.setupUri).startsWith("otpauth://totp/Signal?")
}
@Test
fun `beginSetup treats an account name of nothing but whitespace as no account name`() = runTest {
val result = repository.beginSetup(" ") as BeginSetupResult.Success
assertThat(result.setupUri).startsWith("otpauth://totp/Signal?")
}
/** A space has to become %20 rather than the + form encoding would produce, or apps render it literally. */
@Test
fun `beginSetup percent-encodes the account name`() = runTest {
val result = repository.beginSetup("+1 555") as BeginSetupResult.Success
assertThat(result.setupUri).startsWith("otpauth://totp/Signal:%2B1%20555?")
}
@Test
fun `setup asks for the parameters every authenticator app supports`() = runTest {
val result = repository.beginSetup(ACCOUNT_NAME) as BeginSetupResult.Success
assertThat(result.setupUri).contains("algorithm=SHA1")
assertThat(result.setupUri).contains("digits=6")
assertThat(result.setupUri).contains("period=30")
}
@Test
fun `an account at its limit is told rather than handed a key`() = runTest {
repeat(TotpApi.MAX_KEYS) { confirmNewApp() }
assertThat(repository.beginSetup(ACCOUNT_NAME)).isEqualTo(BeginSetupResult.TooManyApps)
}
@Test
fun `a confirmed app shows up in the list with the time it was confirmed`() = runTest {
val appId = confirmNewApp()
val apps = (repository.getTotpApps() as AppsResult.Success).apps
assertThat(apps).hasSize(1)
assertThat(apps.first().id).isEqualTo(appId)
assertThat(apps.first().createdAt).isEqualTo(NOW)
}
/** The service wants metadata at confirmation time, and the user hasn't been asked for a name yet. */
@Test
fun `a newly confirmed app starts out with no name`() = runTest {
confirmNewApp()
val apps = (repository.getTotpApps() as AppsResult.Success).apps
assertThat(apps.first().name).isEqualTo("")
}
@Test
fun `naming a new app names it`() = runTest {
val appId = confirmNewApp()
assertThat(repository.nameNewTotpApp(appId, "Aegis")).isEqualTo(UpdateResult.Success)
assertThat(listedApp(appId)?.name).isEqualTo("Aegis")
}
@Test
fun `renaming keeps the time the app was confirmed`() = runTest {
val appId = confirmNewApp()
repository.nameNewTotpApp(appId, "Aegis")
now += 60_000
assertThat(repository.renameTotpApp(listedApp(appId)!!, "Aegis on my tablet")).isEqualTo(UpdateResult.Success)
val app = listedApp(appId)
assertThat(app?.name).isEqualTo("Aegis on my tablet")
assertThat(app?.createdAt).isEqualTo(NOW)
}
@Test
fun `renaming an app that isn't there is reported rather than creating one`() = runTest {
val gone = TotpApp(id = 7, name = "Aegis", createdAt = NOW)
assertThat(repository.renameTotpApp(gone, "Aegis on my tablet")).isEqualTo(UpdateResult.AppNotFound)
}
@Test
fun `a removed app leaves the list`() = runTest {
val appId = confirmNewApp()
assertThat(repository.removeTotpApp(appId)).isEqualTo(UpdateResult.Success)
assertThat((repository.getTotpApps() as AppsResult.Success).apps).isEmpty()
}
/** The service can't tell a wrong code from a missing pending key, so neither can we. */
@Test
fun `confirming with nothing pending is just a wrong code`() = runTest {
assertThat(repository.confirmPendingApp(CODE)).isEqualTo(ConfirmResult.IncorrectCode)
}
@Test
fun `a code that isn't a number is reported as a wrong code`() = runTest {
repository.beginSetup(ACCOUNT_NAME)
assertThat(repository.confirmPendingApp("abcdef")).isEqualTo(ConfirmResult.IncorrectCode)
}
@Test
fun `a code confirms the app`() = runTest {
repository.beginSetup(ACCOUNT_NAME)
assertThat(repository.confirmPendingApp(CODE)).isInstanceOf(ConfirmResult.Success::class)
}
private suspend fun listedApp(appId: Long): TotpApp? {
return (repository.getTotpApps() as AppsResult.Success).apps.firstOrNull { it.id == appId }
}
private suspend fun confirmNewApp(): Long {
repository.beginSetup(ACCOUNT_NAME)
return (repository.confirmPendingApp(CODE) as ConfirmResult.Success).appId
}
}
@@ -0,0 +1,182 @@
/*
* 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.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import io.mockk.coEvery
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.totpsetup.TotpSetupAction
import org.signal.appsettings.totpsetup.TotpSetupEvent
import org.signal.appsettings.totpsetup.TotpSetupState.Dialog
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
import java.util.UUID
@OptIn(ExperimentalCoroutinesApi::class)
class TotpSetupViewModelTest {
companion object {
private const val ACCOUNT_NAME = "8B4A1F0C"
private const val SETUP_URI = "otpauth://totp/Signal:%2B15551234567?secret=MZXW6YTBOI"
private const val DISPLAY_KEY = "MZXW 6YTB OI"
private const val CLIPBOARD_KEY = "MZXW6YTBOI"
private val SETUP_SUCCESS = TotpRepository.BeginSetupResult.Success(
setupUri = SETUP_URI,
displayKey = DISPLAY_KEY,
clipboardKey = CLIPBOARD_KEY
)
}
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.beginSetup(any()) } returns SETUP_SUCCESS
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `the screen asks for a key as soon as it opens and shows it grouped`() = runTest(testDispatcher) {
val viewModel = createViewModel()
assertThat(viewModel.state.value.setupKey).isEqualTo(DISPLAY_KEY)
assertThat(viewModel.state.value.loading).isFalse()
assertThat(viewModel.state.value.canContinue).isTrue()
}
@Test
fun `OpenTotpAppClicked hands off the setup link rather than the displayed key`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpSetupEvent.OpenTotpAppClicked)
assertThat(actions.last()).isEqualTo(TotpSetupAction.LaunchTotpApp(SETUP_URI))
}
@Test
fun `CopyKeyClicked copies the unbroken key rather than the grouped one`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpSetupEvent.CopyKeyClicked)
assertThat(actions.first()).isEqualTo(TotpSetupAction.CopyKeyToClipboard(CLIPBOARD_KEY))
assertThat(actions.last()).isEqualTo(TotpSetupAction.ShowKeyCopied)
}
@Test
fun `ContinueClicked moves on to code entry`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpSetupEvent.ContinueClicked)
assertThat(actions.last()).isEqualTo(TotpSetupAction.NavigateToCodeEntry)
}
@Test
fun `NoTotpAppFound reports the failure`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpSetupEvent.NoTotpAppFound)
assertThat(actions.last()).isEqualTo(TotpSetupAction.ShowNoTotpAppFound)
}
@Test
fun `backing out leaves the screen`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpSetupEvent.NavigateBackClicked)
assertThat(actions.last()).isEqualTo(TotpSetupAction.NavigateBack)
}
@Test
fun `an account at its limit is told so rather than shown an empty key`() = runTest(testDispatcher) {
coEvery { repository.beginSetup(any()) } returns TotpRepository.BeginSetupResult.TooManyApps
val viewModel = createViewModel()
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.MaxAppsReached(2))
assertThat(viewModel.state.value.canContinue).isFalse()
}
@Test
fun `a network failure is reported rather than left spinning`() = runTest(testDispatcher) {
coEvery { repository.beginSetup(any()) } returns TotpRepository.BeginSetupResult.NetworkFailure
val viewModel = createViewModel()
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.NetworkFailure)
assertThat(viewModel.state.value.loading).isFalse()
}
@Test
fun `dismissing a failure dialog leaves the screen, since there's nothing to retry here`() = runTest(testDispatcher) {
coEvery { repository.beginSetup(any()) } returns TotpRepository.BeginSetupResult.NetworkFailure
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(TotpSetupEvent.DialogDismissed)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
assertThat(actions.last()).isEqualTo(TotpSetupAction.NavigateBack)
}
@Test
fun `accountNameFor - takes the first hunk of the ACI, uppercased`() {
val aci = UUID.fromString("8b4a1f0c-2d3e-4a5b-9c7d-1e2f3a4b5c6d")
assertThat(TotpSetupViewModel.accountNameFor(aci)).isEqualTo("8B4A1F0C")
}
@Test
fun `accountNameFor - has nothing to say without an ACI, which leaves the issuer as the whole label`() {
assertThat(TotpSetupViewModel.accountNameFor(null)).isEqualTo("")
}
private fun createViewModel() = TotpSetupViewModel(repository = repository, accountName = ACCOUNT_NAME)
private fun TestScope.collectActions(actions: Flow<TotpSetupAction>): List<TotpSetupAction> {
val collected = mutableListOf<TotpSetupAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}