Add scaffolding for passkey creation screen.

This commit is contained in:
Greyson Parrelli
2026-08-21 17:36:10 -04:00
committed by Cody Henthorne
parent 91441f6fca
commit 3e35f70bea
24 changed files with 1001 additions and 8 deletions
@@ -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)
@@ -107,6 +107,9 @@ class AccountSettingsViewModel(
AccountSettingsEvent.AuthenticatorAppClicked -> {
_actions.send(AccountSettingsAction.NavigateToAuthenticatorAppSetup)
}
AccountSettingsEvent.PasskeysClicked -> {
_actions.send(AccountSettingsAction.NavigateToPasskeys)
}
AccountSettingsEvent.AdvancedPinSettingsClicked -> {
_actions.send(AccountSettingsAction.NavigateToAdvancedPinSettings)
}
@@ -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<Passkey> = MOCK_PASSKEYS
}
@@ -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.")
}
}
}
@@ -223,6 +223,13 @@
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_accountSettingsFragment_to_passkeysFragment"
app:destination="@id/passkeysFragment"
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
@@ -243,6 +250,11 @@
android:name="org.thoughtcrime.securesms.components.settings.app.account.authenticator.AuthenticatorCodeEntryFragment"
android:label="authenticator_code_entry_fragment" />
<fragment
android:id="@+id/passkeysFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.passkeys.PasskeysFragment"
android:label="passkeys_fragment" />
<fragment
android:id="@+id/linkedDeviceAccountSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.LinkedDeviceAccountSettingsFragment"
@@ -320,6 +320,18 @@ class AccountSettingsViewModelTest {
assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToAuthenticatorAppSetup)
}
@Test
fun `PasskeysClicked opens the passkeys screen`() = runTest(testDispatcher) {
every { repository.isPhoneNumberlessRegistrationEnabled() } returns true
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(AccountSettingsEvent.PasskeysClicked)
assertThat(actions.last()).isEqualTo(AccountSettingsAction.NavigateToPasskeys)
}
private fun createViewModel(): AccountSettingsViewModel = AccountSettingsViewModel(repository)
private fun TestScope.collectActions(actions: Flow<AccountSettingsAction>): List<AccountSettingsAction> {
@@ -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))
}
@@ -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="M7.88 7.88c0.34-0.34 0.9-0.34 1.24 0L12 10.76l2.88-2.88c0.34-0.34 0.9-0.34 1.24 0 0.34 0.34 0.34 0.9 0 1.24L13.24 12l2.88 2.88c0.34 0.34 0.34 0.9 0 1.24-0.34 0.34-0.9 0.34-1.24 0L12 13.24l-2.88 2.88c-0.34 0.34-0.9 0.34-1.24 0-0.34-0.34-0.34-0.9 0-1.24L10.76 12 7.88 9.12c-0.34-0.34-0.34-0.9 0-1.24Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M1.13 12C1.13 6 5.99 1.12 12 1.12 18 1.13 22.88 6 22.88 12c0 6-4.87 10.88-10.88 10.88C6 22.88 1.12 18 1.12 12ZM12 2.87c-5.04 0-9.13 4.09-9.13 9.13 0 5.04 4.09 9.13 9.13 9.13 5.04 0 9.13-4.09 9.13-9.13 0-5.04-4.09-9.13-9.13-9.13Z"/>
</vector>
+1
View File
@@ -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)
@@ -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
@@ -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
@@ -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)
)
}
@@ -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)"
}
@@ -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
}
@@ -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
}
@@ -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<Passkey>
}
@@ -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<Passkey>,
onEvent: (PasskeysEvent) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxSize()
.testTag(PasskeysTestTags.SCROLLER)
) {
item {
Image(
painter = painterResource(R.drawable.image_passkeys_phone),
contentDescription = null,
modifier = Modifier.padding(top = 32.dp)
)
}
item {
DescriptionWithLearnMore(
text = stringResource(R.string.PasskeysScreen__set_up_a_passkey_with),
onEvent = onEvent,
modifier = Modifier
.padding(top = 24.dp)
.padding(horizontal = 34.dp)
.testTag(PasskeysTestTags.LEARN_MORE)
)
}
item {
SetUpPasskeyButton(
text = stringResource(R.string.PasskeysScreen__add_a_new_passkey),
onEvent = onEvent,
modifier = Modifier.padding(top = 24.dp, bottom = 20.dp)
)
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(
text = stringResource(R.string.PasskeysScreen__passkeys),
modifier = Modifier.fillMaxWidth()
)
}
items(passkeys, key = { it.id }) { passkey ->
PasskeyRow(
passkey = passkey,
onEvent = onEvent
)
}
}
}
@Composable
private fun PasskeyRow(
passkey: Passkey,
onEvent: (PasskeysEvent) -> Unit
) {
val context = LocalContext.current
val addedTime = remember(passkey.createdAt) {
DateUtils.getRelativeDateTimeString(context, passkey.createdAt, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString()
}
Rows.TextRow(
icon = {
Icon(
painter = SignalIcons.Key.painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
},
text = {
TextAndLabel(
text = passkey.name,
label = stringResource(R.string.PasskeysScreen__added_s, addedTime)
)
PasskeyMenuButton(
passkey = passkey,
onEvent = onEvent
)
},
modifier = Modifier.testTag(PasskeysTestTags.ROW_PASSKEY)
)
}
@Composable
private fun PasskeyMenuButton(
passkey: Passkey,
onEvent: (PasskeysEvent) -> Unit
) {
val menuController = remember { DropdownMenus.MenuController() }
Box {
IconButton(
onClick = menuController::show,
modifier = Modifier.testTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)
) {
Icon(
imageVector = SignalIcons.MoreVertical.imageVector,
contentDescription = stringResource(R.string.PasskeysScreen__open_passkey_options),
tint = MaterialTheme.colorScheme.onSurface
)
}
DropdownMenus.Menu(controller = menuController) { controller ->
DropdownMenus.Item(
leadingIconResId = CoreUiR.drawable.symbol_edit_24,
text = { Text(text = stringResource(R.string.PasskeysScreen__rename)) },
onClick = {
onEvent(PasskeysEvent.RenamePasskeyClicked(passkey.id))
controller.hide()
},
modifier = Modifier.testTag(PasskeysTestTags.MENU_ITEM_RENAME)
)
DropdownMenus.Item(
leadingIconResId = CoreUiR.drawable.symbol_x_circle_24,
text = { Text(text = stringResource(R.string.PasskeysScreen__remove)) },
onClick = {
onEvent(PasskeysEvent.RemovePasskeyClicked(passkey.id))
controller.hide()
},
modifier = Modifier.testTag(PasskeysTestTags.MENU_ITEM_REMOVE)
)
}
}
}
@Composable
private fun DescriptionWithLearnMore(
text: String,
onEvent: (PasskeysEvent) -> Unit,
modifier: Modifier = Modifier
) {
Text(
text = buildAnnotatedString {
append(text)
append(' ')
withLink(
LinkAnnotation.Clickable(
tag = "learn-more",
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary)),
linkInteractionListener = { onEvent(PasskeysEvent.LearnMoreClicked) }
)
) {
append(stringResource(R.string.PasskeysScreen__learn_more))
}
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier
)
}
@Composable
private fun SetUpPasskeyButton(
text: String,
onEvent: (PasskeysEvent) -> Unit,
modifier: Modifier = Modifier
) {
Buttons.MediumTonal(
onClick = { onEvent(PasskeysEvent.SetUpPasskeyClicked) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 40.dp)
.testTag(PasskeysTestTags.BUTTON_SET_UP)
) {
Text(text = text)
}
}
@Composable
private fun BulletRow(
icon: SignalIcons,
text: String
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(
painter = icon.painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@DayNightPreviews
@Composable
private fun PasskeysScreenPreview() {
Previews.Preview {
PasskeysScreen(
state = PasskeysState(),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun PasskeysScreenWithPasskeysPreview() {
Previews.Preview {
PasskeysScreen(
state = PasskeysState(
passkeys = listOf(
Passkey(id = 1, name = "My Security Key", createdAt = System.currentTimeMillis()),
Passkey(id = 2, name = "My Pixel Phone", createdAt = System.currentTimeMillis())
)
),
onEvent = {}
)
}
}
@@ -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<Passkey> = emptyList()
)
@@ -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<PasskeysEvent>(TAG) {
companion object {
private val TAG = Log.tag(PasskeysViewModel::class)
}
private val _state = MutableStateFlow(PasskeysState(passkeys = repository.getPasskeys()))
private val _actions = Channel<PasskeysAction>(Channel.BUFFERED)
val state: StateFlow<PasskeysState> = _state.asStateFlow()
val actions: Flow<PasskeysAction> = _actions.receiveAsFlow()
override suspend fun processEvent(event: PasskeysEvent) {
when (event) {
PasskeysEvent.NavigateBackClicked -> {
_actions.send(PasskeysAction.NavigateBack)
}
PasskeysEvent.SetUpPasskeyClicked -> {
_actions.send(PasskeysAction.LaunchPasskeyCreation)
}
PasskeysEvent.LearnMoreClicked -> {
_actions.send(PasskeysAction.OpenLearnMore)
}
is PasskeysEvent.RenamePasskeyClicked, is PasskeysEvent.RemovePasskeyClicked -> {
// Nothing to do yet -- rename and remove haven't been built.
}
}
}
class Factory(
private val repository: PasskeysRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PasskeysViewModel(repository) as T
}
}
}
@@ -0,0 +1,67 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="52dp"
android:height="92dp"
android:viewportWidth="52"
android:viewportHeight="92">
<path
android:fillColor="#D9E0EE"
android:strokeColor="#4A5775"
android:strokeWidth="2"
android:pathData="M10,1h28a9,9 0 0 1 9,9v72a9,9 0 0 1 -9,9h-28a9,9 0 0 1 -9,-9v-72a9,9 0 0 1 9,-9z"/>
<path
android:fillColor="#EAEEF6"
android:pathData="M26.3418,2C24.2815,15.0299 14.5971,25.5234 2,28.7656V10C2,5.58172 5.58172,2 10,2H26.3418Z"/>
<path
android:fillColor="#C0CBE2"
android:pathData="M41.4021,2.94279C41.4021,2.85734 41.4931,2.80233 41.5695,2.84051C44.1958,4.15231 45.9998,6.86514 45.9998,10.0004V82.0004C45.9995,86.4185 42.4179,90.0004 37.9998,90.0004H9.99975C6.63198,90.0003 5.96246,85.4672 9.18582,84.4915C9.92001,84.2693 10.6988,84.1498 11.5056,84.1498H33.4021C37.8204,84.1498 41.4021,80.5681 41.4021,76.1498V2.94279Z"/>
<path
android:fillColor="#4A5775"
android:pathData="M19.7695,5.38446h8.4616a1.69231,1.69231 0 0 1 0,3.38461h-8.4616a1.69231,1.69231 0 0 1 0,-3.38461z"/>
<group
android:translateX="8.11"
android:translateY="29.25">
<path
android:fillColor="#FFFFFF"
android:pathData="M7.20447,7.28906C7.20447,3.27305 9.85894,0 13.392,0C16.925,0 19.5795,3.27305 19.5795,7.28906C19.5795,9.31067 18.9208,11.214 17.8305,12.6259C16.7405,14.0373 15.1791,15 13.392,15C11.6048,15 10.0434,14.0373 8.95346,12.6259C7.86315,11.214 7.20447,9.31067 7.20447,7.28906Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M0.0309865,27.6291C0.989446,22.0849 6.70416,18 13.3921,18C20.08,18 25.7947,22.0849 26.7532,27.6291C26.9887,28.9916 25.8423,30 24.6421,30H2.14209C0.941895,30 -0.204559,28.9916 0.0309865,27.6291Z"/>
</group>
<group
android:translateX="7.73"
android:translateY="28.88">
<path
android:fillColor="#4A5775"
android:fillType="evenOdd"
android:pathData="M13.7678,20.25C7.7345,20.25 3.01725,23.9103 2.25433,28.3235C2.25019,28.3474 2.25312,28.3589 2.25515,28.3653C2.25791,28.3738 2.2653,28.3901 2.28473,28.4106C2.32654,28.4547 2.40827,28.5 2.51783,28.5H25.0178C25.1274,28.5 25.2091,28.4547 25.2509,28.4106C25.2704,28.3901 25.2778,28.3738 25.2805,28.3653C25.2825,28.3589 25.2855,28.3474 25.2813,28.3235C24.5184,23.9103 19.8012,20.25 13.7678,20.25ZM0.0372122,27.9402C1.03478,22.1698 6.94899,18 13.7678,18C20.5867,18 26.5009,22.1698 27.4985,27.9402C27.7812,29.5758 26.4039,30.75 25.0178,30.75H2.51783C1.13176,30.75 -0.245544,29.5758 0.0372122,27.9402Z"/>
<path
android:fillColor="#4A5775"
android:fillType="evenOdd"
android:pathData="M13.7678,2.25C11.5597,2.25 9.45533,4.36827 9.45533,7.66406C9.45533,9.29155 9.98883,10.7872 10.8133,11.8549C11.6384,12.9234 12.6947,13.5 13.7678,13.5C14.8409,13.5 15.8972,12.9234 16.7223,11.8549C17.5468,10.7872 18.0803,9.29155 18.0803,7.66406C18.0803,4.36827 15.9759,2.25 13.7678,2.25ZM7.20533,7.66406C7.20533,3.50401 9.96982,0 13.7678,0C17.5658,0 20.3303,3.50401 20.3303,7.66406C20.3303,9.76449 19.6466,11.7493 18.5031,13.2301C17.3602,14.7101 15.6978,15.75 13.7678,15.75C11.8379,15.75 10.1754,14.7101 9.03252,13.2301C7.88905,11.7493 7.20533,9.76449 7.20533,7.66406Z"/>
</group>
<group
android:translateX="28.97"
android:translateY="41.52"
android:scaleX="-1"
android:pivotX="5.5"
android:pivotY="11.21">
<path
android:fillColor="#FFFFFF"
android:pathData="M1.61077,1.61091C3.75865,-0.536972 7.24121,-0.536968 9.38909,1.61091C11.537,3.75879 11.537,7.24135 9.38909,9.38923C8.89219,9.88606 8.32292,10.2658 7.71624,10.5328L7.62151,21.7633L5.2553,22.4273L3.02483,19.9957L3.73284,17.1676L3.02483,15.0455V10.4107C2.51536,10.1538 2.03626,9.81472 1.61077,9.38923C-0.537013,7.24144 -0.536835,3.75881 1.61077,1.61091Z"/>
</group>
<group
android:translateX="23.72"
android:translateY="41.97"
android:rotation="45"
android:scaleY="-1"
android:pivotX="10.75"
android:pivotY="10">
<path
android:fillColor="#4A5775"
android:pathData="M7.5,14.7498C7.5,13.8524 6.77246,13.1248 5.875,13.1248C4.97754,13.1248 4.25,13.8524 4.25,14.7498C4.25,15.6473 4.97754,16.3748 5.875,16.3748C6.77246,16.3748 7.5,15.6473 7.5,14.7498Z"/>
<path
android:fillColor="#4A5775"
android:fillType="evenOdd"
android:pathData="M16.4557,0C16.0576,0 15.6758,0.158282 15.3944,0.439973L8.94364,6.89844C8.32568,6.72014 7.67331,6.6248 7,6.6248C3.99436,6.6248 1.43356,8.51865 0.441029,11.1748C0.155587,11.9386 0,12.7647 0,13.6248C0,14.3886 0.122692,15.1257 0.350204,15.8164C1.2697,18.6077 3.89799,20.6248 7,20.6248C10.866,20.6248 14,17.4908 14,13.6248C14,12.8152 13.8622,12.0362 13.6083,11.311L14.4144,10.5H15.75C16.5784,10.5 17.25,9.82843 17.25,9V7.00141H19.2482C20.0766,7.00141 20.7482,6.32984 20.7482,5.50141V1.5C20.7482,0.671574 20.0766,0 19.2482,0L16.4557,0ZM9.45405,9.21754L16.663,2L18.7482,2V5.00141H16.75C15.9216,5.00141 15.25,5.67298 15.25,6.50141V8.5H14.2064C13.807,8.5 13.4241,8.65929 13.1425,8.94256L11.2299,10.8668L11.5279,11.5005C11.8304,12.144 12,12.8632 12,13.6248C12,16.3862 9.76142,18.6248 7,18.6248C4.78691,18.6248 2.90719,17.1863 2.2498,15.1906C2.08797,14.6994 2,14.1733 2,13.6248C2,13.0073 2.11147,12.4182 2.3145,11.8748C3.02445,9.97495 4.85581,8.6248 7,8.6248C7.65263,8.6248 8.27375,8.74933 8.84263,8.97499L9.45405,9.21754Z"/>
</group>
</vector>
@@ -63,10 +63,10 @@
<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>
<!-- Account setting that takes the user to the passkeys screen -->
<string name="AccountSettingsFragment__passkeys">Passkeys</string>
<!-- Description of the passkeys setting -->
<string name="AccountSettingsFragment__device_biometrics_or_fido2_security_key">Device biometrics or FIDO2 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 -->
@@ -104,6 +104,34 @@
<!-- Button that advances from the setup instructions to entering a code -->
<string name="AuthenticatorSetupScreen__continue">Continue</string>
<!-- PasskeysScreen -->
<!-- Title of the screen where the user sets up passkeys -->
<string name="PasskeysScreen__passkeys">Passkeys</string>
<!-- Description of passkeys shown at the top of the screen -->
<string name="PasskeysScreen__with_passkeys_you_can_easily_add">With passkeys you can easily add a secure second form of authentication to your account. Passkeys can use your device\'s biometrics or a compatible FIDO2 security key.</string>
<!-- Clickable text that takes the user to a support article -->
<string name="PasskeysScreen__learn_more">Learn more</string>
<!-- Bullet point explaining that a passkey can be given a name -->
<string name="PasskeysScreen__give_your_passkey_a_friendly_name">Give your passkey a friendly name to identify it</string>
<!-- Bullet point explaining how a passkey is stored -->
<string name="PasskeysScreen__use_your_devices_biometrics">Use your device\'s biometrics or a FIDO2 security key to store your passkey</string>
<!-- Bullet point explaining that passkeys can be added and removed -->
<string name="PasskeysScreen__add_or_remove_passkeys_at_anytime">Add or remove passkeys at anytime.</string>
<!-- Button that starts creating a passkey -->
<string name="PasskeysScreen__set_up_a_passkey">Set up a passkey</string>
<!-- Description of passkeys shown at the top of the screen once some already exist -->
<string name="PasskeysScreen__set_up_a_passkey_with">Set up a passkey with your device\'s biometrics or a compatible FIDO2 security key.</string>
<!-- Button that starts creating a passkey once some already exist -->
<string name="PasskeysScreen__add_a_new_passkey">Add a new passkey</string>
<!-- Label under a passkey\'s name saying when it was added. The placeholder is a relative time like "today, 11:00 AM" -->
<string name="PasskeysScreen__added_s">Added %1$s</string>
<!-- Accessibility description of the button that opens a passkey\'s menu -->
<string name="PasskeysScreen__open_passkey_options">Open passkey options</string>
<!-- Menu option that renames a passkey -->
<string name="PasskeysScreen__rename">Rename</string>
<!-- Menu option that removes a passkey -->
<string name="PasskeysScreen__remove">Remove</string>
<!-- 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>
@@ -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(
@@ -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<PasskeysEvent>()
@Test
fun givenNoPasskeys_whenIClickSetUpAPasskey_thenIExpectSetUpPasskeyEvent() {
setContent(PasskeysState())
composeTestRule.onNodeWithTag(PasskeysTestTags.BUTTON_SET_UP)
.assertIsDisplayed()
.performClick()
assertThat(events).contains(PasskeysEvent.SetUpPasskeyClicked)
}
@Test
fun givenPasskeys_whenIDisplayScreen_thenIExpectARowPerPasskey() {
setContent(PasskeysState(passkeys = PASSKEYS))
for (passkey in PASSKEYS) {
composeTestRule.onNodeWithTag(PasskeysTestTags.SCROLLER).performScrollToNode(hasText(passkey.name))
composeTestRule.onNodeWithText(passkey.name).assertIsDisplayed()
}
}
@Test
fun givenPasskeys_whenIClickAddANewPasskey_thenIExpectSetUpPasskeyEvent() {
setContent(PasskeysState(passkeys = PASSKEYS))
composeTestRule.onNodeWithTag(PasskeysTestTags.BUTTON_SET_UP)
.assertIsDisplayed()
.performClick()
assertThat(events).contains(PasskeysEvent.SetUpPasskeyClicked)
}
@Test
fun givenPasskeys_whenIClickRenameInAPasskeysMenu_thenIExpectRenameEvent() {
setContent(PasskeysState(passkeys = PASSKEYS))
scrollTo(PasskeysTestTags.BUTTON_PASSKEY_MENU)
composeTestRule.onAllNodesWithTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)[0].performClick()
composeTestRule.onNodeWithTag(PasskeysTestTags.MENU_ITEM_RENAME).performClick()
assertThat(events).contains(PasskeysEvent.RenamePasskeyClicked(passkeyId = PASSKEYS[0].id))
}
@Test
fun givenPasskeys_whenIClickRemoveInAPasskeysMenu_thenIExpectRemoveEvent() {
setContent(PasskeysState(passkeys = PASSKEYS))
scrollTo(PasskeysTestTags.BUTTON_PASSKEY_MENU)
composeTestRule.onAllNodesWithTag(PasskeysTestTags.BUTTON_PASSKEY_MENU)[0].performClick()
composeTestRule.onNodeWithTag(PasskeysTestTags.MENU_ITEM_REMOVE).performClick()
assertThat(events).contains(PasskeysEvent.RemovePasskeyClicked(passkeyId = PASSKEYS[0].id))
}
private fun setContent(state: PasskeysState) {
composeTestRule.setContent {
PasskeysScreen(
state = state,
onEvent = { events += it }
)
}
}
private fun scrollTo(testTag: String) {
composeTestRule.onNodeWithTag(PasskeysTestTags.SCROLLER)
.performScrollToNode(hasTestTag(testTag))
}
}
@@ -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<Passkey> = PASSKEYS
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `the passkeys are available as soon as the screen opens`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
assertThat(viewModel.state.value.passkeys).isEqualTo(PASSKEYS)
}
@Test
fun `SetUpPasskeyClicked launches passkey creation`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(PasskeysEvent.SetUpPasskeyClicked)
assertThat(actions.last()).isEqualTo(PasskeysAction.LaunchPasskeyCreation)
}
@Test
fun `LearnMoreClicked opens the learn more article`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(PasskeysEvent.LearnMoreClicked)
assertThat(actions.last()).isEqualTo(PasskeysAction.OpenLearnMore)
}
@Test
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(PasskeysEvent.NavigateBackClicked)
assertThat(actions.last()).isEqualTo(PasskeysAction.NavigateBack)
}
@Test
fun `rename and remove produce no actions yet`() = runTest(testDispatcher) {
val viewModel = PasskeysViewModel(repository)
val actions = collectActions(viewModel.actions)
viewModel.onEvent(PasskeysEvent.RenamePasskeyClicked(passkeyId = 1))
viewModel.onEvent(PasskeysEvent.RemovePasskeyClicked(passkeyId = 1))
assertThat(actions).isEmpty()
}
private fun TestScope.collectActions(actions: Flow<PasskeysAction>): List<PasskeysAction> {
val collected = mutableListOf<PasskeysAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}