Add support for the pull-to-refresh in settings (#615)

This commit is contained in:
David Perez 2024-01-15 09:41:14 -06:00 committed by Álison Fernandes
parent 82b174168e
commit 561aabc528
10 changed files with 246 additions and 17 deletions

View file

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

View file

@ -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<Boolean?>
/**
* Stores the given [isPullToRefreshEnabled] for the given [userId].
*/
fun storePullToRefreshEnabled(userId: String, isPullToRefreshEnabled: Boolean?)
}

View file

@ -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<String, MutableSharedFlow<Int?>>()
private val mutablePullToRefreshEnabledFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
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<Boolean?> =
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<VaultTimeoutAction?> =
@ -95,4 +111,11 @@ class SettingsDiskSourceImpl(
mutableVaultTimeoutInMinutesFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePullToRefreshEnabledFlowMap(
userId: String,
): MutableSharedFlow<Boolean?> =
mutablePullToRefreshEnabledFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
}

View file

@ -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<Boolean>
/**
* Stores the given [isPullToRefreshEnabled] for the active user.
*/
fun storePullToRefreshEnabled(isPullToRefreshEnabled: Boolean)
}

View file

@ -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<Boolean> {
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,
)
}
}
}
/**

View file

@ -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<OtherState, OtherEvent, OtherAction>(
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) }
}

View file

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

View file

@ -19,9 +19,14 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val mutableVaultTimeoutInMinutesFlowMap =
mutableMapOf<String, MutableSharedFlow<Int?>>()
private val mutablePullToRefreshEnabledFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val storedVaultTimeoutActions = mutableMapOf<String, VaultTimeoutAction?>()
private val storedVaultTimeoutInMinutes = mutableMapOf<String, Int?>()
private val storedPullToRefreshEnabled = mutableMapOf<String, Boolean?>()
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<Boolean?> =
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<Boolean?> =
mutablePullToRefreshEnabledFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
//endregion Private helper functions
}

View file

@ -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<UserStateJson> {
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))
}
}
/**

View file

@ -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<VaultRepository>()
private val mutablePullToRefreshStateFlow = MutableStateFlow(false)
private val settingsRepository = mockk<SettingsRepository> {
every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshStateFlow
}
private val vaultRepository = mockk<VaultRepository>()
@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)