PM-9406: Add passkey management to autofill settings (#3392)

This commit is contained in:
Shannon Draeker 2024-07-30 16:10:09 -06:00 committed by GitHub
parent 646566edd8
commit 82096e0625
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 134 additions and 1 deletions

View file

@ -23,6 +23,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
* @param text The label for the row as a [String]. * @param text The label for the row as a [String].
* @param onConfirmClick The callback when the confirm button of the dialog is clicked. * @param onConfirmClick The callback when the confirm button of the dialog is clicked.
* @param modifier The modifier to be applied to the layout. * @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 * @param withDivider Indicates if a divider should be drawn on the bottom of the row, defaults
* to `true`. * to `true`.
* @param dialogTitle The title of the dialog displayed when the user clicks this item. * @param dialogTitle The title of the dialog displayed when the user clicks this item.
@ -37,6 +38,7 @@ fun BitwardenExternalLinkRow(
text: String, text: String,
onConfirmClick: () -> Unit, onConfirmClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
description: String? = null,
withDivider: Boolean = true, withDivider: Boolean = true,
dialogTitle: String, dialogTitle: String,
dialogMessage: String, dialogMessage: String,
@ -46,6 +48,7 @@ fun BitwardenExternalLinkRow(
var shouldShowDialog by rememberSaveable { mutableStateOf(false) } var shouldShowDialog by rememberSaveable { mutableStateOf(false) }
BitwardenTextRow( BitwardenTextRow(
text = text, text = text,
description = description,
onClick = { shouldShowDialog = true }, onClick = { shouldShowDialog = true },
modifier = modifier, modifier = modifier,
withDivider = withDivider, withDivider = withDivider,

View file

@ -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.BitwardenSelectionDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenSelectionRow 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.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.row.BitwardenTextRow
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenWideSwitch
@ -79,6 +80,10 @@ fun AutoFillScreen(
AutoFillEvent.NavigateToBlockAutoFill -> { AutoFillEvent.NavigateToBlockAutoFill -> {
onNavigateToBlockAutoFillScreen() onNavigateToBlockAutoFillScreen()
} }
AutoFillEvent.NavigateToSettings -> {
intentManager.startCredentialManagerSettings(context)
}
} }
} }
@ -150,6 +155,22 @@ fun AutoFillScreen(
.padding(horizontal = 16.dp), .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)) Spacer(modifier = Modifier.height(16.dp))
BitwardenListHeaderText( BitwardenListHeaderText(
label = stringResource(id = R.string.additional_options), label = stringResource(id = R.string.additional_options),

View file

@ -35,6 +35,7 @@ class AutoFillViewModel @Inject constructor(
isCopyTotpAutomaticallyEnabled = !settingsRepository.isAutoCopyTotpDisabled, isCopyTotpAutomaticallyEnabled = !settingsRepository.isAutoCopyTotpDisabled,
isUseInlineAutoFillEnabled = settingsRepository.isInlineAutofillEnabled, isUseInlineAutoFillEnabled = settingsRepository.isInlineAutofillEnabled,
showInlineAutofillOption = !isBuildVersionBelow(Build.VERSION_CODES.R), showInlineAutofillOption = !isBuildVersionBelow(Build.VERSION_CODES.R),
showPasskeyManagementRow = !isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
defaultUriMatchType = settingsRepository.defaultUriMatchType, defaultUriMatchType = settingsRepository.defaultUriMatchType,
), ),
) { ) {
@ -61,6 +62,7 @@ class AutoFillViewModel @Inject constructor(
is AutoFillAction.DefaultUriMatchTypeSelect -> handleDefaultUriMatchTypeSelect(action) is AutoFillAction.DefaultUriMatchTypeSelect -> handleDefaultUriMatchTypeSelect(action)
AutoFillAction.BlockAutoFillClick -> handleBlockAutoFillClick() AutoFillAction.BlockAutoFillClick -> handleBlockAutoFillClick()
is AutoFillAction.UseInlineAutofillClick -> handleUseInlineAutofillClick(action) is AutoFillAction.UseInlineAutofillClick -> handleUseInlineAutofillClick(action)
AutoFillAction.PasskeyManagementClick -> handlePasskeyManagementClick()
is AutoFillAction.Internal.AutofillEnabledUpdateReceive -> { is AutoFillAction.Internal.AutofillEnabledUpdateReceive -> {
handleAutofillEnabledUpdateReceive(action) handleAutofillEnabledUpdateReceive(action)
} }
@ -95,6 +97,10 @@ class AutoFillViewModel @Inject constructor(
mutableStateFlow.update { it.copy(isUseInlineAutoFillEnabled = action.isEnabled) } mutableStateFlow.update { it.copy(isUseInlineAutoFillEnabled = action.isEnabled) }
} }
private fun handlePasskeyManagementClick() {
sendEvent(AutoFillEvent.NavigateToSettings)
}
private fun handleDefaultUriMatchTypeSelect(action: AutoFillAction.DefaultUriMatchTypeSelect) { private fun handleDefaultUriMatchTypeSelect(action: AutoFillAction.DefaultUriMatchTypeSelect) {
settingsRepository.defaultUriMatchType = action.defaultUriMatchType settingsRepository.defaultUriMatchType = action.defaultUriMatchType
mutableStateFlow.update { mutableStateFlow.update {
@ -125,6 +131,7 @@ data class AutoFillState(
val isCopyTotpAutomaticallyEnabled: Boolean, val isCopyTotpAutomaticallyEnabled: Boolean,
val isUseInlineAutoFillEnabled: Boolean, val isUseInlineAutoFillEnabled: Boolean,
val showInlineAutofillOption: Boolean, val showInlineAutofillOption: Boolean,
val showPasskeyManagementRow: Boolean,
val defaultUriMatchType: UriMatchType, val defaultUriMatchType: UriMatchType,
) : Parcelable { ) : Parcelable {
@ -155,6 +162,11 @@ sealed class AutoFillEvent {
*/ */
data object NavigateToBlockAutoFill : AutoFillEvent() data object NavigateToBlockAutoFill : AutoFillEvent()
/**
* Navigate to device settings.
*/
data object NavigateToSettings : AutoFillEvent()
/** /**
* Displays a toast with the given [Text]. * Displays a toast with the given [Text].
*/ */
@ -212,6 +224,11 @@ sealed class AutoFillAction {
val isEnabled: Boolean, val isEnabled: Boolean,
) : AutoFillAction() ) : AutoFillAction()
/**
* User clicked passkey management button.
*/
data object PasskeyManagementClick : AutoFillAction()
/** /**
* Internal actions. * Internal actions.
*/ */

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.manager.intent package com.x8bit.bitwarden.ui.platform.manager.intent
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
@ -37,6 +38,11 @@ interface IntentManager {
*/ */
fun startApplicationDetailsSettingsActivity() fun startApplicationDetailsSettingsActivity()
/**
* Starts the credential manager settings.
*/
fun startCredentialManagerSettings(context: Context)
/** /**
* Start an activity to view the given [uri] in an external browser. * Start an activity to view the given [uri] in an external browser.
*/ */

View file

@ -8,6 +8,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.Settings import android.provider.Settings
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
@ -19,6 +20,7 @@ import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.credentials.CredentialManager
import com.x8bit.bitwarden.BuildConfig import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.autofill.util.toPendingIntentMutabilityFlag import com.x8bit.bitwarden.data.autofill.util.toPendingIntentMutabilityFlag
@ -117,6 +119,12 @@ class IntentManagerImpl(
startActivity(intent = intent) 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) { override fun launchUri(uri: Uri) {
val newUri = if (uri.scheme == null) { val newUri = if (uri.scheme == null) {
uri.buildUpon().scheme("https").build() uri.buildUpon().scheme("https").build()

View file

@ -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.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.assertNoDialogExists import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -42,6 +44,7 @@ class AutoFillScreenTest : BaseComposeTest() {
} }
private val intentManager: IntentManager = mockk { private val intentManager: IntentManager = mockk {
every { startSystemAutofillSettingsActivity() } answers { isSystemSettingsRequestSuccess } every { startSystemAutofillSettingsActivity() } answers { isSystemSettingsRequestSuccess }
every { startCredentialManagerSettings(any()) } just runs
} }
@Before @Before
@ -94,6 +97,15 @@ class AutoFillScreenTest : BaseComposeTest() {
.assertIsDisplayed() .assertIsDisplayed()
} }
@Test
fun `on NavigateToSettings should attempt to navigate to credential manager settings`() {
mutableEventFlow.tryEmit(AutoFillEvent.NavigateToSettings)
verify { intentManager.startCredentialManagerSettings(any()) }
composeTestRule.assertNoDialogExists()
}
@Test @Test
fun `on autofill settings fallback dialog Ok click should dismiss the dialog`() { fun `on autofill settings fallback dialog Ok click should dismiss the dialog`() {
isSystemSettingsRequestSuccess = false isSystemSettingsRequestSuccess = false
@ -187,6 +199,33 @@ class AutoFillScreenTest : BaseComposeTest() {
.assertIsNotEnabled() .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 @Test
fun `use inline autofill should be displayed according to state`() { fun `use inline autofill should be displayed according to state`() {
mutableStateFlow.update { mutableStateFlow.update {
@ -356,5 +395,6 @@ private val DEFAULT_STATE: AutoFillState = AutoFillState(
isCopyTotpAutomaticallyEnabled = false, isCopyTotpAutomaticallyEnabled = false,
isUseInlineAutoFillEnabled = false, isUseInlineAutoFillEnabled = false,
showInlineAutofillOption = true, showInlineAutofillOption = true,
showPasskeyManagementRow = true,
defaultUriMatchType = UriMatchType.DOMAIN, defaultUriMatchType = UriMatchType.DOMAIN,
) )

View file

@ -50,12 +50,20 @@ class AutoFillViewModelTest : BaseViewModelTest() {
@Test @Test
fun `initial state should be correct when not set`() { 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) val viewModel = createViewModel(state = null)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
unmockkStatic(::isBuildVersionBelow)
} }
@Test @Test
fun `initial state should be correct when set`() { fun `initial state should be correct when set`() {
mockkStatic(::isBuildVersionBelow)
every { isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) } returns false
mutableIsAutofillEnabledStateFlow.value = true mutableIsAutofillEnabledStateFlow.value = true
val state = DEFAULT_STATE.copy( val state = DEFAULT_STATE.copy(
isAutoFillServicesEnabled = true, isAutoFillServicesEnabled = true,
@ -63,6 +71,23 @@ class AutoFillViewModelTest : BaseViewModelTest() {
) )
val viewModel = createViewModel(state = state) val viewModel = createViewModel(state = state)
assertEquals(state, viewModel.stateFlow.value) 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 @Test
@ -70,7 +95,10 @@ class AutoFillViewModelTest : BaseViewModelTest() {
every { isBuildVersionBelow(Build.VERSION_CODES.R) } returns false every { isBuildVersionBelow(Build.VERSION_CODES.R) } returns false
val viewModel = createViewModel(state = null) val viewModel = createViewModel(state = null)
assertEquals( assertEquals(
DEFAULT_STATE.copy(showInlineAutofillOption = true), DEFAULT_STATE.copy(
showInlineAutofillOption = true,
showPasskeyManagementRow = false,
),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
} }
@ -175,6 +203,15 @@ class AutoFillViewModelTest : BaseViewModelTest() {
verify { settingsRepository.isInlineAutofillEnabled = false } 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") @Suppress("MaxLineLength")
@Test @Test
fun `on DefaultUriMatchTypeSelect should update the state and save the new value to settings`() { 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, isCopyTotpAutomaticallyEnabled = false,
isUseInlineAutoFillEnabled = true, isUseInlineAutoFillEnabled = true,
showInlineAutofillOption = false, showInlineAutofillOption = false,
showPasskeyManagementRow = true,
defaultUriMatchType = UriMatchType.DOMAIN, defaultUriMatchType = UriMatchType.DOMAIN,
) )