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