[PM-26909] Implement screen capture toggle authenticator (#6033)

This commit is contained in:
aj-rosado 2025-10-16 16:51:54 +01:00 committed by GitHub
parent 8a90d77fd7
commit 3ace095b86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 163 additions and 14 deletions

View File

@ -67,16 +67,16 @@ interface SettingsRepository {
*/
var isScreenCaptureAllowed: Boolean
/**
* A set of Bitwarden account IDs that have previously been synced.
*/
var previouslySyncedBitwardenAccountIds: Set<String>
/**
* Whether or not screen capture is allowed for the current user.
*/
val isScreenCaptureAllowedStateFlow: StateFlow<Boolean>
/**
* A set of Bitwarden account IDs that have previously been synced.
*/
var previouslySyncedBitwardenAccountIds: Set<String>
/**
* Clears any previously stored encrypted user key used with biometrics for the current user.
*/

View File

@ -52,6 +52,7 @@ import com.bitwarden.ui.platform.base.util.cardStyle
import com.bitwarden.ui.platform.base.util.mirrorIfRtl
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenMediumTopAppBar
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.bitwarden.ui.platform.components.model.CardStyle
@ -157,6 +158,13 @@ fun SettingsScreen(
)
}
},
onScreenCaptureChange = remember(viewModel) {
{
viewModel.trySendAction(
SettingsAction.SecurityClick.AllowScreenCaptureToggle(it),
)
}
},
)
Spacer(modifier = Modifier.height(16.dp))
VaultSettings(
@ -256,8 +264,8 @@ private fun SecuritySettings(
state: SettingsState,
biometricsManager: BiometricsManager = LocalBiometricsManager.current,
onBiometricToggle: (Boolean) -> Unit,
onScreenCaptureChange: (Boolean) -> Unit,
) {
if (!biometricsManager.isBiometricsSupported) return
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenListHeaderText(
modifier = Modifier
@ -265,18 +273,35 @@ private fun SecuritySettings(
.padding(horizontal = 16.dp),
label = stringResource(id = BitwardenString.security),
)
Spacer(modifier = Modifier.height(8.dp))
UnlockWithBiometricsRow(
val hasBiometrics = biometricsManager.isBiometricsSupported
if (hasBiometrics) {
UnlockWithBiometricsRow(
modifier = Modifier
.testTag("UnlockWithBiometricsSwitch")
.fillMaxWidth()
.standardHorizontalMargin(),
isChecked = state.isUnlockWithBiometricsEnabled,
onBiometricToggle = { onBiometricToggle(it) },
biometricsManager = biometricsManager,
)
}
ScreenCaptureRow(
currentValue = state.allowScreenCapture,
cardStyle = if (hasBiometrics) {
CardStyle.Bottom
} else {
CardStyle.Full
},
onValueChange = onScreenCaptureChange,
modifier = Modifier
.testTag("UnlockWithBiometricsSwitch")
.fillMaxWidth()
.testTag(tag = "AllowScreenCaptureSwitch")
.standardHorizontalMargin(),
isChecked = state.isUnlockWithBiometricsEnabled,
onBiometricToggle = { onBiometricToggle(it) },
biometricsManager = biometricsManager,
)
}
//endregion
//region Data settings
@ -421,7 +446,7 @@ private fun UnlockWithBiometricsRow(
var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) }
BitwardenSwitch(
modifier = modifier,
cardStyle = CardStyle.Full,
cardStyle = CardStyle.Top(),
label = stringResource(BitwardenString.unlock_with_biometrics),
isChecked = isChecked || showBiometricsPrompt,
onCheckedChange = { toggled ->
@ -443,6 +468,47 @@ private fun UnlockWithBiometricsRow(
)
}
@Composable
private fun ScreenCaptureRow(
currentValue: Boolean,
cardStyle: CardStyle,
onValueChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowScreenCaptureConfirmDialog by remember { mutableStateOf(false) }
BitwardenSwitch(
label = stringResource(id = BitwardenString.allow_screen_capture),
isChecked = currentValue,
onCheckedChange = {
if (currentValue) {
onValueChange(false)
} else {
shouldShowScreenCaptureConfirmDialog = true
}
},
cardStyle = cardStyle,
modifier = modifier,
)
if (shouldShowScreenCaptureConfirmDialog) {
BitwardenTwoButtonDialog(
title = stringResource(BitwardenString.allow_screen_capture),
message = stringResource(
id = BitwardenString.are_you_sure_you_want_to_enable_screen_capture,
),
confirmButtonText = stringResource(BitwardenString.yes),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = {
onValueChange(true)
shouldShowScreenCaptureConfirmDialog = false
},
onDismissClick = { shouldShowScreenCaptureConfirmDialog = false },
onDismissRequest = { shouldShowScreenCaptureConfirmDialog = false },
)
}
}
//endregion Data settings
//region Appearance settings

View File

@ -58,6 +58,7 @@ class SettingsViewModel @Inject constructor(
accountSyncState = authenticatorBridgeManager.accountSyncStateFlow.value,
defaultSaveOption = settingsRepository.defaultSaveOption,
sharedAccountsState = authenticatorRepository.sharedCodesStateFlow.value,
isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed,
),
) {
@ -126,6 +127,10 @@ class SettingsViewModel @Inject constructor(
is SettingsAction.SecurityClick.UnlockWithBiometricToggle -> {
handleBiometricsSetupClick(action)
}
is SettingsAction.SecurityClick.AllowScreenCaptureToggle -> {
handleAllowScreenCaptureToggle(action)
}
}
}
@ -173,6 +178,13 @@ class SettingsViewModel @Inject constructor(
}
}
private fun handleAllowScreenCaptureToggle(
action: SettingsAction.SecurityClick.AllowScreenCaptureToggle,
) {
settingsRepository.isScreenCaptureAllowed = action.enabled
mutableStateFlow.update { it.copy(allowScreenCapture = action.enabled) }
}
private fun handleVaultClick(action: SettingsAction.DataClick) {
when (action) {
SettingsAction.DataClick.ExportClick -> handleExportClick()
@ -319,6 +331,7 @@ class SettingsViewModel @Inject constructor(
isSubmitCrashLogsEnabled: Boolean,
accountSyncState: AccountSyncState,
sharedAccountsState: SharedVerificationCodesState,
isScreenCaptureAllowed: Boolean,
): SettingsState {
val currentYear = Year.now(clock)
val copyrightInfo = "© Bitwarden Inc. 2015-$currentYear".asText()
@ -343,6 +356,7 @@ class SettingsViewModel @Inject constructor(
defaultSaveOption = defaultSaveOption,
showSyncWithBitwarden = shouldShowSyncWithBitwarden,
showDefaultSaveOptionRow = shouldShowDefaultSaveOption,
allowScreenCapture = isScreenCaptureAllowed,
)
}
}
@ -362,6 +376,7 @@ data class SettingsState(
val dialog: Dialog?,
val version: Text,
val copyrightInfo: Text,
val allowScreenCapture: Boolean,
) : Parcelable {
/**
@ -460,13 +475,18 @@ sealed class SettingsAction(
}
/**
* Indicates the user clicked the Unlock with biometrics button.
* Models actions for the Security section of settings.
*/
sealed class SecurityClick : SettingsAction() {
/**
* Indicates the user clicked unlock with biometrics toggle.
*/
data class UnlockWithBiometricToggle(val enabled: Boolean) : SecurityClick()
/**
* Indicates the user clicked allow screen capture toggle.
*/
data class AllowScreenCaptureToggle(val enabled: Boolean) : SecurityClick()
}
/**

View File

@ -20,6 +20,7 @@ import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.assertNoDialogExists
import com.bitwarden.ui.util.concat
import io.mockk.every
import io.mockk.just
@ -175,6 +176,38 @@ class SettingsScreenTest : AuthenticatorComposeTest() {
.onNode(isDialog())
.assertDoesNotExist()
}
@Test
fun `on allow screen capture confirm should send AllowScreenCaptureToggle`() {
composeTestRule.onNodeWithText("Allow screen capture").performScrollTo().performClick()
composeTestRule.onNodeWithText("Yes").performClick()
composeTestRule.assertNoDialogExists()
verify {
viewModel.trySendAction(
SettingsAction.SecurityClick.AllowScreenCaptureToggle(true),
)
}
}
@Test
fun `on allow screen capture cancel should dismiss dialog`() {
composeTestRule.onNodeWithText("Allow screen capture").performScrollTo().performClick()
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
}
@Test
fun `on allow screen capture row click should display confirm enable screen capture dialog`() {
composeTestRule.onNodeWithText("Allow screen capture").performScrollTo().performClick()
composeTestRule
.onAllNodesWithText("Allow screen capture")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
}
private val APP_LANGUAGE = AppLanguage.ENGLISH
@ -194,4 +227,5 @@ private val DEFAULT_STATE = SettingsState(
version = BitwardenString.version.asText()
.concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()),
copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(),
allowScreenCapture = false,
)

View File

@ -47,6 +47,7 @@ class SettingsViewModelTest : BaseViewModelTest() {
every { sharedCodesStateFlow } returns mutableSharedCodesFlow
}
private val mutableDefaultSaveOptionFlow = bufferedMutableSharedFlow<DefaultSaveOption>()
private val mutableScreenCaptureAllowedStateFlow = MutableStateFlow(false)
private val settingsRepository: SettingsRepository = mockk {
every { appLanguage } returns APP_LANGUAGE
every { appTheme } returns APP_THEME
@ -54,6 +55,9 @@ class SettingsViewModelTest : BaseViewModelTest() {
every { defaultSaveOptionFlow } returns mutableDefaultSaveOptionFlow
every { isUnlockWithBiometricsEnabled } returns true
every { isCrashLoggingEnabled } returns true
every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedStateFlow
every { isScreenCaptureAllowed } answers { mutableScreenCaptureAllowedStateFlow.value }
every { isScreenCaptureAllowed = any() } just runs
}
private val clipboardManager: BitwardenClipboardManager = mockk()
@ -197,6 +201,30 @@ class SettingsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on AllowScreenCaptureToggled should update value in state and SettingsRepository`() =
runTest {
val viewModel = createViewModel()
val newScreenCaptureAllowedValue = true
viewModel.trySendAction(
SettingsAction.SecurityClick.AllowScreenCaptureToggle(
newScreenCaptureAllowedValue,
),
)
verify(exactly = 1) {
settingsRepository.isScreenCaptureAllowed = newScreenCaptureAllowedValue
}
viewModel.stateFlow.test {
assertEquals(
DEFAULT_STATE.copy(allowScreenCapture = true),
awaitItem(),
)
}
}
private fun createViewModel(
savedState: SettingsState? = DEFAULT_STATE,
) = SettingsViewModel(
@ -231,4 +259,5 @@ private val DEFAULT_STATE = SettingsState(
version = BitwardenString.version.asText()
.concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()),
copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(),
allowScreenCapture = false,
)