mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-14 20:57:26 +01:00
Add numberless account setting screen placeholders.
This commit is contained in:
committed by
Cody Henthorne
parent
e935981db3
commit
8954e55e0e
+1
@@ -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
@@ -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) {
|
||||
|
||||
+15
-1
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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
|
||||
}
|
||||
+50
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
}
|
||||
+76
@@ -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()
|
||||
}
|
||||
}
|
||||
+71
@@ -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"
|
||||
|
||||
+32
@@ -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> {
|
||||
|
||||
+108
@@ -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
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.account.authenticator
|
||||
|
||||
import 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>
|
||||
+3
@@ -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
|
||||
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
|
||||
+170
@@ -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() {
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
+24
@@ -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
|
||||
}
|
||||
+23
@@ -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
|
||||
}
|
||||
+124
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
}
|
||||
+37
@@ -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
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
+317
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+13
@@ -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 & 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>
|
||||
|
||||
+21
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+67
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user