diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/BaseDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/BaseDiskSource.kt index 10e48aafc8..1b43657834 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/BaseDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/BaseDiskSource.kt @@ -10,6 +10,37 @@ import androidx.core.content.edit abstract class BaseDiskSource( private val sharedPreferences: SharedPreferences, ) { + /** + * Gets the [Boolean] for the given [key] from [SharedPreferences], or return the [default] + * value if that key is not present. + */ + protected fun getBoolean( + key: String, + default: Boolean? = null, + ): Boolean? = + if (sharedPreferences.contains(key)) { + sharedPreferences.getBoolean(key, false) + } else { + // Make sure we can return a null value as a default if necessary + default + } + + /** + * Puts the [value] in [SharedPreferences] for the given [key] (or removes the key when the + * value is `null`). + */ + protected fun putBoolean( + key: String, + value: Boolean?, + ): Unit = + sharedPreferences.edit { + if (value != null) { + putBoolean(key, value) + } else { + remove(key) + } + } + /** * Gets the [Int] for the given [key] from [SharedPreferences], or return the [default] value * if that key is not present. 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 f3be128fef..548861b622 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 @@ -53,4 +53,20 @@ interface SettingsDiskSource { userId: String, vaultTimeoutAction: VaultTimeoutAction?, ) + + /** + * Gets the current state of the pull to refresh feature for the given [userId]. + */ + fun getPullToRefreshEnabled(userId: String): Boolean? + + /** + * Emits updates that track [getPullToRefreshEnabled] for the given [userId]. This will replay + * the last known value, if any. + */ + fun getPullToRefreshEnabledFlow(userId: String): Flow + + /** + * Stores the given [isPullToRefreshEnabled] for the given [userId]. + */ + fun storePullToRefreshEnabled(userId: String, isPullToRefreshEnabled: Boolean?) } 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 1754662e2e..ac659bf56c 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 @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.onSubscription private const val APP_LANGUAGE_KEY = "$BASE_KEY:appLocale" +private const val PULL_TO_REFRESH_KEY = "$BASE_KEY:syncOnRefresh" private const val VAULT_TIMEOUT_ACTION_KEY = "$BASE_KEY:vaultTimeoutAction" private const val VAULT_TIME_IN_MINUTES_KEY = "$BASE_KEY:vaultTimeout" @@ -27,6 +28,9 @@ class SettingsDiskSourceImpl( private val mutableVaultTimeoutInMinutesFlowMap = mutableMapOf>() + private val mutablePullToRefreshEnabledFlowMap = + mutableMapOf>() + override var appLanguage: AppLanguage? get() = getString(key = APP_LANGUAGE_KEY) ?.let { storedValue -> @@ -82,6 +86,18 @@ class SettingsDiskSourceImpl( getMutableVaultTimeoutActionFlow(userId = userId).tryEmit(vaultTimeoutAction) } + override fun getPullToRefreshEnabled(userId: String): Boolean? = + getBoolean(key = "${PULL_TO_REFRESH_KEY}_$userId") + + override fun getPullToRefreshEnabledFlow(userId: String): Flow = + getMutablePullToRefreshEnabledFlowMap(userId = userId) + .onSubscription { emit(getPullToRefreshEnabled(userId = userId)) } + + override fun storePullToRefreshEnabled(userId: String, isPullToRefreshEnabled: Boolean?) { + putBoolean(key = "${PULL_TO_REFRESH_KEY}_$userId", value = isPullToRefreshEnabled) + getMutablePullToRefreshEnabledFlowMap(userId = userId).tryEmit(isPullToRefreshEnabled) + } + private fun getMutableVaultTimeoutActionFlow( userId: String, ): MutableSharedFlow = @@ -95,4 +111,11 @@ class SettingsDiskSourceImpl( mutableVaultTimeoutInMinutesFlowMap.getOrPut(userId) { bufferedMutableSharedFlow(replay = 1) } + + private fun getMutablePullToRefreshEnabledFlowMap( + userId: String, + ): MutableSharedFlow = + mutablePullToRefreshEnabledFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } } 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 dd45c81bc8..d4c971210e 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 @@ -63,4 +63,14 @@ interface SettingsRepository { * Stores the given [VaultTimeoutAction] for the given [userId]. */ fun storeVaultTimeoutAction(userId: String, vaultTimeoutAction: VaultTimeoutAction?) + + /** + * Gets updates for the pull to refresh enabled. + */ + fun getPullToRefreshEnabledFlow(): StateFlow + + /** + * Stores the given [isPullToRefreshEnabled] for the active user. + */ + fun storePullToRefreshEnabled(isPullToRefreshEnabled: Boolean) } 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 3fc67efe04..c8d7ddcc9a 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 @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map @@ -116,6 +117,29 @@ class SettingsRepositoryImpl( vaultTimeoutAction = vaultTimeoutAction, ) } + + override fun getPullToRefreshEnabledFlow(): StateFlow { + val userId = activeUserId ?: return MutableStateFlow(false) + return settingsDiskSource + .getPullToRefreshEnabledFlow(userId = userId) + .map { it ?: false } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = settingsDiskSource + .getPullToRefreshEnabled(userId = userId) + ?: false, + ) + } + + override fun storePullToRefreshEnabled(isPullToRefreshEnabled: Boolean) { + activeUserId?.let { + settingsDiskSource.storePullToRefreshEnabled( + userId = it, + isPullToRefreshEnabled = isPullToRefreshEnabled, + ) + } + } } /** 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 7b089472c3..dd170efb60 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 @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.other import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text @@ -19,13 +20,14 @@ private const val KEY_STATE = "state" */ @HiltViewModel class OtherViewModel @Inject constructor( + private val settingsRepo: SettingsRepository, private val vaultRepo: VaultRepository, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: OtherState( allowScreenCapture = false, - allowSyncOnRefresh = false, + allowSyncOnRefresh = settingsRepo.getPullToRefreshEnabledFlow().value, clearClipboardFrequency = OtherState.ClearClipboardFrequency.DEFAULT, lastSyncTime = "5/14/2023 4:52 PM", ), @@ -44,7 +46,7 @@ class OtherViewModel @Inject constructor( } private fun handleAllowSyncToggled(action: OtherAction.AllowSyncToggle) { - // TODO BIT-461 hook up to pull-to-refresh feature + settingsRepo.storePullToRefreshEnabled(action.isSyncEnabled) mutableStateFlow.update { it.copy(allowSyncOnRefresh = action.isSyncEnabled) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt index 28c4df1c2d..fb3b38ac27 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt @@ -232,4 +232,65 @@ class SettingsDiskSourceTest { ) assertNull(fakeSharedPreferences.getString(vaultTimeoutActionKey, null)) } + + @Test + fun `storePullToRefreshEnabled when values are present should pull from SharedPreferences`() { + val pullToRefreshBaseKey = "bwPreferencesStorage:syncOnRefresh" + val mockUserId = "mockUserId" + val pullToRefreshKey = "${pullToRefreshBaseKey}_$mockUserId" + fakeSharedPreferences + .edit() + .putBoolean(pullToRefreshKey, true) + .apply() + assertEquals(true, settingsDiskSource.getPullToRefreshEnabled(userId = mockUserId)) + } + + @Test + fun `storePullToRefreshEnabled when values are absent should return null`() { + val mockUserId = "mockUserId" + assertNull(settingsDiskSource.getPullToRefreshEnabled(userId = mockUserId)) + } + + @Test + fun `getPullToRefreshEnabledFlow should react to changes in getPullToRefreshEnabled`() = + runTest { + val mockUserId = "mockUserId" + settingsDiskSource.getPullToRefreshEnabledFlow(userId = mockUserId).test { + // The initial values of the Flow and the property are in sync + assertNull(settingsDiskSource.getPullToRefreshEnabled(userId = mockUserId)) + assertNull(awaitItem()) + + // Updating the disk source updates shared preferences + settingsDiskSource.storePullToRefreshEnabled( + userId = mockUserId, + isPullToRefreshEnabled = true, + ) + assertEquals(true, awaitItem()) + } + } + + @Test + fun `storePullToRefreshEnabled for non-null values should update SharedPreferences`() { + val pullToRefreshBaseKey = "bwPreferencesStorage:syncOnRefresh" + val mockUserId = "mockUserId" + val pullToRefreshKey = "${pullToRefreshBaseKey}_$mockUserId" + settingsDiskSource.storePullToRefreshEnabled( + userId = mockUserId, + isPullToRefreshEnabled = true, + ) + assertTrue(fakeSharedPreferences.getBoolean(pullToRefreshKey, false)) + } + + @Test + fun `storePullToRefreshEnabled for null values should clear SharedPreferences`() { + val pullToRefreshBaseKey = "bwPreferencesStorage:syncOnRefresh" + val mockUserId = "mockUserId" + val pullToRefreshKey = "${pullToRefreshBaseKey}_$mockUserId" + fakeSharedPreferences.edit { putBoolean(pullToRefreshKey, false) } + settingsDiskSource.storePullToRefreshEnabled( + userId = mockUserId, + isPullToRefreshEnabled = null, + ) + assertFalse(fakeSharedPreferences.contains(pullToRefreshKey)) + } } 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 c98fa13a7a..e3e8bf943b 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 @@ -19,9 +19,14 @@ class FakeSettingsDiskSource : SettingsDiskSource { private val mutableVaultTimeoutInMinutesFlowMap = mutableMapOf>() + private val mutablePullToRefreshEnabledFlowMap = + mutableMapOf>() + private val storedVaultTimeoutActions = mutableMapOf() private val storedVaultTimeoutInMinutes = mutableMapOf() + private val storedPullToRefreshEnabled = mutableMapOf() + override var appLanguage: AppLanguage? = null override fun clearData(userId: String) { @@ -62,6 +67,18 @@ class FakeSettingsDiskSource : SettingsDiskSource { getMutableVaultTimeoutActionsFlow(userId = userId).tryEmit(vaultTimeoutAction) } + override fun getPullToRefreshEnabled(userId: String): Boolean? = + storedPullToRefreshEnabled[userId] + + override fun getPullToRefreshEnabledFlow(userId: String): Flow = + getMutablePullToRefreshEnabledFlow(userId = userId) + .onSubscription { emit(getPullToRefreshEnabled(userId = userId)) } + + override fun storePullToRefreshEnabled(userId: String, isPullToRefreshEnabled: Boolean?) { + storedPullToRefreshEnabled[userId] = isPullToRefreshEnabled + getMutablePullToRefreshEnabledFlow(userId = userId).tryEmit(isPullToRefreshEnabled) + } + //region Private helper functions private fun getMutableVaultTimeoutActionsFlow( @@ -78,5 +95,12 @@ class FakeSettingsDiskSource : SettingsDiskSource { bufferedMutableSharedFlow(replay = 1) } + private fun getMutablePullToRefreshEnabledFlow( + userId: String, + ): MutableSharedFlow = + mutablePullToRefreshEnabledFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + //endregion 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 3ee7cc1a6f..858341e2c9 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 @@ -2,11 +2,13 @@ package com.x8bit.bitwarden.data.platform.repository import app.cash.turbine.test import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -271,6 +273,38 @@ class SettingsRepositoryTest { ) } } + + @Test + fun `getPullToRefreshEnabledFlow should react to changes in SettingsDiskSource`() = runTest { + val userId = "userId" + val userState = mockk { + every { activeUserId } returns userId + } + coEvery { authDiskSource.userState } returns userState + settingsRepository + .getPullToRefreshEnabledFlow() + .test { + assertFalse(awaitItem()) + fakeSettingsDiskSource.storePullToRefreshEnabled( + userId = userId, + isPullToRefreshEnabled = true, + ) + assertTrue(awaitItem()) + fakeSettingsDiskSource.storePullToRefreshEnabled( + userId = userId, + isPullToRefreshEnabled = false, + ) + assertFalse(awaitItem()) + } + } + + @Test + fun `storePullToRefreshEnabled should properly update SettingsDiskSource`() { + val userId = "userId" + every { authDiskSource.userState?.activeUserId } returns userId + settingsRepository.storePullToRefreshEnabled(true) + assertEquals(true, fakeSettingsDiskSource.getPullToRefreshEnabled(userId = userId)) + } } /** 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 01b60a4e82..18ad2e5848 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 @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.other import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.every @@ -9,12 +10,17 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs 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.Test class OtherViewModelTest : BaseViewModelTest() { - val vaultRepository = mockk() + private val mutablePullToRefreshStateFlow = MutableStateFlow(false) + private val settingsRepository = mockk { + every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshStateFlow + } + private val vaultRepository = mockk() @Test fun `initial state should be correct when not set`() { @@ -51,21 +57,18 @@ class OtherViewModelTest : BaseViewModelTest() { } @Test - fun `on AllowSyncToggled should update value in state`() = runTest { + fun `on AllowSyncToggled should update value in state`() { + every { + settingsRepository.storePullToRefreshEnabled(isPullToRefreshEnabled = true) + } just runs val viewModel = createViewModel() - viewModel.eventFlow.test { - expectNoEvents() - } - viewModel.stateFlow.test { - assertEquals( - DEFAULT_STATE, - awaitItem(), - ) - viewModel.trySendAction(OtherAction.AllowSyncToggle(true)) - assertEquals( - DEFAULT_STATE.copy(allowSyncOnRefresh = true), - awaitItem(), - ) + viewModel.trySendAction(OtherAction.AllowSyncToggle(true)) + assertEquals( + DEFAULT_STATE.copy(allowSyncOnRefresh = true), + viewModel.stateFlow.value, + ) + verify(exactly = 1) { + settingsRepository.storePullToRefreshEnabled(isPullToRefreshEnabled = true) } } @@ -114,6 +117,7 @@ class OtherViewModelTest : BaseViewModelTest() { private fun createViewModel( state: OtherState? = null, ) = OtherViewModel( + settingsRepo = settingsRepository, vaultRepo = vaultRepository, savedStateHandle = SavedStateHandle().apply { set("state", state)