Add numberless account setting screen placeholders.

This commit is contained in:
Greyson Parrelli
2026-08-19 19:05:49 -04:00
committed by Cody Henthorne
parent e935981db3
commit 8954e55e0e
36 changed files with 1799 additions and 1 deletions
@@ -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)
@@ -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) {
@@ -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
}
)
}
}
@@ -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
}
@@ -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()
}
}
}
}
@@ -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<AuthenticatorCodeEntryEvent>(TAG) {
companion object {
private val TAG = Log.tag(AuthenticatorCodeEntryViewModel::class)
}
private val _state = MutableStateFlow(AuthenticatorCodeEntryState())
private val _actions = Channel<AuthenticatorCodeEntryAction>(Channel.BUFFERED)
val state: StateFlow<AuthenticatorCodeEntryState> = _state.asStateFlow()
val actions: Flow<AuthenticatorCodeEntryAction> = _actions.receiveAsFlow()
override suspend fun processEvent(event: AuthenticatorCodeEntryEvent) {
when (event) {
AuthenticatorCodeEntryEvent.NavigateBackClicked -> {
_actions.send(AuthenticatorCodeEntryAction.NavigateBack)
}
is AuthenticatorCodeEntryEvent.CodeChanged -> {
val digits = event.code.filter { it.isDigit() }.take(AuthenticatorCodeEntryState.CODE_LENGTH)
_state.update { it.copy(code = digits) }
}
AuthenticatorCodeEntryEvent.DoneClicked -> {
if (!_state.value.canSubmit) {
return
}
Log.i(TAG, "Accepting the entered code without verifying it, which is all we can do until this is wired up.")
_state.update { it.copy(submitting = true) }
repository.setHasAuthenticatorApp(true)
_actions.send(AuthenticatorCodeEntryAction.ShowAuthenticatorAppAdded)
_actions.send(AuthenticatorCodeEntryAction.NavigateToAccountSettings)
}
}
}
}
@@ -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
}
}
@@ -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()
}
}
@@ -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<AuthenticatorSetupEvent>(TAG) {
companion object {
private val TAG = Log.tag(AuthenticatorSetupViewModel::class)
private const val ACCOUNT_LABEL = "Signal"
}
private val _state = MutableStateFlow(AuthenticatorSetupState(setupKey = repository.getSetupKey()))
private val _actions = Channel<AuthenticatorSetupAction>(Channel.BUFFERED)
val state: StateFlow<AuthenticatorSetupState> = _state.asStateFlow()
val actions: Flow<AuthenticatorSetupAction> = _actions.receiveAsFlow()
override suspend fun processEvent(event: AuthenticatorSetupEvent) {
when (event) {
AuthenticatorSetupEvent.NavigateBackClicked -> {
_actions.send(AuthenticatorSetupAction.NavigateBack)
}
AuthenticatorSetupEvent.OpenAuthenticatorAppClicked -> {
_actions.send(AuthenticatorSetupAction.LaunchAuthenticatorApp(buildSetupUri(_state.value.setupKey)))
}
AuthenticatorSetupEvent.CopyKeyClicked -> {
_actions.send(AuthenticatorSetupAction.CopyKeyToClipboard(_state.value.setupKey))
_actions.send(AuthenticatorSetupAction.ShowKeyCopied)
}
AuthenticatorSetupEvent.NoAuthenticatorAppFound -> {
_actions.send(AuthenticatorSetupAction.ShowNoAuthenticatorAppFound)
}
AuthenticatorSetupEvent.ContinueClicked -> {
_actions.send(AuthenticatorSetupAction.NavigateToCodeEntry)
}
}
}
private fun buildSetupUri(setupKey: String): String {
return Uri.Builder()
.scheme("otpauth")
.authority("totp")
.appendPath(ACCOUNT_LABEL)
.appendQueryParameter("secret", setupKey)
.appendQueryParameter("issuer", ACCOUNT_LABEL)
.build()
.toString()
}
}
@@ -216,8 +216,33 @@
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_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" />
</fragment>
<fragment
android:id="@+id/authenticatorSetupFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.authenticator.AuthenticatorSetupFragment"
android:label="authenticator_setup_fragment">
<action
android:id="@+id/action_authenticatorSetupFragment_to_authenticatorCodeEntryFragment"
app:destination="@id/authenticatorCodeEntryFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/authenticatorCodeEntryFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.authenticator.AuthenticatorCodeEntryFragment"
android:label="authenticator_code_entry_fragment" />
<fragment
android:id="@+id/linkedDeviceAccountSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.LinkedDeviceAccountSettingsFragment"
@@ -9,6 +9,7 @@ import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
import assertk.assertions.isNull
import assertk.assertions.isTrue
import io.mockk.coEvery
import io.mockk.coVerify
@@ -61,6 +62,8 @@ class AccountSettingsViewModelTest {
every { repository.isUserUnregistered() } returns false
every { repository.isClientDeprecated() } returns false
every { repository.getPinKeyboardType() } returns PinKeyboardType.NUMERIC
every { repository.isPhoneNumberlessRegistrationEnabled() } returns false
every { repository.hasAuthenticatorApp() } returns false
every { repository.verifyLocalPin(any()) } answers { firstArg<String>() == 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<AccountSettingsAction>): List<AccountSettingsAction> {
@@ -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<AuthenticatorCodeEntryAction>): List<AuthenticatorCodeEntryAction> {
val collected = mutableListOf<AuthenticatorCodeEntryAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}
@@ -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<AuthenticatorSetupAction>): List<AuthenticatorSetupAction> {
val collected = mutableListOf<AuthenticatorSetupAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}
@@ -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)),
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M8.5 16.87c0-0.82-0.67-1.5-1.5-1.5s-1.5 0.68-1.5 1.5c0 0.83 0.67 1.5 1.5 1.5s1.5-0.67 1.5-1.5Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M17.7 2.13c-0.36 0-0.7 0.14-0.97 0.4l-6.5 6.5C9.6 8.86 8.93 8.76 8.25 8.76c-2.95 0-5.47 1.86-6.44 4.47-0.28 0.75-0.43 1.56-0.43 2.4 0 0.76 0.12 1.48 0.34 2.16 0.9 2.74 3.49 4.72 6.53 4.72 3.8 0 6.88-3.08 6.88-6.88 0-0.82-0.15-1.6-0.41-2.34l0.9-0.9H17c0.76 0 1.38-0.62 1.38-1.38V8.88h2.12c0.76 0 1.37-0.62 1.37-1.38v-4c0-0.76-0.61-1.38-1.37-1.38h-2.8Zm0.16 1.75h2.26v3.25H18c-0.76 0-1.37 0.61-1.37 1.37v2.13h-1.17c-0.37 0-0.72 0.14-0.98 0.4l-1.85 1.86 0.26 0.56c0.31 0.66 0.49 1.4 0.49 2.17 0 2.84-2.3 5.13-5.13 5.13-2.27 0-4.2-1.47-4.87-3.52-0.16-0.5-0.25-1.04-0.25-1.6 0-0.64 0.11-1.24 0.32-1.8 0.73-1.95 2.6-3.33 4.8-3.33 0.67 0 1.3 0.13 1.89 0.36l0.54 0.21 7.18-7.2Z"/>
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M9.36 2.13c-1.09 0-1.96 0-2.66 0.05C5.98 2.24 5.36 2.37 4.8 2.66 3.87 3.12 3.12 3.87 2.66 4.79 2.36 5.36 2.24 5.98 2.18 6.7 2.12 7.4 2.12 8.27 2.12 9.36v5.28c0 1.09 0 1.96 0.06 2.66 0.06 0.72 0.19 1.34 0.48 1.91 0.46 0.92 1.21 1.67 2.13 2.13 0.57 0.3 1.19 0.42 1.91 0.48 0.7 0.05 1.57 0.05 2.66 0.05h5.28c1.09 0 1.96 0 2.66-0.05 0.72-0.06 1.34-0.18 1.91-0.48 0.92-0.46 1.67-1.2 2.13-2.13 0.3-0.57 0.42-1.19 0.48-1.91 0.05-0.7 0.05-1.57 0.05-2.66V13c0-0.48-0.39-0.88-0.87-0.88s-0.88 0.4-0.88 0.88v1.6c0 1.13 0 1.93-0.05 2.56-0.05 0.6-0.14 0.97-0.29 1.26-0.3 0.59-0.77 1.06-1.36 1.36-0.29 0.15-0.65 0.24-1.26 0.3-0.63 0.04-1.43 0.05-2.56 0.05H9.4c-1.13 0-1.93 0-2.56-0.06-0.6-0.05-0.97-0.14-1.26-0.29-0.59-0.3-1.06-0.77-1.36-1.36-0.15-0.29-0.24-0.65-0.3-1.26-0.04-0.63-0.04-1.43-0.04-2.56V9.4c0-1.13 0-1.93 0.05-2.56 0.05-0.6 0.14-0.97 0.29-1.26C4.52 5 4.99 4.52 5.58 4.22c0.29-0.15 0.65-0.24 1.26-0.3 0.63-0.04 1.43-0.04 2.56-0.04H11c0.48 0 0.88-0.4 0.88-0.88S11.47 2.12 11 2.12H9.36Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M21.88 3c0-0.23-0.1-0.45-0.26-0.62-0.17-0.16-0.39-0.25-0.62-0.25h-6.5c-0.48 0-0.88 0.39-0.88 0.87s0.4 0.88 0.88 0.88h4.64l-1.5 1.25-7.76 7.75c-0.34 0.34-0.34 0.9 0 1.24 0.34 0.34 0.9 0.34 1.24 0l7.75-7.75 1.25-1.5V9.5c0 0.48 0.4 0.88 0.88 0.88s0.88-0.4 0.88-0.88V3Z"/>
</vector>
@@ -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
@@ -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
@@ -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() {
@@ -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
@@ -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
}
@@ -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
}
@@ -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 = {}
)
}
}
@@ -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
}
}
@@ -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
}
@@ -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
}
@@ -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 = {}
)
}
}
@@ -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"})"
}
@@ -0,0 +1,59 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="62dp"
android:height="38dp"
android:viewportWidth="62"
android:viewportHeight="38">
<path
android:fillColor="#D9E0EE"
android:pathData="M12,1h38a11,11 0 0 1 11,11v14a11,11 0 0 1 -11,11h-38a11,11 0 0 1 -11,-11v-14a11,11 0 0 1 11,-11z"/>
<group android:translateX="1">
<clip-path android:pathData="M12,0h37a12,12 0 0 1 12,12v14a12,12 0 0 1 -12,12h-37a12,12 0 0 1 -12,-12v-14a12,12 0 0 1 12,-12z"/>
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.45"
android:pathData="M-19,-6.6a22.4,22.4 0 1 0 44.8,0a22.4,22.4 0 1 0 -44.8,0z"/>
<path
android:fillColor="#C0CBE2"
android:pathData="M77.9424,27.8789C77.9424,32.2422 74.4053,35.7793 70.042,35.7793C65.6787,35.7793 62.1416,39.3164 62.1416,43.6797V97C62.1416,101.418 58.5599,105 54.1416,105H16.1416C11.7233,105 8.1416,101.418 8.1416,97V40C8.1416,35.5817 11.7233,32 16.1416,32H47.1592C51.5775,32 55.1592,28.4183 55.1592,24V-38C55.1592,-42.4183 58.7409,-46 63.1592,-46H69.9424C74.3607,-46 77.9424,-42.4183 77.9424,-38V27.8789Z"/>
</group>
<path
android:strokeColor="#4A5775"
android:strokeWidth="2"
android:pathData="M12,1h38a11,11 0 0 1 11,11v14a11,11 0 0 1 -11,11h-38a11,11 0 0 1 -11,-11v-14a11,11 0 0 1 11,-11z"/>
<group android:translateX="17.5" android:translateY="20">
<path android:fillColor="#4A5775" android:pathData="M-0.9,-2.4a0.9,0.9 0 0 1 1.8,0v4.8a0.9,0.9 0 0 1 -1.8,0z"/>
<group android:rotation="60">
<path android:fillColor="#4A5775" android:pathData="M-0.9,-2.4a0.9,0.9 0 0 1 1.8,0v4.8a0.9,0.9 0 0 1 -1.8,0z"/>
</group>
<group android:rotation="120">
<path android:fillColor="#4A5775" android:pathData="M-0.9,-2.4a0.9,0.9 0 0 1 1.8,0v4.8a0.9,0.9 0 0 1 -1.8,0z"/>
</group>
</group>
<group android:translateX="26.5" android:translateY="20">
<path android:fillColor="#4A5775" android:pathData="M-0.9,-2.4a0.9,0.9 0 0 1 1.8,0v4.8a0.9,0.9 0 0 1 -1.8,0z"/>
<group android:rotation="60">
<path android:fillColor="#4A5775" android:pathData="M-0.9,-2.4a0.9,0.9 0 0 1 1.8,0v4.8a0.9,0.9 0 0 1 -1.8,0z"/>
</group>
<group android:rotation="120">
<path android:fillColor="#4A5775" android:pathData="M-0.9,-2.4a0.9,0.9 0 0 1 1.8,0v4.8a0.9,0.9 0 0 1 -1.8,0z"/>
</group>
</group>
<group android:translateX="35.5" android:translateY="20">
<path android:fillColor="#4A5775" android:pathData="M-0.9,-2.4a0.9,0.9 0 0 1 1.8,0v4.8a0.9,0.9 0 0 1 -1.8,0z"/>
<group android:rotation="60">
<path android:fillColor="#4A5775" android:pathData="M-0.9,-2.4a0.9,0.9 0 0 1 1.8,0v4.8a0.9,0.9 0 0 1 -1.8,0z"/>
</group>
<group android:rotation="120">
<path android:fillColor="#4A5775" android:pathData="M-0.9,-2.4a0.9,0.9 0 0 1 1.8,0v4.8a0.9,0.9 0 0 1 -1.8,0z"/>
</group>
</group>
<group android:translateX="44.5" android:translateY="20">
<path android:fillColor="#4A5775" android:pathData="M-0.9,-2.4a0.9,0.9 0 0 1 1.8,0v4.8a0.9,0.9 0 0 1 -1.8,0z"/>
<group android:rotation="60">
<path android:fillColor="#4A5775" android:pathData="M-0.9,-2.4a0.9,0.9 0 0 1 1.8,0v4.8a0.9,0.9 0 0 1 -1.8,0z"/>
</group>
<group android:rotation="120">
<path android:fillColor="#4A5775" android:pathData="M-0.9,-2.4a0.9,0.9 0 0 1 1.8,0v4.8a0.9,0.9 0 0 1 -1.8,0z"/>
</group>
</group>
</vector>
@@ -0,0 +1,35 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="52dp"
android:viewportWidth="48"
android:viewportHeight="52">
<path
android:fillColor="#A4B0CB"
android:strokeColor="#4A5775"
android:strokeWidth="2"
android:pathData="M14,13h20a13,13 0 0 1 13,13v12a13,13 0 0 1 -13,13h-20a13,13 0 0 1 -13,-13v-12a13,13 0 0 1 13,-13z"/>
<path
android:fillColor="#CBD5E9"
android:strokeColor="#61739A"
android:strokeWidth="2"
android:pathData="M14,1h20a13,13 0 0 1 13,13v19a13,13 0 0 1 -13,13h-20a13,13 0 0 1 -13,-13v-19a13,13 0 0 1 13,-13z"/>
<group>
<clip-path android:pathData="M14,0h20a14,14 0 0 1 14,14v19a14,14 0 0 1 -14,14h-20a14,14 0 0 1 -14,-14v-19a14,14 0 0 1 14,-14z"/>
<path
android:fillColor="#FFFFFF"
android:fillAlpha="0.3"
android:pathData="M-26,2.875a28.875,28.875 0 1 0 57.75,0a28.875,28.875 0 1 0 -57.75,0z"/>
</group>
<path
android:strokeColor="#4A5775"
android:strokeWidth="2"
android:pathData="M14,1h20a13,13 0 0 1 13,13v19a13,13 0 0 1 -13,13h-20a13,13 0 0 1 -13,-13v-19a13,13 0 0 1 13,-13z"/>
<path
android:fillColor="#FFFFFF"
android:strokeColor="#4A5775"
android:strokeWidth="2"
android:pathData="M24,10C31.2178,10 37,16.083 37,23.5C37,30.917 31.2178,37 24,37C16.7822,37 11,30.917 11,23.5C11,16.083 16.7822,10 24,10Z"/>
<path
android:fillColor="#4A5775"
android:pathData="M18,24.5892C18,24.3235 18.0881,24.0972 18.2644,23.9104C18.4475,23.7165 18.6644,23.6195 18.9153,23.6195C19.0508,23.6195 19.1729,23.6483 19.2814,23.7057C19.3966,23.756 19.4915,23.8207 19.5661,23.8997L22.1695,26.6687C22.5492,27.0709 22.8847,27.5055 23.1763,27.9724L23.2678,27.9185C23.1458,27.2433 23.0847,26.5035 23.0847,25.699V15.9697C23.0847,15.7039 23.1729,15.4777 23.3492,15.2909C23.5322,15.097 23.7492,15 24,15C24.2508,15 24.4644,15.097 24.6407,15.2909C24.8237,15.4777 24.9153,15.7039 24.9153,15.9697V25.699C24.9153,26.4891 24.8576,27.229 24.7424,27.9185L24.8237,27.9724C25.1153,27.5055 25.4508,27.0709 25.8305,26.6687L28.4339,23.8997C28.522,23.8063 28.6237,23.738 28.739,23.6949C28.8542,23.6447 28.9695,23.6195 29.0847,23.6195C29.3356,23.6195 29.5492,23.7165 29.7254,23.9104C29.9085,24.0972 30,24.3235 30,24.5892C30,24.7329 29.9729,24.8658 29.9186,24.9879C29.8644,25.11 29.8034,25.207 29.7356,25.2788L24.6508,30.666C24.4407,30.8887 24.2237,31 24,31C23.7831,31 23.5661,30.8887 23.3492,30.666L18.2644,25.2788C18.1763,25.1854 18.1085,25.0777 18.061,24.9556C18.0203,24.8334 18,24.7113 18,24.5892Z"/>
</vector>
@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="42.5dp"
android:height="81dp"
android:viewportWidth="42.5"
android:viewportHeight="81">
<path
android:fillColor="#D9E0EE"
android:strokeColor="#4A5775"
android:strokeWidth="2"
android:pathData="M10,1h22.5a9,9 0 0 1 9,9v61a9,9 0 0 1 -9,9h-22.5a9,9 0 0 1 -9,-9v-61a9,9 0 0 1 9,-9z"/>
<path
android:fillColor="#EAEEF6"
android:pathData="M23.2988,2C21.496,13.401 13.0224,22.583 2,25.4199V10C2,5.58172 5.58172,2 10,2H23.2988Z"/>
<path
android:fillColor="#C0CBE2"
android:pathData="M36.4762,3.82493C36.4762,3.47728 36.8553,3.28287 37.1384,3.48469C39.1723,4.9348 40.4995,7.31143 40.4996,9.99973V70.9997C40.4995,75.4179 36.9178,78.9997 32.4996,78.9997H9.99962C6.87341,78.9996 6.37174,74.8544 9.40805,74.1101C10.0197,73.9601 10.659,73.8806 11.317,73.8806H28.4762C32.8945,73.8806 36.4762,70.2989 36.4762,65.8806V3.82493Z"/>
<path
android:fillColor="#FFFFFF"
android:fillType="evenOdd"
android:pathData="M18.859,54.2378C20.1026,55.2541 21.8974,55.2541 23.141,54.2378L27.8116,50.421C30.4529,48.2626 32.1082,45.1405 32.4046,41.7579L32.9872,35.1101C33.1239,33.5501 32.1509,32.1052 30.6462,31.6337L23.4252,29.3709C21.8468,28.8763 20.1531,28.8763 18.5748,29.371L11.3538,31.6337C9.84914,32.1052 8.87607,33.5501 9.01277,35.11L9.59533,41.7579C9.89175,45.1405 11.547,48.2626 14.1883,50.421L18.859,54.2378Z"/>
<path
android:fillColor="#4A5775"
android:fillType="evenOdd"
android:pathData="M18.859,54.2378C20.1026,55.2541 21.8974,55.2541 23.141,54.2378L27.8116,50.421C30.4529,48.2626 32.1082,45.1405 32.4046,41.7579L32.9872,35.1101C33.1239,33.5501 32.1509,32.1052 30.6462,31.6337L23.4252,29.3709C21.8468,28.8763 20.1531,28.8763 18.5748,29.371L11.3538,31.6337C9.84914,32.1052 8.87607,33.5501 9.01277,35.11L9.59533,41.7579C9.89175,45.1405 11.547,48.2626 14.1883,50.421L18.859,54.2378ZM21.8378,52.6675C21.3511,53.0652 20.6488,53.0652 20.1622,52.6675L15.4916,48.8508C13.2753,47.0396 11.8864,44.42 11.6377,41.5817L11.0551,34.9338C11.0016,34.3234 11.3824,33.758 11.9712,33.5735L19.1921,31.3108C20.3687,30.9421 21.6312,30.9421 22.8079,31.3108L30.0288,33.5735C30.6176,33.758 30.9984,34.3234 30.9449,34.9338L30.3623,41.5817C30.1136,44.42 28.7247,47.0396 26.5084,48.8507L21.8378,52.6675Z"/>
<path
android:fillColor="#4A5775"
android:pathData="M18,39a3,3 0 1 0 6,0a3,3 0 1 0 -6,0z"/>
<path
android:fillColor="#4A5775"
android:pathData="M18,46L20.25,39H21.75L24,46H18Z"/>
<path
android:fillColor="#4A5775"
android:pathData="M17.548,4.96144h7.4038a1.48076,1.48076 0 0 1 0,2.96152h-7.4038a1.48076,1.48076 0 0 1 0,-2.96152z"/>
</vector>
@@ -0,0 +1,39 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="363dp"
android:height="220dp"
android:viewportWidth="363"
android:viewportHeight="220"
tools:ignore="VectorRaster">
<path
android:pathData="M26,0h311a26,26 0 0,1 26,26v168a26,26 0 0,1 -26,26h-311a26,26 0 0,1 -26,-26v-168a26,26 0 0,1 26,-26Z"
android:fillColor="#343DBF"
/>
<group android:translateX="-8" android:translateY="-4">
<clip-path android:pathData="M34,4h311a26,26 0 0,1 26,26v168a26,26 0 0,1 -26,26h-311a26,26 0 0,1 -26,-26v-168a26,26 0 0,1 26,-26Z" />
<path
android:pathData="M72.5 79.5C24.5 134.3 -26.1667 126.667 -45.5 116L-82 240L401.5 226.5L429 -81C391 -55.5 341.5 10 266 19C217.221 24.8148 132.5 11 72.5 79.5Z"
android:fillColor="#3F47C1"
android:fillAlpha="0.5"
/>
<path
android:pathData="M89.5 129.5C41.5 184.3 -9.16666 176.667 -28.5 166L-65 290L418.5 276.5L446 -31C408 -5.5 358.5 60 283 69C234.221 74.8148 149.5 61 89.5 129.5Z"
android:fillColor="#8389E5"
android:fillAlpha="0.2"
/>
<path
android:pathData="M109.5 188.5C61.5 243.3 10.8333 235.667 -8.5 225L-45 319L438.5 305.5L466 28C428 53.5 378.5 119 303 128C254.221 133.815 169.5 120 109.5 188.5Z"
android:fillColor="#858CE4"
android:fillAlpha="0.24"
/>
<path
android:pathData="M38.7139 46.2578C35.7061 46.2578 33.6436 44.958 33.042 43.1533C32.9668 42.917 32.9131 42.6592 32.9131 42.4014C32.9131 41.7031 33.3428 41.252 33.998 41.252C34.5459 41.252 34.9004 41.499 35.1367 42.0791C35.6201 43.5293 37.0273 44.2061 38.8213 44.2061C40.7871 44.2061 42.1943 43.1963 42.1943 41.8105C42.1943 40.6074 41.3779 39.8555 39.3047 39.4043L37.6074 39.0498C34.5137 38.3945 33.1064 36.9551 33.1064 34.7207C33.1064 32.0674 35.4375 30.2412 38.7354 30.2412C41.4102 30.2412 43.5156 31.498 44.1172 33.5713C44.1709 33.7217 44.2031 33.9043 44.2031 34.1299C44.2031 34.7529 43.7627 35.1611 43.1396 35.1611C42.5596 35.1611 42.2051 34.8926 41.958 34.334C41.4316 32.9053 40.25 32.293 38.7031 32.293C36.8877 32.293 35.5449 33.1738 35.5449 34.5918C35.5449 35.7197 36.3506 36.4717 38.3594 36.9014L40.0459 37.2559C43.3008 37.9434 44.6328 39.2432 44.6328 41.499C44.6328 44.4209 42.334 46.2578 38.7139 46.2578ZM48.098 32.9912C47.3568 32.9912 46.7553 32.3896 46.7553 31.6592C46.7553 30.918 47.3568 30.3379 48.098 30.3379C48.85 30.3379 49.4516 30.918 49.4516 31.6592C49.4516 32.3896 48.85 32.9912 48.098 32.9912ZM48.098 46.2041C47.3998 46.2041 46.9379 45.7207 46.9379 44.9795V35.7305C46.9379 34.9785 47.3998 34.4951 48.098 34.4951C48.7963 34.4951 49.2582 34.9785 49.2582 35.7305V44.9795C49.2582 45.7207 48.7963 46.2041 48.098 46.2041ZM56.7195 50.2539C54.6678 50.2324 53.0457 49.4375 52.2723 48.2129C52.1219 47.9551 52.0574 47.7188 52.0574 47.4502C52.0574 46.9131 52.4549 46.5264 53.0457 46.5264C53.4002 46.5264 53.6258 46.6445 53.9373 46.9561C54.8611 47.9229 55.6561 48.3418 56.7625 48.3633C58.6531 48.3848 59.8025 47.3105 59.8025 45.7314V43.9375H59.7488C59.115 45.1621 57.783 45.9678 56.1717 45.9678C53.325 45.9678 51.4773 43.7441 51.4773 40.2529C51.4773 36.7188 53.3035 34.5166 56.2254 34.5166C57.826 34.5166 59.0721 35.3223 59.7596 36.6006H59.8025V35.6875C59.8025 34.9355 60.3074 34.4951 60.9734 34.4951C61.6395 34.4951 62.1336 34.9355 62.1336 35.6875V45.6885C62.1336 48.4814 60.0818 50.2861 56.7195 50.2539ZM56.7947 44.0771C58.6102 44.0771 59.8133 42.5947 59.8133 40.2744C59.8133 37.9541 58.6102 36.4287 56.7947 36.4287C55.0115 36.4287 53.8514 37.9111 53.8514 40.2637C53.8514 42.627 55.0115 44.0771 56.7947 44.0771ZM65.9318 46.2041C65.2443 46.2041 64.7717 45.7422 64.7717 44.9795V35.666C64.7717 34.957 65.2014 34.4951 65.8781 34.4951C66.5441 34.4951 67.0061 34.957 67.0061 35.6768V36.5684H67.0598C67.6721 35.2793 68.8537 34.5059 70.5939 34.5059C73.0861 34.5059 74.5148 36.0957 74.5148 38.6846V44.9795C74.5148 45.7422 74.0314 46.2041 73.3439 46.2041C72.6672 46.2041 72.1838 45.7422 72.1838 44.9795V39.1357C72.1838 37.4385 71.3889 36.5039 69.7775 36.5039C68.1447 36.5039 67.092 37.6641 67.092 39.415V44.9795C67.092 45.7422 66.6086 46.2041 65.9318 46.2041ZM80.4186 46.1934C78.2057 46.1934 76.648 44.8184 76.648 42.7881C76.648 40.8115 78.1734 39.5977 80.8482 39.4473L83.9527 39.2646V38.3945C83.9527 37.1377 83.1041 36.3857 81.6861 36.3857C80.5689 36.3857 79.8277 36.7832 79.1832 37.8145C78.9469 38.1582 78.6461 38.3086 78.2379 38.3086C77.6578 38.3086 77.2389 37.9219 77.2389 37.3418C77.2389 37.1055 77.3033 36.8477 77.443 36.5898C78.0338 35.3115 79.7418 34.4951 81.7721 34.4951C84.5113 34.4951 86.2623 35.9453 86.2623 38.2119V45.0332C86.2623 45.7637 85.8004 46.2041 85.1451 46.2041C84.5006 46.2041 84.0602 45.7852 84.0387 45.0977V44.1416H83.985C83.3297 45.3984 81.8902 46.1934 80.4186 46.1934ZM81.0523 44.3564C82.6744 44.3564 83.9527 43.2393 83.9527 41.7676V40.876L81.1598 41.0479C79.774 41.1445 78.9898 41.7568 78.9898 42.7236C78.9898 43.7119 79.817 44.3564 81.0523 44.3564ZM90.1143 46.2041C89.4375 46.2041 88.9541 45.7422 88.9541 44.9795V31.5195C88.9541 30.7568 89.4375 30.2949 90.1143 30.2949C90.791 30.2949 91.2744 30.7568 91.2744 31.5195V44.9795C91.2744 45.7422 90.791 46.2041 90.1143 46.2041Z"
android:fillColor="#FFFFFF"
/>
</group>
<path
android:pathData="M26,1h311a25,25 0 0,1 25,25v168a25,25 0 0,1 -25,25h-311a25,25 0 0,1 -25,-25v-168a25,25 0 0,1 25,-25Z"
android:strokeColor="#333BA8"
android:strokeWidth="2"
/>
</vector>
@@ -44,4 +44,75 @@
<string name="AccountSettingsFragment__change_phone_number">Change phone number</string>
<!-- Account setting that allows user to request and export their signal account data -->
<string name="AccountSettingsFragment__request_account_data">Your account data</string>
<!-- Section header for the Signal Login section of account settings -->
<string name="AccountSettingsFragment__signal_login">Signal Login</string>
<!-- Title of the card that opens the user\'s Signal Login account and recovery keys -->
<string name="AccountSettingsFragment__account_and_recovery">Account &amp; recovery</string>
<!-- Subtitle of the Signal Login card describing how many keys make up the login -->
<plurals name="AccountSettingsFragment__d_keys">
<item quantity="one">%1$d key</item>
<item quantity="other">%1$d keys</item>
</plurals>
<!-- Description of what a Signal Login is, shown below the Signal Login card -->
<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 -->
<string name="AccountSettingsFragment__authenticator_app">Authenticator app</string>
<!-- Description of the authenticator app setting -->
<string name="AccountSettingsFragment__use_an_authenticator_app">Use an authenticator app to generate one-time verification codes</string>
<!-- Description of the authenticator app setting when one has already been set up -->
<string name="AccountSettingsFragment__enabled">Enabled</string>
<!-- Account setting for setting up a physical security key -->
<string name="AccountSettingsFragment__security_keys">Security keys</string>
<!-- Description of the security keys setting -->
<string name="AccountSettingsFragment__set_up_using_a_physical_security_key">Set up using a physical security key</string>
<!-- Description of two-factor authentication, shown below the two-factor authentication rows -->
<string name="AccountSettingsFragment__use_a_second_form_of_authentication">Use a second form of authentication to protect your account when using your Signal Login on a new device.</string>
<!-- Clickable text that takes the user to a support article -->
<string name="AccountSettingsFragment__learn_more">Learn more</string>
<!-- AuthenticatorSetupScreen -->
<!-- Title of the screen that walks the user through setting up an authenticator app -->
<string name="AuthenticatorSetupScreen__authenticator_app">Authenticator app</string>
<!-- Instructions shown at the top of the authenticator app setup screen -->
<string name="AuthenticatorSetupScreen__follow_these_steps">Follow these steps to set up your authenticator app.</string>
<!-- Clickable text that takes the user to a support article -->
<string name="AuthenticatorSetupScreen__learn_more">Learn more</string>
<!-- Header of the first step of authenticator app setup -->
<string name="AuthenticatorSetupScreen__step_1">Step 1</string>
<!-- Body of the first step of authenticator app setup -->
<string name="AuthenticatorSetupScreen__install_a_trusted_authenticator_app">Install a trusted authenticator app on your device.</string>
<!-- Header of the second step of authenticator app setup -->
<string name="AuthenticatorSetupScreen__step_2">Step 2</string>
<!-- Body of the second step of authenticator app setup -->
<string name="AuthenticatorSetupScreen__open_your_authenticator_app">Open your authenticator app by tapping the button below to add your Signal account.</string>
<!-- Button that opens the user\'s authenticator app -->
<string name="AuthenticatorSetupScreen__open">Open</string>
<!-- Text above the setup key explaining that it can be entered by hand instead -->
<string name="AuthenticatorSetupScreen__or_you_can_copy_this_key">Or you can copy this key to manually set it up.</string>
<!-- Button that copies the setup key to the clipboard -->
<string name="AuthenticatorSetupScreen__copy">Copy</string>
<!-- Toast shown after the setup key has been copied to the clipboard -->
<string name="AuthenticatorSetupScreen__copied_to_clipboard">Copied to clipboard</string>
<!-- Toast shown when there is no app installed that can handle the authenticator setup link -->
<string name="AuthenticatorSetupScreen__no_authenticator_app_found">No authenticator app found</string>
<!-- Header of the third step of authenticator app setup -->
<string name="AuthenticatorSetupScreen__step_3">Step 3</string>
<!-- Body of the third step of authenticator app setup -->
<string name="AuthenticatorSetupScreen__copy_the_code_thats_generated">Copy the code that\'s generated and return here to continue.</string>
<!-- Button that advances from the setup instructions to entering a code -->
<string name="AuthenticatorSetupScreen__continue">Continue</string>
<!-- AuthenticatorCodeEntryScreen -->
<!-- Title of the screen where the user enters the code from their authenticator app -->
<string name="AuthenticatorCodeEntryScreen__enter_your_code">Enter your code</string>
<!-- Instructions shown above the code entry field -->
<string name="AuthenticatorCodeEntryScreen__enter_the_6_digit_code">Enter the 6-digit code from your authenticator app.</string>
<!-- Label of the code entry field -->
<string name="AuthenticatorCodeEntryScreen__code">Code</string>
<!-- Button that submits the entered code -->
<string name="AuthenticatorCodeEntryScreen__done">Done</string>
<!-- Toast shown after an authenticator app has been successfully set up -->
<string name="AuthenticatorCodeEntryScreen__authenticator_app_added">Authenticator app added</string>
</resources>
@@ -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
)
}
@@ -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<AuthenticatorCodeEntryEvent>()
@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 }
)
}
}
}
@@ -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<AuthenticatorSetupEvent>()
@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))
}
}