mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-19 16:24:41 +01:00
Add scaffolding for passkey creation screen.
This commit is contained in:
committed by
Cody Henthorne
parent
91441f6fca
commit
3e35f70bea
+1
@@ -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)
|
||||
|
||||
+3
@@ -107,6 +107,9 @@ class AccountSettingsViewModel(
|
||||
AccountSettingsEvent.AuthenticatorAppClicked -> {
|
||||
_actions.send(AccountSettingsAction.NavigateToAuthenticatorAppSetup)
|
||||
}
|
||||
AccountSettingsEvent.PasskeysClicked -> {
|
||||
_actions.send(AccountSettingsAction.NavigateToPasskeys)
|
||||
}
|
||||
AccountSettingsEvent.AdvancedPinSettingsClicked -> {
|
||||
_actions.send(AccountSettingsAction.NavigateToAdvancedPinSettings)
|
||||
}
|
||||
|
||||
+25
@@ -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
|
||||
}
|
||||
+52
@@ -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"
|
||||
|
||||
+12
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
|
||||
+5
-4
@@ -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
|
||||
}
|
||||
+14
@@ -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()
|
||||
)
|
||||
+60
@@ -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>
|
||||
|
||||
+11
@@ -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(
|
||||
|
||||
+111
@@ -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))
|
||||
}
|
||||
}
|
||||
+104
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user