BIT-805: Screen capture toggle setting implementation (#788)

This commit is contained in:
Joshua Queen 2024-01-26 11:26:17 -05:00 committed by Álison Fernandes
parent c765de99f1
commit bc834fee93
11 changed files with 189 additions and 11 deletions

View file

@ -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)
}
}
}

View file

@ -28,7 +28,7 @@ class MainViewModel @Inject constructor(
private val intentManager: IntentManager,
settingsRepository: SettingsRepository,
private val savedStateHandle: SavedStateHandle,
) : BaseViewModel<MainState, Unit, MainAction>(
) : BaseViewModel<MainState, MainEvent, MainAction>(
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()
}

View file

@ -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<Boolean?>
/**
* Stores whether or not [isScreenCaptureAllowed] for the given [userId].
*/

View file

@ -54,6 +54,9 @@ class SettingsDiskSourceImpl(
private val mutableIsIconLoadingDisabledFlow =
bufferedMutableSharedFlow<Boolean?>()
private val mutableScreenCaptureAllowedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
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<Boolean?> =
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<Boolean?> =
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)
}
}

View file

@ -98,6 +98,16 @@ interface SettingsRepository {
*/
val isAutofillEnabledStateFlow: StateFlow<Boolean>
/**
* 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<Boolean>
/**
* Disables autofill if it is currently enabled.
*/

View file

@ -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<Boolean> =
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<Boolean>
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()
}

View file

@ -36,7 +36,7 @@ class OtherViewModel @Inject constructor(
) : BaseViewModel<OtherState, OtherEvent, OtherAction>(
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) }
}

View file

@ -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<UserState?>(DEFAULT_USER_STATE)
private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true)
val authRepository = mockk<AuthRepository> {
every { userStateFlow } returns mutableUserStateFlow
every { activeUserId } returns USER_ID
@ -31,6 +34,7 @@ class MainViewModelTest : BaseViewModelTest() {
private val settingsRepository = mockk<SettingsRepository> {
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(

View file

@ -33,6 +33,9 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val mutableIsIconLoadingDisabled =
bufferedMutableSharedFlow<Boolean?>()
private val mutableScreenCaptureAllowedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private var storedAppTheme: AppTheme = AppTheme.DEFAULT
private val storedLastSyncTime = mutableMapOf<String, Instant?>()
private val storedVaultTimeoutActions = mutableMapOf<String, VaultTimeoutAction?>()
@ -193,11 +196,22 @@ class FakeSettingsDiskSource : SettingsDiskSource {
override fun getScreenCaptureAllowed(userId: String): Boolean? =
storedScreenCaptureAllowed[userId]
override fun getScreenCaptureAllowedFlow(userId: String): Flow<Boolean?> {
return getMutableScreenCaptureAllowedFlow(userId)
}
override fun storeScreenCaptureAllowed(
userId: String,
isScreenCaptureAllowed: Boolean?,
) {
storedScreenCaptureAllowed[userId] = isScreenCaptureAllowed
getMutableScreenCaptureAllowedFlow(userId).tryEmit(isScreenCaptureAllowed)
}
private fun getMutableScreenCaptureAllowedFlow(userId: String): MutableSharedFlow<Boolean?> {
return mutableScreenCaptureAllowedFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
}
//region Private helper functions

View file

@ -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 =

View file

@ -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<SettingsRepository> {
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<VaultRepository>()
@ -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(),