From 3ace095b86de782247ced38eabaa430035cf72bb Mon Sep 17 00:00:00 2001 From: aj-rosado <109146700+aj-rosado@users.noreply.github.com> Date: Thu, 16 Oct 2025 16:51:54 +0100 Subject: [PATCH] [PM-26909] Implement screen capture toggle authenticator (#6033) --- .../platform/repository/SettingsRepository.kt | 10 +-- .../feature/settings/SettingsScreen.kt | 82 +++++++++++++++++-- .../feature/settings/SettingsViewModel.kt | 22 ++++- .../feature/settings/SettingsScreenTest.kt | 34 ++++++++ .../feature/settings/SettingsViewModelTest.kt | 29 +++++++ 5 files changed, 163 insertions(+), 14 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt index de08e0b83e..51241506da 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt @@ -67,16 +67,16 @@ interface SettingsRepository { */ var isScreenCaptureAllowed: Boolean - /** - * A set of Bitwarden account IDs that have previously been synced. - */ - var previouslySyncedBitwardenAccountIds: Set - /** * Whether or not screen capture is allowed for the current user. */ val isScreenCaptureAllowedStateFlow: StateFlow + /** + * A set of Bitwarden account IDs that have previously been synced. + */ + var previouslySyncedBitwardenAccountIds: Set + /** * Clears any previously stored encrypted user key used with biometrics for the current user. */ diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index 0a5625f1dc..f707bdf9f8 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -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 diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index 075767efc6..fbebc12342 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -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() } /** diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt index d2d0863f09..ec39b18392 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt @@ -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, ) diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt index 3f4a99e9ca..b43fffd3e0 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -47,6 +47,7 @@ class SettingsViewModelTest : BaseViewModelTest() { every { sharedCodesStateFlow } returns mutableSharedCodesFlow } private val mutableDefaultSaveOptionFlow = bufferedMutableSharedFlow() + 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, )