diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt index 44fbe00627..d075d2e322 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt @@ -74,6 +74,7 @@ class AccountSettingsFragment : ComposeFragment() { AccountSettingsAction.LaunchCreatePinFlow -> pinFlowLauncher.launch(CreateSvrPinActivity.getIntentForPinCreate(requireContext())) AccountSettingsAction.LaunchChangePinFlow -> pinFlowLauncher.launch(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext())) AccountSettingsAction.ShowPinCreatedConfirmation -> Snackbar.make(requireView(), R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show() + AccountSettingsAction.NavigateToAuthenticatorAppSetup -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_authenticatorSetupFragment) AccountSettingsAction.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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsRepository.kt index a5d66709df..4d2714ad97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsRepository.kt @@ -8,10 +8,12 @@ package org.thoughtcrime.securesms.components.settings.app.account import kotlinx.coroutines.withContext import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.components.settings.app.account.authenticator.AuthenticatorRepository import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.lock.v2.PinKeyboardType import org.thoughtcrime.securesms.pin.SvrRepository +import org.thoughtcrime.securesms.util.Environment import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.api.kbs.PinHashUtil import java.io.IOException @@ -25,6 +27,8 @@ class AccountSettingsRepository { private val TAG = Log.tag(AccountSettingsRepository::class) } + private val authenticatorRepository = AuthenticatorRepository() + fun hasPin(): Boolean = SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut() fun hasRestoredAep(): Boolean = SignalStore.account.restoredAccountEntropyPool @@ -41,6 +45,10 @@ class AccountSettingsRepository { fun getPinKeyboardType(): PinKeyboardType = SignalStore.pin.keyboardType + fun isPhoneNumberlessRegistrationEnabled(): Boolean = Environment.PHONENUMBERLESS_REGISTRATION + + fun hasAuthenticatorApp(): Boolean = authenticatorRepository.hasAuthenticatorApp() + fun verifyLocalPin(pin: String): Boolean { val localPinHash = SignalStore.svr.localPinHash if (localPinHash == null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt index 4c7ac7b7a8..d5fff1d1f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt @@ -31,6 +31,9 @@ class AccountSettingsViewModel( companion object { private val TAG = Log.tag(AccountSettingsViewModel::class) + + /** Stand-in for the real key count, which we have nowhere to read from yet. */ + private const val MOCK_SIGNAL_LOGIN_KEY_COUNT = 2 } private val _state = MutableStateFlow(AccountSettingsState()) @@ -101,6 +104,9 @@ class AccountSettingsViewModel( ) } } + AccountSettingsEvent.AuthenticatorAppClicked -> { + _actions.send(AccountSettingsAction.NavigateToAuthenticatorAppSetup) + } AccountSettingsEvent.AdvancedPinSettingsClicked -> { _actions.send(AccountSettingsAction.NavigateToAdvancedPinSettings) } @@ -146,7 +152,15 @@ class AccountSettingsViewModel( pinRemindersEnabled = repository.arePinRemindersEnabled(), registrationLockEnabled = repository.isRegistrationLockEnabled(), userUnregistered = repository.isUserUnregistered(), - clientDeprecated = repository.isClientDeprecated() + clientDeprecated = repository.isClientDeprecated(), + signalLogin = if (repository.isPhoneNumberlessRegistrationEnabled()) { + AccountSettingsState.SignalLogin( + keyCount = MOCK_SIGNAL_LOGIN_KEY_COUNT, + hasAuthenticatorApp = repository.hasAuthenticatorApp() + ) + } else { + null + } ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppStore.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppStore.kt new file mode 100644 index 0000000000..c34fb5dbbb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorAppStore.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.authenticator + +/** + * Stand-in for wherever authenticator app state will eventually live. Nothing is persisted or sent to the service yet, + * so all of this is mocked up and lasts only as long as the process does. + */ +object AuthenticatorAppStore { + + /** The key we'd hand off to an authenticator app, which the service will supply for real later on. */ + const val MOCK_SETUP_KEY = "KVZ7WL3FDDWJZMTOB7PLZPKVRFD4LYSX" + + @Volatile + var hasAuthenticatorApp: Boolean = false +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryFragment.kt new file mode 100644 index 0000000000..b867e4654b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryFragment.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.authenticator + +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.fragment.findNavController +import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryAction +import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryScreen +import org.signal.core.ui.compose.CollectActions +import org.signal.core.ui.compose.ComposeFragment +import org.thoughtcrime.securesms.R +import org.signal.appsettings.R as AppSettingsR + +/** + * Collects the code from the user's authenticator app. Carries out the [AuthenticatorCodeEntryAction]s that need the + * nav graph. + */ +class AuthenticatorCodeEntryFragment : ComposeFragment() { + + private val viewModel: AuthenticatorCodeEntryViewModel by viewModels() + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + + CollectActions(viewModel.actions) { action -> handleAction(action) } + + AuthenticatorCodeEntryScreen( + state = state, + onEvent = viewModel::onEvent + ) + } + + private fun handleAction(action: AuthenticatorCodeEntryAction) { + when (action) { + AuthenticatorCodeEntryAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed() + AuthenticatorCodeEntryAction.NavigateToAccountSettings -> findNavController().popBackStack(R.id.accountSettingsFragment, false) + AuthenticatorCodeEntryAction.ShowAuthenticatorAppAdded -> { + Toast.makeText(requireContext(), AppSettingsR.string.AuthenticatorCodeEntryScreen__authenticator_app_added, Toast.LENGTH_SHORT).show() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModel.kt new file mode 100644 index 0000000000..bcce007b0c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModel.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.authenticator + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryAction +import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryEvent +import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryState +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log + +/** + * Drives the screen that collects a code from the user's authenticator app. There's nothing to verify the code + * against yet, so any code of the right length is treated as correct. + */ +class AuthenticatorCodeEntryViewModel( + private val repository: AuthenticatorRepository = AuthenticatorRepository() +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(AuthenticatorCodeEntryViewModel::class) + } + + private val _state = MutableStateFlow(AuthenticatorCodeEntryState()) + private val _actions = Channel(Channel.BUFFERED) + + val state: StateFlow = _state.asStateFlow() + val actions: Flow = _actions.receiveAsFlow() + + override suspend fun processEvent(event: AuthenticatorCodeEntryEvent) { + when (event) { + AuthenticatorCodeEntryEvent.NavigateBackClicked -> { + _actions.send(AuthenticatorCodeEntryAction.NavigateBack) + } + is AuthenticatorCodeEntryEvent.CodeChanged -> { + val digits = event.code.filter { it.isDigit() }.take(AuthenticatorCodeEntryState.CODE_LENGTH) + _state.update { it.copy(code = digits) } + } + AuthenticatorCodeEntryEvent.DoneClicked -> { + if (!_state.value.canSubmit) { + return + } + + Log.i(TAG, "Accepting the entered code without verifying it, which is all we can do until this is wired up.") + _state.update { it.copy(submitting = true) } + repository.setHasAuthenticatorApp(true) + + _actions.send(AuthenticatorCodeEntryAction.ShowAuthenticatorAppAdded) + _actions.send(AuthenticatorCodeEntryAction.NavigateToAccountSettings) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorRepository.kt new file mode 100644 index 0000000000..c57b06b107 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorRepository.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.authenticator + +class AuthenticatorRepository { + + fun getSetupKey(): String = AuthenticatorAppStore.MOCK_SETUP_KEY + + fun hasAuthenticatorApp(): Boolean = AuthenticatorAppStore.hasAuthenticatorApp + + fun setHasAuthenticatorApp(hasAuthenticatorApp: Boolean) { + AuthenticatorAppStore.hasAuthenticatorApp = hasAuthenticatorApp + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorSetupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorSetupFragment.kt new file mode 100644 index 0000000000..0ec0b8ed0a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorSetupFragment.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.authenticator + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +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.authenticatorsetup.AuthenticatorSetupAction +import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupEvent +import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupScreen +import org.signal.core.ui.compose.CollectActions +import org.signal.core.ui.compose.ComposeFragment +import org.signal.core.util.Util +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 + +/** + * Walks the user through setting up an authenticator app. Carries out the [AuthenticatorSetupAction]s that need an + * Activity or the nav graph. + */ +class AuthenticatorSetupFragment : ComposeFragment() { + + companion object { + private val TAG = Log.tag(AuthenticatorSetupFragment::class) + } + + private val viewModel: AuthenticatorSetupViewModel by viewModels() + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + + CollectActions(viewModel.actions) { action -> handleAction(action) } + + AuthenticatorSetupScreen( + state = state, + onEvent = viewModel::onEvent + ) + } + + private fun handleAction(action: AuthenticatorSetupAction) { + when (action) { + AuthenticatorSetupAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed() + is AuthenticatorSetupAction.LaunchAuthenticatorApp -> launchAuthenticatorApp(action.uri) + is AuthenticatorSetupAction.CopyKeyToClipboard -> Util.copyToClipboard(requireContext(), action.key) + AuthenticatorSetupAction.ShowKeyCopied -> toast(AppSettingsR.string.AuthenticatorSetupScreen__copied_to_clipboard) + AuthenticatorSetupAction.ShowNoAuthenticatorAppFound -> toast(AppSettingsR.string.AuthenticatorSetupScreen__no_authenticator_app_found) + AuthenticatorSetupAction.NavigateToCodeEntry -> findNavController().safeNavigate(R.id.action_authenticatorSetupFragment_to_authenticatorCodeEntryFragment) + } + } + + private fun launchAuthenticatorApp(uri: String) { + try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri))) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "No app is willing to handle the authenticator setup link.", e) + viewModel.onEvent(AuthenticatorSetupEvent.NoAuthenticatorAppFound) + } + } + + private fun toast(@StringRes message: Int) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorSetupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorSetupViewModel.kt new file mode 100644 index 0000000000..8ce040d845 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorSetupViewModel.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.authenticator + +import android.net.Uri +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupAction +import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupEvent +import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupState +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log + +/** + * Drives the screen that walks the user through pairing an authenticator app. The setup key is mocked up for now, + * since there's nothing to fetch it from yet. + */ +class AuthenticatorSetupViewModel( + repository: AuthenticatorRepository = AuthenticatorRepository() +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(AuthenticatorSetupViewModel::class) + private const val ACCOUNT_LABEL = "Signal" + } + + private val _state = MutableStateFlow(AuthenticatorSetupState(setupKey = repository.getSetupKey())) + private val _actions = Channel(Channel.BUFFERED) + + val state: StateFlow = _state.asStateFlow() + val actions: Flow = _actions.receiveAsFlow() + + override suspend fun processEvent(event: AuthenticatorSetupEvent) { + when (event) { + AuthenticatorSetupEvent.NavigateBackClicked -> { + _actions.send(AuthenticatorSetupAction.NavigateBack) + } + AuthenticatorSetupEvent.OpenAuthenticatorAppClicked -> { + _actions.send(AuthenticatorSetupAction.LaunchAuthenticatorApp(buildSetupUri(_state.value.setupKey))) + } + AuthenticatorSetupEvent.CopyKeyClicked -> { + _actions.send(AuthenticatorSetupAction.CopyKeyToClipboard(_state.value.setupKey)) + _actions.send(AuthenticatorSetupAction.ShowKeyCopied) + } + AuthenticatorSetupEvent.NoAuthenticatorAppFound -> { + _actions.send(AuthenticatorSetupAction.ShowNoAuthenticatorAppFound) + } + AuthenticatorSetupEvent.ContinueClicked -> { + _actions.send(AuthenticatorSetupAction.NavigateToCodeEntry) + } + } + } + + private fun buildSetupUri(setupKey: String): String { + return Uri.Builder() + .scheme("otpauth") + .authority("totp") + .appendPath(ACCOUNT_LABEL) + .appendQueryParameter("secret", setupKey) + .appendQueryParameter("issuer", ACCOUNT_LABEL) + .build() + .toString() + } +} diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index 80f3f6a378..d7e754111f 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -216,8 +216,33 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + + + + + + () == CORRECT_PIN } coEvery { repository.setRegistrationLockEnabled(any()) } returns true } @@ -288,6 +291,35 @@ class AccountSettingsViewModelTest { assertThat(actions.last()).isEqualTo(AccountSettingsAction.ShowPinCreatedConfirmation) } + @Test + fun `the Signal Login section is left out when phone-numberless registration is off`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + assertThat(viewModel.state.value.signalLogin).isNull() + } + + @Test + fun `the Signal Login section is filled in when phone-numberless registration is on`() = runTest(testDispatcher) { + every { repository.isPhoneNumberlessRegistrationEnabled() } returns true + every { repository.hasAuthenticatorApp() } returns true + + val viewModel = createViewModel() + + assertThat(viewModel.state.value.signalLogin?.hasAuthenticatorApp).isEqualTo(true) + } + + @Test + fun `AuthenticatorAppClicked opens the authenticator setup flow`() = runTest(testDispatcher) { + every { repository.isPhoneNumberlessRegistrationEnabled() } returns true + + val viewModel = createViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AccountSettingsEvent.AuthenticatorAppClicked) + + assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToAuthenticatorAppSetup) + } + private fun createViewModel(): AccountSettingsViewModel = AccountSettingsViewModel(repository) private fun TestScope.collectActions(actions: Flow): List { diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModelTest.kt new file mode 100644 index 0000000000..9ae61f35b1 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorCodeEntryViewModelTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.authenticator + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryAction +import org.signal.appsettings.authenticatorcodeentry.AuthenticatorCodeEntryEvent +import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule + +@OptIn(ExperimentalCoroutinesApi::class) +class AuthenticatorCodeEntryViewModelTest { + + companion object { + private const val FULL_CODE = "123456" + } + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val dispatcherRule = CoroutineDispatcherRule(testDispatcher) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + AuthenticatorAppStore.hasAuthenticatorApp = false + } + + @After + fun tearDown() { + Dispatchers.resetMain() + AuthenticatorAppStore.hasAuthenticatorApp = false + } + + @Test + fun `non-digits are dropped and the code is capped at six digits`() = runTest(testDispatcher) { + val viewModel = AuthenticatorCodeEntryViewModel() + + viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged("12a34 5678")) + + assertThat(viewModel.state.value.code).isEqualTo(FULL_CODE) + } + + @Test + fun `a partial code can't be submitted`() = runTest(testDispatcher) { + val viewModel = AuthenticatorCodeEntryViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged("123")) + + assertThat(viewModel.state.value.canSubmit).isFalse() + + viewModel.onEvent(AuthenticatorCodeEntryEvent.DoneClicked) + + assertThat(actions).isEmpty() + assertThat(AuthenticatorAppStore.hasAuthenticatorApp).isFalse() + } + + @Test + fun `a full code is accepted and sends the user back to account settings`() = runTest(testDispatcher) { + val viewModel = AuthenticatorCodeEntryViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorCodeEntryEvent.CodeChanged(FULL_CODE)) + viewModel.onEvent(AuthenticatorCodeEntryEvent.DoneClicked) + + assertThat(AuthenticatorAppStore.hasAuthenticatorApp).isTrue() + assertThat(actions).contains(AuthenticatorCodeEntryAction.ShowAuthenticatorAppAdded) + assertThat(actions.last()).isEqualTo(AuthenticatorCodeEntryAction.NavigateToAccountSettings) + } + + @Test + fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) { + val viewModel = AuthenticatorCodeEntryViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorCodeEntryEvent.NavigateBackClicked) + + assertThat(actions.last()).isEqualTo(AuthenticatorCodeEntryAction.NavigateBack) + } + + private fun TestScope.collectActions(actions: Flow): List { + val collected = mutableListOf() + backgroundScope.launch { actions.toList(collected) } + return collected + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorSetupViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorSetupViewModelTest.kt new file mode 100644 index 0000000000..d5f6898e7a --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/authenticator/AuthenticatorSetupViewModelTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.authenticator + +import android.app.Application +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupAction +import org.signal.appsettings.authenticatorsetup.AuthenticatorSetupEvent +import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class AuthenticatorSetupViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val dispatcherRule = CoroutineDispatcherRule(testDispatcher) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the setup key is available as soon as the screen opens`() = runTest(testDispatcher) { + val viewModel = AuthenticatorSetupViewModel() + + assertThat(viewModel.state.value.setupKey).isEqualTo(AuthenticatorAppStore.MOCK_SETUP_KEY) + } + + @Test + fun `OpenAuthenticatorAppClicked hands off a link carrying the setup key`() = runTest(testDispatcher) { + val viewModel = AuthenticatorSetupViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorSetupEvent.OpenAuthenticatorAppClicked) + + val action = actions.last() + assertThat(action).isInstanceOf(AuthenticatorSetupAction.LaunchAuthenticatorApp::class) + assertThat((action as AuthenticatorSetupAction.LaunchAuthenticatorApp).uri).contains(AuthenticatorAppStore.MOCK_SETUP_KEY) + } + + @Test + fun `CopyKeyClicked copies the key and tells the user`() = runTest(testDispatcher) { + val viewModel = AuthenticatorSetupViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorSetupEvent.CopyKeyClicked) + + assertThat(actions).contains(AuthenticatorSetupAction.CopyKeyToClipboard(AuthenticatorAppStore.MOCK_SETUP_KEY)) + assertThat(actions.last()).isEqualTo(AuthenticatorSetupAction.ShowKeyCopied) + } + + @Test + fun `ContinueClicked moves on to code entry`() = runTest(testDispatcher) { + val viewModel = AuthenticatorSetupViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorSetupEvent.ContinueClicked) + + assertThat(actions.last()).isEqualTo(AuthenticatorSetupAction.NavigateToCodeEntry) + } + + @Test + fun `NoAuthenticatorAppFound reports the failure`() = runTest(testDispatcher) { + val viewModel = AuthenticatorSetupViewModel() + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(AuthenticatorSetupEvent.NoAuthenticatorAppFound) + + assertThat(actions.last()).isEqualTo(AuthenticatorSetupAction.ShowNoAuthenticatorAppFound) + } + + private fun TestScope.collectActions(actions: Flow): List { + val collected = mutableListOf() + backgroundScope.launch { actions.toList(collected) } + return collected + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt index 73349c2f84..cb452051cb 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -65,6 +65,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { FolderDisplay(icon(R.drawable.symbol_folder_display_48)), Forward(icon(R.drawable.symbol_forward_24)), Info(icon(R.drawable.symbol_info_24)), + Key(icon(R.drawable.symbol_key_24)), Keyboard(icon(R.drawable.ic_keyboard_24)), Link(icon(R.drawable.symbol_link_24)), Lock(icon(R.drawable.symbol_lock_24)), @@ -74,6 +75,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { MoreVertical(icon(R.drawable.symbol_more_vertical_24)), Nighttime(icon(R.drawable.ic_nighttime_26)), NumberPad(icon(R.drawable.ic_number_pad_conversation_filter_24)), + Open(icon(R.drawable.symbol_open_24)), PersonCircle(icon(R.drawable.symbol_person_circle_24)), Phone(icon(R.drawable.symbol_phone_24)), Plus(icon(R.drawable.symbol_plus_24)), diff --git a/core/ui/src/main/res/drawable/symbol_key_24.xml b/core/ui/src/main/res/drawable/symbol_key_24.xml new file mode 100644 index 0000000000..c80c274075 --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_key_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/symbol_open_24.xml b/core/ui/src/main/res/drawable/symbol_open_24.xml new file mode 100644 index 0000000000..9bd99f1797 --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_open_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsAction.kt index 7d0ea0fdad..3ab3acc254 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsAction.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsAction.kt @@ -25,6 +25,9 @@ sealed interface AccountSettingsAction { /** Tell the user their PIN was created. */ data object ShowPinCreatedConfirmation : AccountSettingsAction + /** Open the flow that sets up an authenticator app. */ + data object NavigateToAuthenticatorAppSetup : AccountSettingsAction + /** Open the advanced PIN settings screen. */ data object NavigateToAdvancedPinSettings : AccountSettingsAction diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsEvent.kt index 2357b742a3..ae3738fc6c 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsEvent.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsEvent.kt @@ -42,6 +42,9 @@ 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 AuthenticatorAppClicked : AccountSettingsEvent + /** The user tapped the advanced PIN settings row. */ data object AdvancedPinSettingsClicked : AccountSettingsEvent diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsScreen.kt index 04a7e45de8..d4b7b6b1a0 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsScreen.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsScreen.kt @@ -7,9 +7,15 @@ package org.signal.appsettings.account import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.Image +import androidx.compose.foundation.background 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.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon @@ -22,13 +28,21 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment 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.layout.ContentScale 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 import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import org.signal.appsettings.R import org.signal.appsettings.account.AccountSettingsState.Dialog @@ -42,10 +56,14 @@ 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.compose.theme.SignalTheme +import org.signal.core.ui.R as CoreUiR @VisibleForTesting object AccountSettingsTestTags { const val SCROLLER = "scroller" + const val CARD_SIGNAL_LOGIN = "card-signal-login" + const val ROW_AUTHENTICATOR_APP = "row-authenticator-app" + const val ROW_SECURITY_KEYS = "row-security-keys" const val ROW_MODIFY_PIN = "row-modify-pin" const val ROW_PIN_REMINDER = "row-pin-reminder" const val ROW_REGISTRATION_LOCK = "row-registration-lock" @@ -79,6 +97,63 @@ fun AccountSettingsScreen( .padding(contentPadding) .testTag(AccountSettingsTestTags.SCROLLER) ) { + if (state.signalLogin != null) { + item { + Texts.SectionHeader( + text = stringResource(R.string.AccountSettingsFragment__signal_login) + ) + } + + item { + SignalLoginCard(keyCount = state.signalLogin.keyCount) + } + + item { + SectionFooter(text = stringResource(R.string.AccountSettingsFragment__your_signal_login_is_used_to_recover)) + } + + item { + Dividers.Default() + } + + item { + Texts.SectionHeader( + text = stringResource(R.string.AccountSettingsFragment__two_factor_authentication) + ) + } + + item { + Rows.TextRow( + icon = SignalIcons.DevicePhone.imageVector, + text = stringResource(R.string.AccountSettingsFragment__authenticator_app), + label = if (state.signalLogin.hasAuthenticatorApp) { + stringResource(R.string.AccountSettingsFragment__enabled) + } else { + stringResource(R.string.AccountSettingsFragment__use_an_authenticator_app) + }, + onClick = { onEvent(AccountSettingsEvent.AuthenticatorAppClicked) }, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_AUTHENTICATOR_APP) + ) + } + + item { + Rows.TextRow( + icon = SignalIcons.Key.imageVector, + text = stringResource(R.string.AccountSettingsFragment__security_keys), + label = stringResource(R.string.AccountSettingsFragment__set_up_using_a_physical_security_key), + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_SECURITY_KEYS) + ) + } + + item { + SectionFooter(text = stringResource(R.string.AccountSettingsFragment__use_a_second_form_of_authentication)) + } + + item { + Dividers.Default() + } + } + item { Texts.SectionHeader( text = stringResource(R.string.preferences_app_protection__signal_pin) @@ -241,6 +316,86 @@ fun AccountSettingsScreen( } } +/** + * The card at the top of the screen that summarizes the user's Signal Login. It has no destination yet, so it isn't + * clickable. + */ +@Composable +private fun SignalLoginCard( + keyCount: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .clip(RoundedCornerShape(18.dp)) + .background(SignalTheme.colors.colorSurface2) + .padding(horizontal = 18.dp, vertical = 20.dp) + .testTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.image_signal_login_card), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier + .size(width = 91.dp, height = 52.dp) + .clip(RoundedCornerShape(8.dp)) + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 20.dp) + ) { + Text( + text = stringResource(R.string.AccountSettingsFragment__account_and_recovery), + style = MaterialTheme.typography.bodyLarge + ) + + Text( + text = pluralStringResource(R.plurals.AccountSettingsFragment__d_keys, keyCount, keyCount), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Icon( + imageVector = SignalIcons.ChevronRight.imageVector, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Explanatory text shown underneath a section, ending in a "Learn more" link that has nowhere to go yet. + */ +@Composable +private fun SectionFooter( + text: String, + modifier: Modifier = Modifier +) { + val learnMore = stringResource(R.string.AccountSettingsFragment__learn_more) + val primaryColor = MaterialTheme.colorScheme.primary + + Text( + text = remember(text, learnMore, primaryColor) { + buildAnnotatedString { + append(text) + append(" ") + withStyle(SpanStyle(color = primaryColor)) { + append(learnMore) + } + } + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter), vertical = 16.dp) + ) +} + @Composable private fun DeleteAllDataConfirmationDialog( onEvent: (AccountSettingsEvent) -> Unit @@ -401,6 +556,21 @@ private fun AccountSettingsScreenPreview() { } } +@DayNightPreviews +@Composable +private fun AccountSettingsScreenSignalLoginPreview() { + Previews.Preview { + AccountSettingsScreen( + state = AccountSettingsState( + hasPin = true, + pinRemindersEnabled = true, + signalLogin = AccountSettingsState.SignalLogin(keyCount = 2, hasAuthenticatorApp = false) + ), + onEvent = {} + ) + } +} + @DayNightPreviews @Composable private fun AccountSettingsScreenDeprecatedPreview() { diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsState.kt index f1eaa90e15..ff8b450a4a 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsState.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsState.kt @@ -13,12 +13,22 @@ data class AccountSettingsState( val userUnregistered: Boolean = false, val clientDeprecated: Boolean = false, val canTransferWhileUnregistered: Boolean = true, + val signalLogin: SignalLogin? = null, val dialog: Dialog = Dialog.None ) { val isNotDeprecatedOrUnregistered: Boolean get() = !(userUnregistered || clientDeprecated) + /** + * The Signal Login and two-factor authentication sections, which only exist when phone-numberless registration is + * enabled. Null means the sections aren't shown at all. + */ + data class SignalLogin( + val keyCount: Int, + val hasAuthenticatorApp: Boolean + ) + /** Whichever dialog the screen is showing, if any. Only one is ever up at a time. */ sealed interface Dialog { data object None : Dialog diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryAction.kt new file mode 100644 index 0000000000..58f711b101 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryAction.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorcodeentry + +/** + * One-shot side effects that need the nav graph, and therefore have to be carried out by the fragment hosting + * [AuthenticatorCodeEntryScreen] rather than the screen itself. + * + * Actions are logged, so be sure `toString()` contains nothing sensitive. + */ +sealed interface AuthenticatorCodeEntryAction { + + /** Leave the screen. */ + data object NavigateBack : AuthenticatorCodeEntryAction + + /** The authenticator app is set up, so go back to account settings. */ + data object NavigateToAccountSettings : AuthenticatorCodeEntryAction + + /** Tell the user their authenticator app was added. */ + data object ShowAuthenticatorAppAdded : AuthenticatorCodeEntryAction +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryEvent.kt new file mode 100644 index 0000000000..a34f2dc15b --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryEvent.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorcodeentry + +/** + * Reminder that these events are logged, so don't include anything sensitive in the toString. + */ +sealed interface AuthenticatorCodeEntryEvent { + + /** The user tapped the navigation (back) icon. */ + data object NavigateBackClicked : AuthenticatorCodeEntryEvent + + /** The user typed in the code field. */ + data class CodeChanged(val code: String) : AuthenticatorCodeEntryEvent { + override fun toString(): String = "CodeChanged(length=${code.length})" + } + + /** The user submitted the code they entered. */ + data object DoneClicked : AuthenticatorCodeEntryEvent +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryScreen.kt new file mode 100644 index 0000000000..61b61bcf3a --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryScreen.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorcodeentry + +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import org.signal.appsettings.R +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons + +@VisibleForTesting +object AuthenticatorCodeEntryTestTags { + const val CODE_INPUT = "code-input" + const val BUTTON_DONE = "button-done" +} + +/** + * Collects the one-time code the user's authenticator app generated, which is the last step of setting one up. + */ +@Composable +fun AuthenticatorCodeEntryScreen( + state: AuthenticatorCodeEntryState, + onEvent: (AuthenticatorCodeEntryEvent) -> Unit +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Scaffolds.Settings( + title = stringResource(R.string.AuthenticatorCodeEntryScreen__enter_your_code), + onNavigationClick = { onEvent(AuthenticatorCodeEntryEvent.NavigateBackClicked) }, + navigationIcon = SignalIcons.ArrowStart.imageVector + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .imePadding(), + horizontalAlignment = Alignment.End + ) { + Text( + text = stringResource(R.string.AuthenticatorCodeEntryScreen__enter_the_6_digit_code), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + ) + + TextField( + value = state.code, + onValueChange = { onEvent(AuthenticatorCodeEntryEvent.CodeChanged(it)) }, + label = { Text(text = stringResource(R.string.AuthenticatorCodeEntryScreen__code)) }, + singleLine = true, + enabled = !state.submitting, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (state.canSubmit) onEvent(AuthenticatorCodeEntryEvent.DoneClicked) }), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .focusRequester(focusRequester) + .testTag(AuthenticatorCodeEntryTestTags.CODE_INPUT) + ) + + Spacer(modifier = Modifier.weight(1f)) + + Buttons.LargeTonal( + onClick = { onEvent(AuthenticatorCodeEntryEvent.DoneClicked) }, + enabled = state.canSubmit, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 24.dp) + .testTag(AuthenticatorCodeEntryTestTags.BUTTON_DONE) + ) { + Text(text = stringResource(R.string.AuthenticatorCodeEntryScreen__done)) + } + } + } +} + +@DayNightPreviews +@Composable +private fun AuthenticatorCodeEntryScreenPreview() { + Previews.Preview { + AuthenticatorCodeEntryScreen( + state = AuthenticatorCodeEntryState(code = "123456"), + onEvent = {} + ) + } +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryState.kt new file mode 100644 index 0000000000..1c5ace1f12 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryState.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorcodeentry + +data class AuthenticatorCodeEntryState( + val code: String = "", + val submitting: Boolean = false +) { + + val canSubmit: Boolean + get() = code.length == CODE_LENGTH && !submitting + + override fun toString(): String = "AuthenticatorCodeEntryState(codeLength=${code.length}, submitting=$submitting)" + + companion object { + const val CODE_LENGTH = 6 + } +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupAction.kt new file mode 100644 index 0000000000..11f293ddc2 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupAction.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorsetup + +/** + * One-shot side effects that need an Activity or the nav graph, and therefore have to be carried out by the fragment + * hosting [AuthenticatorSetupScreen] rather than the screen itself. + * + * Actions are logged, so be sure `toString()` contains nothing sensitive. + */ +sealed interface AuthenticatorSetupAction { + + /** Leave the screen. */ + data object NavigateBack : AuthenticatorSetupAction + + /** Hand [uri] off to whichever authenticator app the user has installed. */ + data class LaunchAuthenticatorApp(val uri: String) : AuthenticatorSetupAction { + override fun toString(): String = "LaunchAuthenticatorApp()" + } + + /** Put [key] on the clipboard. */ + data class CopyKeyToClipboard(val key: String) : AuthenticatorSetupAction { + override fun toString(): String = "CopyKeyToClipboard()" + } + + /** Tell the user the setup key was copied. */ + data object ShowKeyCopied : AuthenticatorSetupAction + + /** Tell the user we couldn't find an app to hand the setup key to. */ + data object ShowNoAuthenticatorAppFound : AuthenticatorSetupAction + + /** Move on to the screen where the user enters a code from their authenticator app. */ + data object NavigateToCodeEntry : AuthenticatorSetupAction +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupEvent.kt new file mode 100644 index 0000000000..3a325488c8 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupEvent.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorsetup + +/** + * Reminder that these events are logged, so don't include anything sensitive in the toString. + */ +sealed interface AuthenticatorSetupEvent { + + /** The user tapped the navigation (close) icon. */ + data object NavigateBackClicked : AuthenticatorSetupEvent + + /** The user tapped the button that hands the setup key off to their authenticator app. */ + data object OpenAuthenticatorAppClicked : AuthenticatorSetupEvent + + /** The user tapped the button that copies the setup key. */ + data object CopyKeyClicked : AuthenticatorSetupEvent + + /** The fragment reported that no installed app could handle the setup link. */ + data object NoAuthenticatorAppFound : AuthenticatorSetupEvent + + /** The user finished the steps and is ready to enter a code. */ + data object ContinueClicked : AuthenticatorSetupEvent +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupScreen.kt new file mode 100644 index 0000000000..6a3f9b2488 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupScreen.kt @@ -0,0 +1,317 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorsetup + +import androidx.annotation.DrawableRes +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +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.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.signal.appsettings.R +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons +import org.signal.core.ui.compose.theme.SignalTheme + +@VisibleForTesting +object AuthenticatorSetupTestTags { + const val SCROLLER = "scroller" + const val BUTTON_OPEN = "button-open" + const val BUTTON_COPY = "button-copy" + const val BUTTON_CONTINUE = "button-continue" + const val SETUP_KEY = "setup-key" +} + +/** + * Walks the user through pairing an authenticator app with their account, ending in the code entry screen. + */ +@Composable +fun AuthenticatorSetupScreen( + state: AuthenticatorSetupState, + onEvent: (AuthenticatorSetupEvent) -> Unit +) { + Scaffolds.Settings( + title = stringResource(R.string.AuthenticatorSetupScreen__authenticator_app), + onNavigationClick = { onEvent(AuthenticatorSetupEvent.NavigateBackClicked) }, + navigationIcon = SignalIcons.X.imageVector + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .testTag(AuthenticatorSetupTestTags.SCROLLER) + ) { + TextWithLearnMore( + text = stringResource(R.string.AuthenticatorSetupScreen__follow_these_steps), + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) + + StepCard( + title = stringResource(R.string.AuthenticatorSetupScreen__step_1), + body = stringResource(R.string.AuthenticatorSetupScreen__install_a_trusted_authenticator_app), + illustration = { + StepImage( + image = R.drawable.image_authenticator_install_app, + width = 48.dp, + height = 52.dp + ) + } + ) + + StepCard( + title = stringResource(R.string.AuthenticatorSetupScreen__step_2), + body = stringResource(R.string.AuthenticatorSetupScreen__open_your_authenticator_app), + illustration = { + StepImage( + image = R.drawable.image_authenticator_open_app, + width = 45.dp, + height = 86.dp + ) + } + ) { + SurfaceButton( + text = stringResource(R.string.AuthenticatorSetupScreen__open), + icon = SignalIcons.Open, + onClick = { onEvent(AuthenticatorSetupEvent.OpenAuthenticatorAppClicked) }, + modifier = Modifier + .padding(top = 16.dp) + .testTag(AuthenticatorSetupTestTags.BUTTON_OPEN) + ) + + HorizontalDivider( + thickness = 1.5.dp, + color = SignalTheme.colors.colorSurface5, + modifier = Modifier.padding(top = 24.dp) + ) + + Text( + text = stringResource(R.string.AuthenticatorSetupScreen__or_you_can_copy_this_key), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 24.dp) + ) + + Text( + text = state.setupKey, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontSize = 15.sp, + lineHeight = 28.sp, + letterSpacing = 0.9.sp + ), + modifier = Modifier + .padding(top = 4.dp) + .testTag(AuthenticatorSetupTestTags.SETUP_KEY) + ) + + SurfaceButton( + text = stringResource(R.string.AuthenticatorSetupScreen__copy), + icon = SignalIcons.Copy, + onClick = { onEvent(AuthenticatorSetupEvent.CopyKeyClicked) }, + modifier = Modifier + .padding(top = 16.dp) + .testTag(AuthenticatorSetupTestTags.BUTTON_COPY) + ) + } + + StepCard( + title = stringResource(R.string.AuthenticatorSetupScreen__step_3), + body = stringResource(R.string.AuthenticatorSetupScreen__copy_the_code_thats_generated), + illustration = { + StepImage( + image = R.drawable.image_authenticator_copy_code, + width = 62.dp, + height = 38.dp + ) + } + ) + + Spacer(modifier = Modifier.height(24.dp)) + } + + Buttons.LargeTonal( + onClick = { onEvent(AuthenticatorSetupEvent.ContinueClicked) }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 40.dp, vertical = 16.dp) + .testTag(AuthenticatorSetupTestTags.BUTTON_CONTINUE) + ) { + Text(text = stringResource(R.string.AuthenticatorSetupScreen__continue)) + } + } + } +} + +/** + * Body text with a "Learn more" link appended, which has nowhere to go yet. + */ +@Composable +private fun TextWithLearnMore( + text: String, + modifier: Modifier = Modifier +) { + val learnMore = stringResource(R.string.AuthenticatorSetupScreen__learn_more) + val primaryColor = MaterialTheme.colorScheme.primary + + Text( + text = remember(text, learnMore, primaryColor) { + buildAnnotatedString { + append(text) + append(" ") + withStyle(SpanStyle(color = primaryColor)) { + append(learnMore) + } + } + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier + ) +} + +/** + * One of the numbered steps, which is a card with a title and body alongside an illustration, plus whatever [content] + * the step needs underneath. The illustration is centered on the title and body, and [content] runs the full width of + * the card below both. + */ +@Composable +private fun StepCard( + title: String, + body: String, + illustration: @Composable () -> Unit, + content: @Composable ColumnScope.() -> Unit = {} +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clip(RoundedCornerShape(24.dp)) + .background(SignalTheme.colors.colorSurface2) + .padding(horizontal = 24.dp, vertical = 20.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall + ) + + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } + + illustration() + } + + content() + } +} + +@Composable +private fun StepImage( + @DrawableRes image: Int, + width: Dp, + height: Dp +) { + Image( + painter = painterResource(image), + contentDescription = null, + modifier = Modifier.size(width = width, height = height) + ) +} + +/** + * The pill button used inside the step cards, which sits on the card rather than on the page and so uses the surface + * color as its background. + */ +@Composable +private fun SurfaceButton( + text: String, + icon: SignalIcons, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Buttons.MediumTonal( + onClick = onClick, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + modifier = modifier + ) { + Icon( + painter = icon.painter, + contentDescription = null, + modifier = Modifier + .padding(end = 8.dp) + .size(20.dp) + ) + + Text(text = text) + } +} + +@DayNightPreviews +@Composable +private fun AuthenticatorSetupScreenPreview() { + Previews.Preview { + AuthenticatorSetupScreen( + state = AuthenticatorSetupState(setupKey = "KVZ7WL3FDDWJZMTOB7PLZPKVRFD4LYSX"), + onEvent = {} + ) + } +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupState.kt new file mode 100644 index 0000000000..28ac7c7c40 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupState.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorsetup + +data class AuthenticatorSetupState( + /** The key the user hands to their authenticator app, either through the app link or by copying it. */ + val setupKey: String = "" +) { + override fun toString(): String = "AuthenticatorSetupState(setupKey=${if (setupKey.isEmpty()) "empty" else "present"})" +} diff --git a/feature/app-settings/src/main/res/drawable/image_authenticator_copy_code.xml b/feature/app-settings/src/main/res/drawable/image_authenticator_copy_code.xml new file mode 100644 index 0000000000..38f8b52281 --- /dev/null +++ b/feature/app-settings/src/main/res/drawable/image_authenticator_copy_code.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/app-settings/src/main/res/drawable/image_authenticator_install_app.xml b/feature/app-settings/src/main/res/drawable/image_authenticator_install_app.xml new file mode 100644 index 0000000000..2e3a3dad1f --- /dev/null +++ b/feature/app-settings/src/main/res/drawable/image_authenticator_install_app.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/feature/app-settings/src/main/res/drawable/image_authenticator_open_app.xml b/feature/app-settings/src/main/res/drawable/image_authenticator_open_app.xml new file mode 100644 index 0000000000..264f5ce884 --- /dev/null +++ b/feature/app-settings/src/main/res/drawable/image_authenticator_open_app.xml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/feature/app-settings/src/main/res/drawable/image_signal_login_card.xml b/feature/app-settings/src/main/res/drawable/image_signal_login_card.xml new file mode 100644 index 0000000000..e09443fd2e --- /dev/null +++ b/feature/app-settings/src/main/res/drawable/image_signal_login_card.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + diff --git a/feature/app-settings/src/main/res/values/strings.xml b/feature/app-settings/src/main/res/values/strings.xml index fbd5cb5e27..a95561ac52 100644 --- a/feature/app-settings/src/main/res/values/strings.xml +++ b/feature/app-settings/src/main/res/values/strings.xml @@ -44,4 +44,75 @@ Change phone number Your account data + + Signal Login + + Account & recovery + + + %1$d key + %1$d keys + + + Your Signal Login is used to recover and restore your account. Keep these keys stored safely in a password manager you trust. + + Two-factor authentication + + Authenticator app + + Use an authenticator app to generate one-time verification codes + + Enabled + + Security keys + + Set up using a physical security key + + Use a second form of authentication to protect your account when using your Signal Login on a new device. + + Learn more + + + + Authenticator app + + Follow these steps to set up your authenticator app. + + Learn more + + Step 1 + + Install a trusted authenticator app on your device. + + Step 2 + + Open your authenticator app by tapping the button below to add your Signal account. + + Open + + Or you can copy this key to manually set it up. + + Copy + + Copied to clipboard + + No authenticator app found + + Step 3 + + Copy the code that\'s generated and return here to continue. + + Continue + + + + Enter your code + + Enter the 6-digit code from your authenticator app. + + Code + + Done + + Authenticator app added diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/account/AccountSettingsScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/account/AccountSettingsScreenTest.kt index aa5e8679bc..dfa83f4ad0 100644 --- a/feature/app-settings/src/test/java/org/signal/appsettings/account/AccountSettingsScreenTest.kt +++ b/feature/app-settings/src/test/java/org/signal/appsettings/account/AccountSettingsScreenTest.kt @@ -289,6 +289,25 @@ class AccountSettingsScreenTest { .assertIsNotEnabled() } + @Test + fun givenNoSignalLogin_whenScreenDisplayed_thenTwoFactorSectionIsAbsent() { + setContent(createState()) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertDoesNotExist() + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_AUTHENTICATOR_APP).assertDoesNotExist() + } + + @Test + fun givenASignalLogin_whenIClickAuthenticatorApp_thenIExpectAuthenticatorAppEvent() { + setContent(createState(signalLogin = AccountSettingsState.SignalLogin(keyCount = 2, hasAuthenticatorApp = false))) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.CARD_SIGNAL_LOGIN).assertIsDisplayed() + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_AUTHENTICATOR_APP).performClick() + + assertThat(events).contains(AccountSettingsEvent.AuthenticatorAppClicked) + } + private fun setContent(state: AccountSettingsState) { composeTestRule.setContent { AccountSettingsScreen( @@ -311,6 +330,7 @@ class AccountSettingsScreenTest { userUnregistered: Boolean = false, clientDeprecated: Boolean = false, canTransferWhileUnregistered: Boolean = true, + signalLogin: AccountSettingsState.SignalLogin? = null, dialog: Dialog = Dialog.None ): AccountSettingsState { return AccountSettingsState( @@ -321,6 +341,7 @@ class AccountSettingsScreenTest { userUnregistered = userUnregistered, clientDeprecated = clientDeprecated, canTransferWhileUnregistered = canTransferWhileUnregistered, + signalLogin = signalLogin, dialog = dialog ) } diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryScreenTest.kt new file mode 100644 index 0000000000..63997d7d8e --- /dev/null +++ b/feature/app-settings/src/test/java/org/signal/appsettings/authenticatorcodeentry/AuthenticatorCodeEntryScreenTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorcodeentry + +import android.app.Application +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +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 AuthenticatorCodeEntryScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val events = mutableListOf() + + @Test + fun givenAPartialCode_whenScreenDisplayed_thenDoneIsDisabled() { + setContent(AuthenticatorCodeEntryState(code = "123")) + + composeTestRule.onNodeWithTag(AuthenticatorCodeEntryTestTags.BUTTON_DONE).assertIsNotEnabled() + } + + @Test + fun givenAFullCode_whenIClickDone_thenIExpectDoneEvent() { + setContent(AuthenticatorCodeEntryState(code = "123456")) + + composeTestRule.onNodeWithTag(AuthenticatorCodeEntryTestTags.BUTTON_DONE) + .assertIsEnabled() + .performClick() + + assertThat(events).contains(AuthenticatorCodeEntryEvent.DoneClicked) + } + + @Test + fun whenITypeInTheCodeField_thenIExpectCodeChangedEvent() { + setContent(AuthenticatorCodeEntryState()) + + composeTestRule.onNodeWithTag(AuthenticatorCodeEntryTestTags.CODE_INPUT).performTextInput("123456") + + assertThat(events).contains(AuthenticatorCodeEntryEvent.CodeChanged("123456")) + } + + private fun setContent(state: AuthenticatorCodeEntryState) { + composeTestRule.setContent { + AuthenticatorCodeEntryScreen( + state = state, + onEvent = { events += it } + ) + } + } +} diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupScreenTest.kt new file mode 100644 index 0000000000..a0e30d5741 --- /dev/null +++ b/feature/app-settings/src/test/java/org/signal/appsettings/authenticatorsetup/AuthenticatorSetupScreenTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.authenticatorsetup + +import android.app.Application +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +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 AuthenticatorSetupScreenTest { + + companion object { + private const val SETUP_KEY = "KVZ7WL3FDDWJZMTOB7PLZPKVRFD4LYSX" + } + + @get:Rule + val composeTestRule = createComposeRule() + + private val events = mutableListOf() + + @Test + fun whenIClickOpen_thenIExpectOpenAuthenticatorAppEvent() { + setContent() + + scrollTo(AuthenticatorSetupTestTags.BUTTON_OPEN) + + composeTestRule.onNodeWithTag(AuthenticatorSetupTestTags.BUTTON_OPEN).performClick() + + assertThat(events).contains(AuthenticatorSetupEvent.OpenAuthenticatorAppClicked) + } + + @Test + fun whenIClickCopy_thenIExpectCopyKeyEvent() { + setContent() + + scrollTo(AuthenticatorSetupTestTags.BUTTON_COPY) + + composeTestRule.onNodeWithTag(AuthenticatorSetupTestTags.BUTTON_COPY).performClick() + + assertThat(events).contains(AuthenticatorSetupEvent.CopyKeyClicked) + } + + @Test + fun whenIClickContinue_thenIExpectContinueEvent() { + setContent() + + composeTestRule.onNodeWithTag(AuthenticatorSetupTestTags.BUTTON_CONTINUE) + .assertIsDisplayed() + .performClick() + + assertThat(events).contains(AuthenticatorSetupEvent.ContinueClicked) + } + + private fun setContent() { + composeTestRule.setContent { + AuthenticatorSetupScreen( + state = AuthenticatorSetupState(setupKey = SETUP_KEY), + onEvent = { events += it } + ) + } + } + + private fun scrollTo(testTag: String) { + composeTestRule.onNodeWithTag(AuthenticatorSetupTestTags.SCROLLER) + .performScrollToNode(hasTestTag(testTag)) + } +}