mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 08:45:50 +01:00
Add basic TOTP support with mocked creation.
This commit is contained in:
committed by
Alex Hart
parent
6a032e4f27
commit
62897a309c
+1
-1
@@ -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
-3
@@ -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
|
||||
|
||||
|
||||
+8
-5
@@ -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 {
|
||||
|
||||
-46
@@ -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 } }
|
||||
}
|
||||
-75
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
-81
@@ -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()) }
|
||||
}
|
||||
}
|
||||
-56
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-72
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-56
@@ -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()
|
||||
}
|
||||
}
|
||||
-73
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-47
@@ -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)
|
||||
}
|
||||
}
|
||||
-25
@@ -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)
|
||||
}
|
||||
-71
@@ -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()
|
||||
}
|
||||
}
|
||||
+92
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -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
|
||||
}
|
||||
}
|
||||
+75
@@ -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()
|
||||
}
|
||||
}
|
||||
+109
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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) }
|
||||
}
|
||||
}
|
||||
+62
@@ -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()
|
||||
}
|
||||
}
|
||||
+100
@@ -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)
|
||||
}
|
||||
}
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
+243
@@ -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
|
||||
}
|
||||
}
|
||||
+17
-17
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+95
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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>
|
||||
|
||||
+17
-6
@@ -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
|
||||
|
||||
-195
@@ -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
|
||||
}
|
||||
}
|
||||
-125
@@ -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
|
||||
}
|
||||
}
|
||||
-134
@@ -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
|
||||
}
|
||||
}
|
||||
-60
@@ -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)
|
||||
}
|
||||
}
|
||||
-109
@@ -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
|
||||
}
|
||||
}
|
||||
+129
@@ -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
|
||||
}
|
||||
+280
@@ -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
|
||||
}
|
||||
}
|
||||
+154
@@ -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
|
||||
}
|
||||
}
|
||||
+191
@@ -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
|
||||
}
|
||||
}
|
||||
+55
@@ -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)
|
||||
}
|
||||
}
|
||||
+178
@@ -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
|
||||
}
|
||||
}
|
||||
+182
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user