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