diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt index 44244a90d..01826c062 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden import android.content.Intent import android.os.Bundle +import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -10,10 +11,13 @@ import androidx.compose.runtime.getValue import androidx.core.os.LocaleListCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import javax.inject.Inject /** @@ -32,6 +36,8 @@ class MainActivity : AppCompatActivity() { installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen } super.onCreate(savedInstanceState) + observeViewModelEvents() + if (savedInstanceState == null) { mainViewModel.trySendAction( MainAction.ReceiveFirstIntent( @@ -67,4 +73,25 @@ class MainActivity : AppCompatActivity() { ), ) } + + private fun observeViewModelEvents() { + mainViewModel + .eventFlow + .onEach { event -> + when (event) { + is MainEvent.ScreenCaptureSettingChange -> { + handleScreenCaptureSettingChange(event) + } + } + } + .launchIn(lifecycleScope) + } + + private fun handleScreenCaptureSettingChange(event: MainEvent.ScreenCaptureSettingChange) { + if (event.isAllowed) { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index 4a02854a4..4c477ae82 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -28,7 +28,7 @@ class MainViewModel @Inject constructor( private val intentManager: IntentManager, settingsRepository: SettingsRepository, private val savedStateHandle: SavedStateHandle, -) : BaseViewModel( +) : BaseViewModel( MainState( theme = settingsRepository.appTheme, ), @@ -52,6 +52,13 @@ class MainViewModel @Inject constructor( .appThemeStateFlow .onEach { trySendAction(MainAction.Internal.ThemeUpdate(it)) } .launchIn(viewModelScope) + + settingsRepository + .isScreenCaptureAllowedStateFlow + .onEach { isAllowed -> + sendEvent(MainEvent.ScreenCaptureSettingChange(isAllowed)) + } + .launchIn(viewModelScope) } override fun handleAction(action: MainAction) { @@ -133,3 +140,14 @@ sealed class MainAction { ) : Internal() } } + +/** + * Represents events that are emitted by the [MainViewModel]. + */ +sealed class MainEvent { + + /** + * Event indicating a change in the screen capture setting. + */ + data class ScreenCaptureSettingChange(val isAllowed: Boolean) : MainEvent() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt index ec6a2f981..9e2aa0262 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt @@ -177,6 +177,11 @@ interface SettingsDiskSource { */ fun getScreenCaptureAllowed(userId: String): Boolean? + /** + * Emits updates that track [getScreenCaptureAllowed] for the given [userId]. + */ + fun getScreenCaptureAllowedFlow(userId: String): Flow + /** * Stores whether or not [isScreenCaptureAllowed] for the given [userId]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index 163c93387..95841062d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -54,6 +54,9 @@ class SettingsDiskSourceImpl( private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow() + private val mutableScreenCaptureAllowedFlowMap = + mutableMapOf>() + override var appLanguage: AppLanguage? get() = getString(key = APP_LANGUAGE_KEY) ?.let { storedValue -> @@ -260,6 +263,11 @@ class SettingsDiskSourceImpl( bufferedMutableSharedFlow(replay = 1) } + private fun getMutableScreenCaptureAllowedFlow(userId: String): MutableSharedFlow = + mutableScreenCaptureAllowedFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + override fun getApprovePasswordlessLoginsEnabled(userId: String): Boolean? { return getBoolean(key = "${APPROVE_PASSWORDLESS_LOGINS_KEY}_$userId") } @@ -278,6 +286,10 @@ class SettingsDiskSourceImpl( return getBoolean(key = "${SCREEN_CAPTURE_ALLOW_KEY}_$userId") } + override fun getScreenCaptureAllowedFlow(userId: String): Flow = + getMutableScreenCaptureAllowedFlow(userId) + .onSubscription { emit(getScreenCaptureAllowed(userId)) } + override fun storeScreenCaptureAllowed( userId: String, isScreenCaptureAllowed: Boolean?, @@ -286,5 +298,6 @@ class SettingsDiskSourceImpl( key = "${SCREEN_CAPTURE_ALLOW_KEY}_$userId", value = isScreenCaptureAllowed, ) + getMutableScreenCaptureAllowedFlow(userId).tryEmit(isScreenCaptureAllowed) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt index 8f17a9883..aa13896d2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt @@ -98,6 +98,16 @@ interface SettingsRepository { */ val isAutofillEnabledStateFlow: StateFlow + /** + * Sets whether or not screen capture is allowed for the current user. + */ + var isScreenCaptureAllowed: Boolean + + /** + * Whether or not screen capture is allowed for the current user. + */ + val isScreenCaptureAllowedStateFlow: StateFlow + /** * Disables autofill if it is currently enabled. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index 101e7cb98..afe7815cd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.platform.repository import android.view.autofill.AutofillManager +import com.x8bit.bitwarden.BuildConfig import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager @@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -26,6 +28,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.Instant +private val DEFAULT_IS_SCREEN_CAPTURE_ALLOWED = BuildConfig.DEBUG + /** * Primary implementation of [SettingsRepository]. */ @@ -203,6 +207,39 @@ class SettingsRepositoryImpl( override val isAutofillEnabledStateFlow: StateFlow = mutableIsAutofillEnabledStateFlow.asStateFlow() + override var isScreenCaptureAllowed: Boolean + get() = activeUserId?.let { + settingsDiskSource.getScreenCaptureAllowed(it) + } ?: DEFAULT_IS_SCREEN_CAPTURE_ALLOWED + set(value) { + val userId = activeUserId ?: return + settingsDiskSource.storeScreenCaptureAllowed( + userId = userId, + isScreenCaptureAllowed = value, + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override val isScreenCaptureAllowedStateFlow: StateFlow + get() = authDiskSource + .userStateFlow + .flatMapLatest { userState -> + userState + ?.activeUserId + ?.let { + settingsDiskSource.getScreenCaptureAllowedFlow(userId = it) + .map { isAllowed -> isAllowed ?: DEFAULT_IS_SCREEN_CAPTURE_ALLOWED } + } + ?: flowOf(DEFAULT_IS_SCREEN_CAPTURE_ALLOWED) + } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Lazily, + initialValue = activeUserId + ?.let { settingsDiskSource.getScreenCaptureAllowed(userId = it) } + ?: DEFAULT_IS_SCREEN_CAPTURE_ALLOWED, + ) + init { observeAutofillEnabledChanges() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModel.kt index be56159d1..af11dad02 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModel.kt @@ -36,7 +36,7 @@ class OtherViewModel @Inject constructor( ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: OtherState( - allowScreenCapture = false, + allowScreenCapture = settingsRepo.isScreenCaptureAllowed, allowSyncOnRefresh = settingsRepo.getPullToRefreshEnabledFlow().value, clearClipboardFrequency = OtherState.ClearClipboardFrequency.DEFAULT, lastSyncTime = settingsRepo @@ -64,7 +64,7 @@ class OtherViewModel @Inject constructor( } private fun handleAllowScreenCaptureToggled(action: OtherAction.AllowScreenCaptureToggle) { - // TODO BIT-805 implement screen capture setting + settingsRepo.isScreenCaptureAllowed = action.isScreenCaptureEnabled mutableStateFlow.update { it.copy(allowScreenCapture = action.isScreenCaptureEnabled) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index 439f27326..66bb13121 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden import android.content.Intent +import app.cash.turbine.test import androidx.lifecycle.SavedStateHandle import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState @@ -16,6 +17,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -24,6 +26,7 @@ class MainViewModelTest : BaseViewModelTest() { private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT) private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true) val authRepository = mockk { every { userStateFlow } returns mutableUserStateFlow every { activeUserId } returns USER_ID @@ -31,6 +34,7 @@ class MainViewModelTest : BaseViewModelTest() { private val settingsRepository = mockk { every { appTheme } returns AppTheme.DEFAULT every { appThemeStateFlow } returns mutableAppThemeFlow + every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedFlow } private val specialCircumstanceManager = SpecialCircumstanceManagerImpl() private val intentManager: IntentManager = mockk { @@ -143,6 +147,26 @@ class MainViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `changes in the allowed screen capture value should result in emissions of ScreenCaptureSettingChange `() = + runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + assertEquals( + MainEvent.ScreenCaptureSettingChange(isAllowed = true), + awaitItem(), + ) + + mutableScreenCaptureAllowedFlow.value = false + assertEquals( + MainEvent.ScreenCaptureSettingChange(isAllowed = false), + awaitItem(), + ) + } + } + private fun createViewModel( initialSpecialCircumstance: SpecialCircumstance? = null, ) = MainViewModel( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt index ea61c3a8f..9afc2c4eb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt @@ -33,6 +33,9 @@ class FakeSettingsDiskSource : SettingsDiskSource { private val mutableIsIconLoadingDisabled = bufferedMutableSharedFlow() + private val mutableScreenCaptureAllowedFlowMap = + mutableMapOf>() + private var storedAppTheme: AppTheme = AppTheme.DEFAULT private val storedLastSyncTime = mutableMapOf() private val storedVaultTimeoutActions = mutableMapOf() @@ -193,11 +196,22 @@ class FakeSettingsDiskSource : SettingsDiskSource { override fun getScreenCaptureAllowed(userId: String): Boolean? = storedScreenCaptureAllowed[userId] + override fun getScreenCaptureAllowedFlow(userId: String): Flow { + return getMutableScreenCaptureAllowedFlow(userId) + } + override fun storeScreenCaptureAllowed( userId: String, isScreenCaptureAllowed: Boolean?, ) { storedScreenCaptureAllowed[userId] = isScreenCaptureAllowed + getMutableScreenCaptureAllowedFlow(userId).tryEmit(isScreenCaptureAllowed) + } + + private fun getMutableScreenCaptureAllowedFlow(userId: String): MutableSharedFlow { + return mutableScreenCaptureAllowedFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } } //region Private helper functions diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index ed420cd6b..e39bf6abc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -32,6 +32,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.time.Instant +@Suppress("LargeClass") class SettingsRepositoryTest { private val autofillManager: AutofillManager = mockk { every { hasEnabledAutofillServices() } answers { isAutofillEnabledAndSupported } @@ -724,6 +725,29 @@ class SettingsRepositoryTest { fakeSettingsDiskSource.getApprovePasswordlessLoginsEnabled(userId = userId), ) } + + @Suppress("MaxLineLength") + @Test + fun `isScreenCaptureAllowed property should update SettingsDiskSource and emit changes`() = runTest { + val userId = "userId" + fakeAuthDiskSource.userState = MOCK_USER_STATE + + fakeSettingsDiskSource.storeScreenCaptureAllowed(userId, false) + + settingsRepository.isScreenCaptureAllowedStateFlow.test { + assertFalse(awaitItem()) + + settingsRepository.isScreenCaptureAllowed = true + assertTrue(awaitItem()) + + assertEquals(true, fakeSettingsDiskSource.getScreenCaptureAllowed(userId)) + + settingsRepository.isScreenCaptureAllowed = false + assertFalse(awaitItem()) + + assertEquals(false, fakeSettingsDiskSource.getScreenCaptureAllowed(userId)) + } + } } private val MOCK_USER_STATE = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModelTest.kt index 495e3c54d..c6c949ebc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/other/OtherViewModelTest.kt @@ -27,11 +27,16 @@ class OtherViewModelTest : BaseViewModelTest() { ) private val mutablePullToRefreshStateFlow = MutableStateFlow(false) private val instant: Instant = Instant.parse("2023-10-26T12:00:00Z") + private val isAllowed: Boolean = false private val mutableVaultLastSyncStateFlow = MutableStateFlow(instant) + private val mutableScreenCaptureAllowedStateFlow = MutableStateFlow(isAllowed) private val settingsRepository = mockk { every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshStateFlow every { vaultLastSyncStateFlow } returns mutableVaultLastSyncStateFlow every { vaultLastSync } returns instant + every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedStateFlow + every { isScreenCaptureAllowed } answers { mutableScreenCaptureAllowedStateFlow.value } + every { isScreenCaptureAllowed = any() } just runs } private val vaultRepository = mockk() @@ -50,18 +55,19 @@ class OtherViewModelTest : BaseViewModelTest() { assertEquals(state, viewModel.stateFlow.value) } + @Suppress("MaxLineLength") @Test - fun `on AllowScreenCaptureToggled should update value in state`() = runTest { + fun `on AllowScreenCaptureToggled should update value in state and SettingsRepository`() = runTest { val viewModel = createViewModel() - viewModel.eventFlow.test { - expectNoEvents() + val newScreenCaptureAllowedValue = true + + viewModel.trySendAction(OtherAction.AllowScreenCaptureToggle(newScreenCaptureAllowedValue)) + + verify(exactly = 1) { + settingsRepository.isScreenCaptureAllowed = newScreenCaptureAllowedValue } + viewModel.stateFlow.test { - assertEquals( - DEFAULT_STATE, - awaitItem(), - ) - viewModel.trySendAction(OtherAction.AllowScreenCaptureToggle(true)) assertEquals( DEFAULT_STATE.copy(allowScreenCapture = true), awaitItem(),