From 3e35f70beac97343367528a0cfa07cdc3b5f926a Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 20 Aug 2026 22:31:44 -0400 Subject: [PATCH] Add scaffolding for passkey creation screen. --- .../app/account/AccountSettingsFragment.kt | 1 + .../app/account/AccountSettingsViewModel.kt | 3 + .../account/passkeys/AppPasskeysRepository.kt | 25 ++ .../app/account/passkeys/PasskeysFragment.kt | 52 +++ .../app_settings_with_change_number.xml | 12 + .../account/AccountSettingsViewModelTest.kt | 12 + .../org/signal/core/ui/compose/SignalIcons.kt | 1 + .../main/res/drawable/symbol_x_circle_24.xml | 12 + feature/app-settings/build.gradle.kts | 1 + .../account/AccountSettingsAction.kt | 3 + .../account/AccountSettingsEvent.kt | 3 + .../account/AccountSettingsScreen.kt | 9 +- .../signal/appsettings/passkeys/Passkey.kt | 18 + .../appsettings/passkeys/PasskeysAction.kt | 24 ++ .../appsettings/passkeys/PasskeysEvent.kt | 27 ++ .../passkeys/PasskeysRepository.kt | 14 + .../appsettings/passkeys/PasskeysScreen.kt | 392 ++++++++++++++++++ .../appsettings/passkeys/PasskeysState.kt | 11 + .../appsettings/passkeys/PasskeysViewModel.kt | 60 +++ .../res/drawable/image_passkeys_phone.xml | 67 +++ .../src/main/res/values/strings.xml | 36 +- .../account/AccountSettingsScreenTest.kt | 11 + .../passkeys/PasskeysScreenTest.kt | 111 +++++ .../passkeys/PasskeysViewModelTest.kt | 104 +++++ 24 files changed, 1001 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/AppPasskeysRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/PasskeysFragment.kt create mode 100644 core/ui/src/main/res/drawable/symbol_x_circle_24.xml create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/Passkey.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysAction.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysEvent.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysRepository.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysScreen.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysState.kt create mode 100644 feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysViewModel.kt create mode 100644 feature/app-settings/src/main/res/drawable/image_passkeys_phone.xml create mode 100644 feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysScreenTest.kt create mode 100644 feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysViewModelTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt index d075d2e322..243d6cbd42 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt @@ -75,6 +75,7 @@ class AccountSettingsFragment : ComposeFragment() { AccountSettingsAction.LaunchChangePinFlow -> pinFlowLauncher.launch(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext())) AccountSettingsAction.ShowPinCreatedConfirmation -> Snackbar.make(requireView(), R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show() AccountSettingsAction.NavigateToAuthenticatorAppSetup -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_authenticatorSetupFragment) + AccountSettingsAction.NavigateToPasskeys -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_passkeysFragment) AccountSettingsAction.NavigateToAdvancedPinSettings -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_advancedPinSettingsActivity) AccountSettingsAction.NavigateToChangePhoneNumber -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment) AccountSettingsAction.NavigateToDeviceTransfer -> findNavController().safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt index d5fff1d1f9..378ab90e92 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt @@ -107,6 +107,9 @@ class AccountSettingsViewModel( AccountSettingsEvent.AuthenticatorAppClicked -> { _actions.send(AccountSettingsAction.NavigateToAuthenticatorAppSetup) } + AccountSettingsEvent.PasskeysClicked -> { + _actions.send(AccountSettingsAction.NavigateToPasskeys) + } AccountSettingsEvent.AdvancedPinSettingsClicked -> { _actions.send(AccountSettingsAction.NavigateToAdvancedPinSettings) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/AppPasskeysRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/AppPasskeysRepository.kt new file mode 100644 index 0000000000..9f5c66421b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/AppPasskeysRepository.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.passkeys + +import org.signal.appsettings.passkeys.Passkey +import org.signal.appsettings.passkeys.PasskeysRepository + +/** + * Stand-in for wherever passkeys will eventually be read from. Nothing is fetched from the service yet, so the + * passkeys are mocked. + */ +class AppPasskeysRepository : PasskeysRepository { + + companion object { + private val MOCK_PASSKEYS = listOf( + Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()), + Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis()) + ) + } + + override fun getPasskeys(): List = MOCK_PASSKEYS +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/PasskeysFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/PasskeysFragment.kt new file mode 100644 index 0000000000..287bc9931f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/passkeys/PasskeysFragment.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.account.passkeys + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.signal.appsettings.passkeys.PasskeysAction +import org.signal.appsettings.passkeys.PasskeysScreen +import org.signal.appsettings.passkeys.PasskeysViewModel +import org.signal.core.ui.compose.CollectActions +import org.signal.core.ui.compose.ComposeFragment +import org.signal.core.util.logging.Log + +/** + * Explains passkeys and lets the user start creating one. Carries out the [PasskeysAction]s that need an Activity or + * the nav graph. + */ +class PasskeysFragment : ComposeFragment() { + + companion object { + private val TAG = Log.tag(PasskeysFragment::class) + } + + private val viewModel: PasskeysViewModel by viewModels { + PasskeysViewModel.Factory(AppPasskeysRepository()) + } + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + + CollectActions(viewModel.actions) { action -> handleAction(action) } + + PasskeysScreen( + state = state, + onEvent = viewModel::onEvent + ) + } + + private fun handleAction(action: PasskeysAction) { + when (action) { + PasskeysAction.NavigateBack -> requireActivity().onBackPressedDispatcher.onBackPressed() + PasskeysAction.LaunchPasskeyCreation -> Log.w(TAG, "Passkey creation isn't implemented yet.") + PasskeysAction.OpenLearnMore -> Log.w(TAG, "There's no support article to open yet.") + } + } +} diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index b728b3aefa..e79e35e786 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -223,6 +223,13 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + + ): List { diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt index 8283735ce0..d2cbe710db 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -103,6 +103,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { ViewOnce(icon(R.drawable.symbol_view_once_24)), ViewOnceInfinite(icon(R.drawable.symbol_view_once_infinite_24)), X(icon(R.drawable.symbol_x_24)), + XCircle(icon(R.drawable.symbol_x_circle_24)), XCircleFill(icon(R.drawable.symbol_x_circle_fill_24)) } diff --git a/core/ui/src/main/res/drawable/symbol_x_circle_24.xml b/core/ui/src/main/res/drawable/symbol_x_circle_24.xml new file mode 100644 index 0000000000..a055ebd880 --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_x_circle_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/feature/app-settings/build.gradle.kts b/feature/app-settings/build.gradle.kts index 74b0ce822a..7368f50fa0 100644 --- a/feature/app-settings/build.gradle.kts +++ b/feature/app-settings/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { // Testing testImplementation(testLibs.junit.junit) testImplementation(testLibs.assertk) + testImplementation(testLibs.kotlinx.coroutines.test) testImplementation(testLibs.robolectric.robolectric) testImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsAction.kt index 3ab3acc254..61b06fc4ac 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsAction.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsAction.kt @@ -28,6 +28,9 @@ sealed interface AccountSettingsAction { /** Open the flow that sets up an authenticator app. */ data object NavigateToAuthenticatorAppSetup : AccountSettingsAction + /** Open the passkeys screen. */ + data object NavigateToPasskeys : AccountSettingsAction + /** Open the advanced PIN settings screen. */ data object NavigateToAdvancedPinSettings : AccountSettingsAction diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsEvent.kt index ae3738fc6c..bfd7fda0b9 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsEvent.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsEvent.kt @@ -45,6 +45,9 @@ sealed interface AccountSettingsEvent { /** The user tapped the authenticator app row in the two-factor authentication section. */ data object AuthenticatorAppClicked : AccountSettingsEvent + /** The user tapped the passkeys row in the two-factor authentication section. */ + data object PasskeysClicked : AccountSettingsEvent + /** The user tapped the advanced PIN settings row. */ data object AdvancedPinSettingsClicked : AccountSettingsEvent diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsScreen.kt index d4b7b6b1a0..d11f52a007 100644 --- a/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsScreen.kt +++ b/feature/app-settings/src/main/java/org/signal/appsettings/account/AccountSettingsScreen.kt @@ -63,7 +63,7 @@ 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_PASSKEYS = "row-passkeys" const val ROW_MODIFY_PIN = "row-modify-pin" const val ROW_PIN_REMINDER = "row-pin-reminder" const val ROW_REGISTRATION_LOCK = "row-registration-lock" @@ -139,9 +139,10 @@ fun AccountSettingsScreen( 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) + text = stringResource(R.string.AccountSettingsFragment__passkeys), + label = stringResource(R.string.AccountSettingsFragment__device_biometrics_or_fido2_security_key), + onClick = { onEvent(AccountSettingsEvent.PasskeysClicked) }, + modifier = Modifier.testTag(AccountSettingsTestTags.ROW_PASSKEYS) ) } diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/Passkey.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/Passkey.kt new file mode 100644 index 0000000000..95b429ef6f --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/Passkey.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.passkeys + +/** + * A single passkey on the user's account, as shown on [PasskeysScreen]. + */ +data class Passkey( + val id: Long, + val name: String, + /** When the passkey was added, in epoch milliseconds. */ + val createdAt: Long +) { + override fun toString(): String = "Passkey(id=$id)" +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysAction.kt new file mode 100644 index 0000000000..37127167d0 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysAction.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.passkeys + +/** + * One-shot side effects that need an Activity or the nav graph, and therefore have to be carried out by the fragment + * hosting [PasskeysScreen] rather than the screen itself. + * + * Actions are logged, so be sure `toString()` contains nothing sensitive. + */ +sealed interface PasskeysAction { + + /** Leave the screen. */ + data object NavigateBack : PasskeysAction + + /** Kick off creating a new passkey. */ + data object LaunchPasskeyCreation : PasskeysAction + + /** Send the user to a support article about passkeys. */ + data object OpenLearnMore : PasskeysAction +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysEvent.kt new file mode 100644 index 0000000000..0dfcb32590 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysEvent.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.passkeys + +/** + * Reminder that these events are logged, so don't include anything sensitive in the toString. + */ +sealed interface PasskeysEvent { + + /** The user tapped the navigation (back) icon. */ + data object NavigateBackClicked : PasskeysEvent + + /** The user tapped the button that starts creating a passkey. */ + data object SetUpPasskeyClicked : PasskeysEvent + + /** The user tapped the learn more link. */ + data object LearnMoreClicked : PasskeysEvent + + /** The user tapped the rename option in a passkey's overflow menu. */ + data class RenamePasskeyClicked(val passkeyId: Long) : PasskeysEvent + + /** The user tapped the remove option in a passkey's overflow menu. */ + data class RemovePasskeyClicked(val passkeyId: Long) : PasskeysEvent +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysRepository.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysRepository.kt new file mode 100644 index 0000000000..f16eb59661 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysRepository.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.passkeys + +/** + * Where [PasskeysScreen] reads passkeys from. + */ +interface PasskeysRepository { + + fun getPasskeys(): List +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysScreen.kt new file mode 100644 index 0000000000..6ff2a0bbf0 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysScreen.kt @@ -0,0 +1,392 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.passkeys + +import android.text.format.DateUtils +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import org.signal.appsettings.R +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.DropdownMenus +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Rows.TextAndLabel +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons +import org.signal.core.ui.compose.Texts +import org.signal.core.ui.R as CoreUiR + +@VisibleForTesting +object PasskeysTestTags { + const val SCROLLER = "scroller" + const val LEARN_MORE = "learn-more" + const val BUTTON_SET_UP = "button-set-up" + const val ROW_PASSKEY = "row-passkey" + const val BUTTON_PASSKEY_MENU = "button-passkey-menu" + const val MENU_ITEM_RENAME = "menu-item-rename" + const val MENU_ITEM_REMOVE = "menu-item-remove" +} + +/** + * Explains what passkeys are and lets the user start creating one. Once passkeys exist, they're listed here with + * management options instead. + */ +@Composable +fun PasskeysScreen( + state: PasskeysState, + onEvent: (PasskeysEvent) -> Unit +) { + Scaffolds.Settings( + title = stringResource(R.string.PasskeysScreen__passkeys), + onNavigationClick = { onEvent(PasskeysEvent.NavigateBackClicked) }, + navigationIcon = SignalIcons.ArrowStart.imageVector + ) { contentPadding -> + if (state.passkeys.isEmpty()) { + NoPasskeysContent( + onEvent = onEvent, + modifier = Modifier.padding(contentPadding) + ) + } else { + PasskeyListContent( + passkeys = state.passkeys, + onEvent = onEvent, + modifier = Modifier.padding(contentPadding) + ) + } + } +} + +/** + * Shown before any passkeys exist: an explanation of what passkeys are with a button to create the first one. + */ +@Composable +private fun NoPasskeysContent( + onEvent: (PasskeysEvent) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxSize()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .testTag(PasskeysTestTags.SCROLLER) + ) { + Image( + painter = painterResource(R.drawable.image_passkeys_phone), + contentDescription = null, + modifier = Modifier.padding(top = 32.dp) + ) + + DescriptionWithLearnMore( + text = stringResource(R.string.PasskeysScreen__with_passkeys_you_can_easily_add), + onEvent = onEvent, + modifier = Modifier + .padding(top = 24.dp) + .padding(horizontal = 28.dp) + .testTag(PasskeysTestTags.LEARN_MORE) + ) + + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .fillMaxWidth() + .padding(top = 40.dp) + .padding(horizontal = 64.dp) + ) { + BulletRow( + icon = SignalIcons.CheckCircle, + text = stringResource(R.string.PasskeysScreen__give_your_passkey_a_friendly_name) + ) + + BulletRow( + icon = SignalIcons.Lock, + text = stringResource(R.string.PasskeysScreen__use_your_devices_biometrics) + ) + + BulletRow( + icon = SignalIcons.Trash, + text = stringResource(R.string.PasskeysScreen__add_or_remove_passkeys_at_anytime) + ) + } + } + + SetUpPasskeyButton( + text = stringResource(R.string.PasskeysScreen__set_up_a_passkey), + onEvent = onEvent, + modifier = Modifier.padding(vertical = 16.dp) + ) + } +} + +/** + * Shown once passkeys exist: a shorter explanation with a button to add another, followed by the list of passkeys. + */ +@Composable +private fun PasskeyListContent( + passkeys: List, + onEvent: (PasskeysEvent) -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + .testTag(PasskeysTestTags.SCROLLER) + ) { + item { + Image( + painter = painterResource(R.drawable.image_passkeys_phone), + contentDescription = null, + modifier = Modifier.padding(top = 32.dp) + ) + } + + item { + DescriptionWithLearnMore( + text = stringResource(R.string.PasskeysScreen__set_up_a_passkey_with), + onEvent = onEvent, + modifier = Modifier + .padding(top = 24.dp) + .padding(horizontal = 34.dp) + .testTag(PasskeysTestTags.LEARN_MORE) + ) + } + + item { + SetUpPasskeyButton( + text = stringResource(R.string.PasskeysScreen__add_a_new_passkey), + onEvent = onEvent, + modifier = Modifier.padding(top = 24.dp, bottom = 20.dp) + ) + } + + item { + Dividers.Default() + } + + item { + Texts.SectionHeader( + text = stringResource(R.string.PasskeysScreen__passkeys), + modifier = Modifier.fillMaxWidth() + ) + } + + items(passkeys, key = { it.id }) { passkey -> + PasskeyRow( + passkey = passkey, + onEvent = onEvent + ) + } + } +} + +@Composable +private fun PasskeyRow( + passkey: Passkey, + onEvent: (PasskeysEvent) -> Unit +) { + val context = LocalContext.current + val addedTime = remember(passkey.createdAt) { + DateUtils.getRelativeDateTimeString(context, passkey.createdAt, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString() + } + + Rows.TextRow( + icon = { + Icon( + painter = SignalIcons.Key.painter, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + TextAndLabel( + text = passkey.name, + label = stringResource(R.string.PasskeysScreen__added_s, addedTime) + ) + + PasskeyMenuButton( + passkey = passkey, + onEvent = onEvent + ) + }, + modifier = Modifier.testTag(PasskeysTestTags.ROW_PASSKEY) + ) +} + +@Composable +private fun PasskeyMenuButton( + passkey: Passkey, + onEvent: (PasskeysEvent) -> Unit +) { + val menuController = remember { DropdownMenus.MenuController() } + + Box { + IconButton( + onClick = menuController::show, + modifier = Modifier.testTag(PasskeysTestTags.BUTTON_PASSKEY_MENU) + ) { + Icon( + imageVector = SignalIcons.MoreVertical.imageVector, + contentDescription = stringResource(R.string.PasskeysScreen__open_passkey_options), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + DropdownMenus.Menu(controller = menuController) { controller -> + DropdownMenus.Item( + leadingIconResId = CoreUiR.drawable.symbol_edit_24, + text = { Text(text = stringResource(R.string.PasskeysScreen__rename)) }, + onClick = { + onEvent(PasskeysEvent.RenamePasskeyClicked(passkey.id)) + controller.hide() + }, + modifier = Modifier.testTag(PasskeysTestTags.MENU_ITEM_RENAME) + ) + + DropdownMenus.Item( + leadingIconResId = CoreUiR.drawable.symbol_x_circle_24, + text = { Text(text = stringResource(R.string.PasskeysScreen__remove)) }, + onClick = { + onEvent(PasskeysEvent.RemovePasskeyClicked(passkey.id)) + controller.hide() + }, + modifier = Modifier.testTag(PasskeysTestTags.MENU_ITEM_REMOVE) + ) + } + } +} + +@Composable +private fun DescriptionWithLearnMore( + text: String, + onEvent: (PasskeysEvent) -> Unit, + modifier: Modifier = Modifier +) { + Text( + text = buildAnnotatedString { + append(text) + append(' ') + + withLink( + LinkAnnotation.Clickable( + tag = "learn-more", + styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)), + linkInteractionListener = { onEvent(PasskeysEvent.LearnMoreClicked) } + ) + ) { + append(stringResource(R.string.PasskeysScreen__learn_more)) + } + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = modifier + ) +} + +@Composable +private fun SetUpPasskeyButton( + text: String, + onEvent: (PasskeysEvent) -> Unit, + modifier: Modifier = Modifier +) { + Buttons.MediumTonal( + onClick = { onEvent(PasskeysEvent.SetUpPasskeyClicked) }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 40.dp) + .testTag(PasskeysTestTags.BUTTON_SET_UP) + ) { + Text(text = text) + } +} + +@Composable +private fun BulletRow( + icon: SignalIcons, + text: String +) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon( + painter = icon.painter, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@DayNightPreviews +@Composable +private fun PasskeysScreenPreview() { + Previews.Preview { + PasskeysScreen( + state = PasskeysState(), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun PasskeysScreenWithPasskeysPreview() { + Previews.Preview { + PasskeysScreen( + state = PasskeysState( + passkeys = listOf( + Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()), + Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis()) + ) + ), + onEvent = {} + ) + } +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysState.kt new file mode 100644 index 0000000000..079c09cbd2 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysState.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.passkeys + +data class PasskeysState( + /** The passkeys on the account. When empty, the screen explains passkeys instead of listing them. */ + val passkeys: List = emptyList() +) diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysViewModel.kt b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysViewModel.kt new file mode 100644 index 0000000000..5400a24076 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/passkeys/PasskeysViewModel.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.passkeys + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log + +/** + * Drives the screen that explains passkeys and lists any that already exist. + */ +class PasskeysViewModel( + repository: PasskeysRepository +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(PasskeysViewModel::class) + } + + private val _state = MutableStateFlow(PasskeysState(passkeys = repository.getPasskeys())) + private val _actions = Channel(Channel.BUFFERED) + + val state: StateFlow = _state.asStateFlow() + val actions: Flow = _actions.receiveAsFlow() + + override suspend fun processEvent(event: PasskeysEvent) { + when (event) { + PasskeysEvent.NavigateBackClicked -> { + _actions.send(PasskeysAction.NavigateBack) + } + PasskeysEvent.SetUpPasskeyClicked -> { + _actions.send(PasskeysAction.LaunchPasskeyCreation) + } + PasskeysEvent.LearnMoreClicked -> { + _actions.send(PasskeysAction.OpenLearnMore) + } + is PasskeysEvent.RenamePasskeyClicked, is PasskeysEvent.RemovePasskeyClicked -> { + // Nothing to do yet -- rename and remove haven't been built. + } + } + } + + class Factory( + private val repository: PasskeysRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return PasskeysViewModel(repository) as T + } + } +} diff --git a/feature/app-settings/src/main/res/drawable/image_passkeys_phone.xml b/feature/app-settings/src/main/res/drawable/image_passkeys_phone.xml new file mode 100644 index 0000000000..4e57ed6e03 --- /dev/null +++ b/feature/app-settings/src/main/res/drawable/image_passkeys_phone.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/app-settings/src/main/res/values/strings.xml b/feature/app-settings/src/main/res/values/strings.xml index a95561ac52..fec04d049c 100644 --- a/feature/app-settings/src/main/res/values/strings.xml +++ b/feature/app-settings/src/main/res/values/strings.xml @@ -63,10 +63,10 @@ Use an authenticator app to generate one-time verification codes Enabled - - Security keys - - Set up using a physical security key + + Passkeys + + Device biometrics or FIDO2 security key Use a second form of authentication to protect your account when using your Signal Login on a new device. @@ -104,6 +104,34 @@ Continue + + + Passkeys + + With passkeys you can easily add a secure second form of authentication to your account. Passkeys can use your device\'s biometrics or a compatible FIDO2 security key. + + Learn more + + Give your passkey a friendly name to identify it + + Use your device\'s biometrics or a FIDO2 security key to store your passkey + + Add or remove passkeys at anytime. + + Set up a passkey + + Set up a passkey with your device\'s biometrics or a compatible FIDO2 security key. + + Add a new passkey + + Added %1$s + + Open passkey options + + Rename + + Remove + Enter your code diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/account/AccountSettingsScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/account/AccountSettingsScreenTest.kt index dfa83f4ad0..ab717d97fc 100644 --- a/feature/app-settings/src/test/java/org/signal/appsettings/account/AccountSettingsScreenTest.kt +++ b/feature/app-settings/src/test/java/org/signal/appsettings/account/AccountSettingsScreenTest.kt @@ -308,6 +308,17 @@ class AccountSettingsScreenTest { assertThat(events).contains(AccountSettingsEvent.AuthenticatorAppClicked) } + @Test + fun givenASignalLogin_whenIClickPasskeys_thenIExpectPasskeysEvent() { + setContent(createState(signalLogin = AccountSettingsState.SignalLogin(keyCount = 2, hasAuthenticatorApp = false))) + + scrollTo(AccountSettingsTestTags.ROW_PASSKEYS) + + composeTestRule.onNodeWithTag(AccountSettingsTestTags.ROW_PASSKEYS).performClick() + + assertThat(events).contains(AccountSettingsEvent.PasskeysClicked) + } + private fun setContent(state: AccountSettingsState) { composeTestRule.setContent { AccountSettingsScreen( diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysScreenTest.kt new file mode 100644 index 0000000000..500ae264b2 --- /dev/null +++ b/feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysScreenTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.passkeys + +import android.app.Application +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import assertk.assertThat +import assertk.assertions.contains +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class PasskeysScreenTest { + + companion object { + private val PASSKEYS = listOf( + Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()), + Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis()) + ) + } + + @get:Rule + val composeTestRule = createComposeRule() + + private val events = mutableListOf() + + @Test + fun givenNoPasskeys_whenIClickSetUpAPasskey_thenIExpectSetUpPasskeyEvent() { + setContent(PasskeysState()) + + composeTestRule.onNodeWithTag(PasskeysTestTags.BUTTON_SET_UP) + .assertIsDisplayed() + .performClick() + + assertThat(events).contains(PasskeysEvent.SetUpPasskeyClicked) + } + + @Test + fun givenPasskeys_whenIDisplayScreen_thenIExpectARowPerPasskey() { + setContent(PasskeysState(passkeys = PASSKEYS)) + + for (passkey in PASSKEYS) { + composeTestRule.onNodeWithTag(PasskeysTestTags.SCROLLER).performScrollToNode(hasText(passkey.name)) + composeTestRule.onNodeWithText(passkey.name).assertIsDisplayed() + } + } + + @Test + fun givenPasskeys_whenIClickAddANewPasskey_thenIExpectSetUpPasskeyEvent() { + setContent(PasskeysState(passkeys = PASSKEYS)) + + composeTestRule.onNodeWithTag(PasskeysTestTags.BUTTON_SET_UP) + .assertIsDisplayed() + .performClick() + + assertThat(events).contains(PasskeysEvent.SetUpPasskeyClicked) + } + + @Test + fun givenPasskeys_whenIClickRenameInAPasskeysMenu_thenIExpectRenameEvent() { + setContent(PasskeysState(passkeys = PASSKEYS)) + + scrollTo(PasskeysTestTags.BUTTON_PASSKEY_MENU) + + composeTestRule.onAllNodesWithTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)[0].performClick() + composeTestRule.onNodeWithTag(PasskeysTestTags.MENU_ITEM_RENAME).performClick() + + assertThat(events).contains(PasskeysEvent.RenamePasskeyClicked(passkeyId = PASSKEYS[0].id)) + } + + @Test + fun givenPasskeys_whenIClickRemoveInAPasskeysMenu_thenIExpectRemoveEvent() { + setContent(PasskeysState(passkeys = PASSKEYS)) + + scrollTo(PasskeysTestTags.BUTTON_PASSKEY_MENU) + + composeTestRule.onAllNodesWithTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)[0].performClick() + composeTestRule.onNodeWithTag(PasskeysTestTags.MENU_ITEM_REMOVE).performClick() + + assertThat(events).contains(PasskeysEvent.RemovePasskeyClicked(passkeyId = PASSKEYS[0].id)) + } + + private fun setContent(state: PasskeysState) { + composeTestRule.setContent { + PasskeysScreen( + state = state, + onEvent = { events += it } + ) + } + } + + private fun scrollTo(testTag: String) { + composeTestRule.onNodeWithTag(PasskeysTestTags.SCROLLER) + .performScrollToNode(hasTestTag(testTag)) + } +} diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysViewModelTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysViewModelTest.kt new file mode 100644 index 0000000000..1b1264f3f2 --- /dev/null +++ b/feature/app-settings/src/test/java/org/signal/appsettings/passkeys/PasskeysViewModelTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.appsettings.passkeys + +import assertk.assertThat +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PasskeysViewModelTest { + + companion object { + private val PASSKEYS = listOf( + Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()), + Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis()) + ) + } + + private val testDispatcher = UnconfinedTestDispatcher() + + private val repository = object : PasskeysRepository { + override fun getPasskeys(): List = PASSKEYS + } + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the passkeys are available as soon as the screen opens`() = runTest(testDispatcher) { + val viewModel = PasskeysViewModel(repository) + + assertThat(viewModel.state.value.passkeys).isEqualTo(PASSKEYS) + } + + @Test + fun `SetUpPasskeyClicked launches passkey creation`() = runTest(testDispatcher) { + val viewModel = PasskeysViewModel(repository) + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(PasskeysEvent.SetUpPasskeyClicked) + + assertThat(actions.last()).isEqualTo(PasskeysAction.LaunchPasskeyCreation) + } + + @Test + fun `LearnMoreClicked opens the learn more article`() = runTest(testDispatcher) { + val viewModel = PasskeysViewModel(repository) + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(PasskeysEvent.LearnMoreClicked) + + assertThat(actions.last()).isEqualTo(PasskeysAction.OpenLearnMore) + } + + @Test + fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) { + val viewModel = PasskeysViewModel(repository) + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(PasskeysEvent.NavigateBackClicked) + + assertThat(actions.last()).isEqualTo(PasskeysAction.NavigateBack) + } + + @Test + fun `rename and remove produce no actions yet`() = runTest(testDispatcher) { + val viewModel = PasskeysViewModel(repository) + val actions = collectActions(viewModel.actions) + + viewModel.onEvent(PasskeysEvent.RenamePasskeyClicked(passkeyId = 1)) + viewModel.onEvent(PasskeysEvent.RemovePasskeyClicked(passkeyId = 1)) + + assertThat(actions).isEmpty() + } + + private fun TestScope.collectActions(actions: Flow): List { + val collected = mutableListOf() + backgroundScope.launch { actions.toList(collected) } + return collected + } +}