From 82096e06253a400deb3a672c8eab89fd3c25586d Mon Sep 17 00:00:00 2001 From: Shannon Draeker <125921730+shannon-livefront@users.noreply.github.com> Date: Tue, 30 Jul 2024 16:10:09 -0600 Subject: [PATCH] PM-9406: Add passkey management to autofill settings (#3392) --- .../row/BitwardenExternalLinkRow.kt | 3 ++ .../settings/autofill/AutoFillScreen.kt | 21 ++++++++++ .../settings/autofill/AutoFillViewModel.kt | 17 ++++++++ .../platform/manager/intent/IntentManager.kt | 6 +++ .../manager/intent/IntentManagerImpl.kt | 8 ++++ .../settings/autofill/AutoFillScreenTest.kt | 40 +++++++++++++++++++ .../autofill/AutoFillViewModelTest.kt | 40 ++++++++++++++++++- 7 files changed, 134 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/row/BitwardenExternalLinkRow.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/row/BitwardenExternalLinkRow.kt index 9a7d74e002..5f62878ebb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/row/BitwardenExternalLinkRow.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/row/BitwardenExternalLinkRow.kt @@ -23,6 +23,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme * @param text The label for the row as a [String]. * @param onConfirmClick The callback when the confirm button of the dialog is clicked. * @param modifier The modifier to be applied to the layout. + * @param description An optional description label to be displayed below the [text]. * @param withDivider Indicates if a divider should be drawn on the bottom of the row, defaults * to `true`. * @param dialogTitle The title of the dialog displayed when the user clicks this item. @@ -37,6 +38,7 @@ fun BitwardenExternalLinkRow( text: String, onConfirmClick: () -> Unit, modifier: Modifier = Modifier, + description: String? = null, withDivider: Boolean = true, dialogTitle: String, dialogMessage: String, @@ -46,6 +48,7 @@ fun BitwardenExternalLinkRow( var shouldShowDialog by rememberSaveable { mutableStateOf(false) } BitwardenTextRow( text = text, + description = description, onClick = { shouldShowDialog = true }, modifier = modifier, withDivider = withDivider, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt index e9b3bdbca3..0e02546d24 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreen.kt @@ -38,6 +38,7 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialog import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenSelectionRow import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText +import com.x8bit.bitwarden.ui.platform.components.row.BitwardenExternalLinkRow import com.x8bit.bitwarden.ui.platform.components.row.BitwardenTextRow import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenWideSwitch @@ -79,6 +80,10 @@ fun AutoFillScreen( AutoFillEvent.NavigateToBlockAutoFill -> { onNavigateToBlockAutoFillScreen() } + + AutoFillEvent.NavigateToSettings -> { + intentManager.startCredentialManagerSettings(context) + } } } @@ -150,6 +155,22 @@ fun AutoFillScreen( .padding(horizontal = 16.dp), ) } + if (state.showPasskeyManagementRow) { + BitwardenExternalLinkRow( + text = stringResource(id = R.string.passkey_management), + description = stringResource( + id = R.string.passkey_management_explanation_long, + ), + onConfirmClick = remember(viewModel) { + { viewModel.trySendAction(AutoFillAction.PasskeyManagementClick) } + }, + dialogTitle = stringResource(id = R.string.continue_to_device_settings), + dialogMessage = stringResource( + id = R.string.set_bitwarden_as_passkey_manager_description, + ), + withDivider = false, + ) + } Spacer(modifier = Modifier.height(16.dp)) BitwardenListHeaderText( label = stringResource(id = R.string.additional_options), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt index 4f9fb732e8..7c670ef913 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModel.kt @@ -35,6 +35,7 @@ class AutoFillViewModel @Inject constructor( isCopyTotpAutomaticallyEnabled = !settingsRepository.isAutoCopyTotpDisabled, isUseInlineAutoFillEnabled = settingsRepository.isInlineAutofillEnabled, showInlineAutofillOption = !isBuildVersionBelow(Build.VERSION_CODES.R), + showPasskeyManagementRow = !isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE), defaultUriMatchType = settingsRepository.defaultUriMatchType, ), ) { @@ -61,6 +62,7 @@ class AutoFillViewModel @Inject constructor( is AutoFillAction.DefaultUriMatchTypeSelect -> handleDefaultUriMatchTypeSelect(action) AutoFillAction.BlockAutoFillClick -> handleBlockAutoFillClick() is AutoFillAction.UseInlineAutofillClick -> handleUseInlineAutofillClick(action) + AutoFillAction.PasskeyManagementClick -> handlePasskeyManagementClick() is AutoFillAction.Internal.AutofillEnabledUpdateReceive -> { handleAutofillEnabledUpdateReceive(action) } @@ -95,6 +97,10 @@ class AutoFillViewModel @Inject constructor( mutableStateFlow.update { it.copy(isUseInlineAutoFillEnabled = action.isEnabled) } } + private fun handlePasskeyManagementClick() { + sendEvent(AutoFillEvent.NavigateToSettings) + } + private fun handleDefaultUriMatchTypeSelect(action: AutoFillAction.DefaultUriMatchTypeSelect) { settingsRepository.defaultUriMatchType = action.defaultUriMatchType mutableStateFlow.update { @@ -125,6 +131,7 @@ data class AutoFillState( val isCopyTotpAutomaticallyEnabled: Boolean, val isUseInlineAutoFillEnabled: Boolean, val showInlineAutofillOption: Boolean, + val showPasskeyManagementRow: Boolean, val defaultUriMatchType: UriMatchType, ) : Parcelable { @@ -155,6 +162,11 @@ sealed class AutoFillEvent { */ data object NavigateToBlockAutoFill : AutoFillEvent() + /** + * Navigate to device settings. + */ + data object NavigateToSettings : AutoFillEvent() + /** * Displays a toast with the given [Text]. */ @@ -212,6 +224,11 @@ sealed class AutoFillAction { val isEnabled: Boolean, ) : AutoFillAction() + /** + * User clicked passkey management button. + */ + data object PasskeyManagementClick : AutoFillAction() + /** * Internal actions. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt index f3eac28a6e..821f462029 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.platform.manager.intent import android.app.PendingIntent +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Parcelable @@ -37,6 +38,11 @@ interface IntentManager { */ fun startApplicationDetailsSettingsActivity() + /** + * Starts the credential manager settings. + */ + fun startCredentialManagerSettings(context: Context) + /** * Start an activity to view the given [uri] in an external browser. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt index 91f7a642ce..42482bf05e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt @@ -8,6 +8,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.provider.MediaStore import android.provider.Settings import android.webkit.MimeTypeMap @@ -19,6 +20,7 @@ import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.credentials.CredentialManager import com.x8bit.bitwarden.BuildConfig import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.autofill.util.toPendingIntentMutabilityFlag @@ -117,6 +119,12 @@ class IntentManagerImpl( startActivity(intent = intent) } + override fun startCredentialManagerSettings(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + CredentialManager.create(context).createSettingsPendingIntent().send() + } + } + override fun launchUri(uri: Uri) { val newUri = if (uri.scheme == null) { uri.buildUpon().scheme("https").build() diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt index ac32b03858..216527bf9e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillScreenTest.kt @@ -20,7 +20,9 @@ import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.util.assertNoDialogExists import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -42,6 +44,7 @@ class AutoFillScreenTest : BaseComposeTest() { } private val intentManager: IntentManager = mockk { every { startSystemAutofillSettingsActivity() } answers { isSystemSettingsRequestSuccess } + every { startCredentialManagerSettings(any()) } just runs } @Before @@ -94,6 +97,15 @@ class AutoFillScreenTest : BaseComposeTest() { .assertIsDisplayed() } + @Test + fun `on NavigateToSettings should attempt to navigate to credential manager settings`() { + mutableEventFlow.tryEmit(AutoFillEvent.NavigateToSettings) + + verify { intentManager.startCredentialManagerSettings(any()) } + + composeTestRule.assertNoDialogExists() + } + @Test fun `on autofill settings fallback dialog Ok click should dismiss the dialog`() { isSystemSettingsRequestSuccess = false @@ -187,6 +199,33 @@ class AutoFillScreenTest : BaseComposeTest() { .assertIsNotEnabled() } + @Suppress("MaxLineLength") + @Test + fun `on passkey management click should display confirmation dialog and confirm click should emit PasskeyManagementClick`() { + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule + .onNodeWithText("Passkey management") + .performClick() + composeTestRule.onNode(isDialog()).assertExists() + composeTestRule + .onAllNodesWithText("Continue") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + composeTestRule.onNode(isDialog()).assertDoesNotExist() + verify { viewModel.trySendAction(AutoFillAction.PasskeyManagementClick) } + } + + @Test + fun `passkey management row should not appear according to state`() { + mutableStateFlow.update { + it.copy( + showPasskeyManagementRow = false, + ) + } + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText("Passkey management").assertDoesNotExist() + } + @Test fun `use inline autofill should be displayed according to state`() { mutableStateFlow.update { @@ -356,5 +395,6 @@ private val DEFAULT_STATE: AutoFillState = AutoFillState( isCopyTotpAutomaticallyEnabled = false, isUseInlineAutoFillEnabled = false, showInlineAutofillOption = true, + showPasskeyManagementRow = true, defaultUriMatchType = UriMatchType.DOMAIN, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt index 1191b4d2ee..a3c8f19f5f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/autofill/AutoFillViewModelTest.kt @@ -50,12 +50,20 @@ class AutoFillViewModelTest : BaseViewModelTest() { @Test fun `initial state should be correct when not set`() { + mockkStatic(::isBuildVersionBelow) + every { isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) } returns false + val viewModel = createViewModel(state = null) assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + + unmockkStatic(::isBuildVersionBelow) } @Test fun `initial state should be correct when set`() { + mockkStatic(::isBuildVersionBelow) + every { isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) } returns false + mutableIsAutofillEnabledStateFlow.value = true val state = DEFAULT_STATE.copy( isAutoFillServicesEnabled = true, @@ -63,6 +71,23 @@ class AutoFillViewModelTest : BaseViewModelTest() { ) val viewModel = createViewModel(state = state) assertEquals(state, viewModel.stateFlow.value) + + unmockkStatic(::isBuildVersionBelow) + } + + @Test + fun `initial state should be correct when sdk is below min`() { + mockkStatic(::isBuildVersionBelow) + every { isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) } returns true + + val expected = DEFAULT_STATE.copy( + showPasskeyManagementRow = false, + ) + val viewModel = createViewModel(state = null) + + assertEquals(expected, viewModel.stateFlow.value) + + unmockkStatic(::isBuildVersionBelow) } @Test @@ -70,7 +95,10 @@ class AutoFillViewModelTest : BaseViewModelTest() { every { isBuildVersionBelow(Build.VERSION_CODES.R) } returns false val viewModel = createViewModel(state = null) assertEquals( - DEFAULT_STATE.copy(showInlineAutofillOption = true), + DEFAULT_STATE.copy( + showInlineAutofillOption = true, + showPasskeyManagementRow = false, + ), viewModel.stateFlow.value, ) } @@ -175,6 +203,15 @@ class AutoFillViewModelTest : BaseViewModelTest() { verify { settingsRepository.isInlineAutofillEnabled = false } } + @Test + fun `on PasskeyManagementClick should emit NavigateToSettings`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AutoFillAction.PasskeyManagementClick) + assertEquals(AutoFillEvent.NavigateToSettings, awaitItem()) + } + } + @Suppress("MaxLineLength") @Test fun `on DefaultUriMatchTypeSelect should update the state and save the new value to settings`() { @@ -211,5 +248,6 @@ private val DEFAULT_STATE: AutoFillState = AutoFillState( isCopyTotpAutomaticallyEnabled = false, isUseInlineAutoFillEnabled = true, showInlineAutofillOption = false, + showPasskeyManagementRow = true, defaultUriMatchType = UriMatchType.DOMAIN, )