From 54c288cb258c15dfb65b92baabe03034f61cf4e1 Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Sun, 7 Jan 2024 11:21:34 -0600 Subject: [PATCH] Add disk storage for Vault Timeout (#518) --- .../datasource/disk/BaseDiskSource.kt | 31 ++++++ .../datasource/disk/SettingsDiskSource.kt | 25 +++++ .../datasource/disk/SettingsDiskSourceImpl.kt | 46 +++++++++ .../datasource/disk/SettingsDiskSourceTest.kt | 98 +++++++++++++++++++ .../disk/util/FakeSettingsDiskSource.kt | 44 +++++++++ 5 files changed, 244 insertions(+) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt 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 4283a9ed14..2741d01fc6 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 [Int] for the given [key] from [SharedPreferences], or return the [default] value + * if that key is not present. + */ + protected fun getInt( + key: String, + default: Int? = null, + ): Int? = + if (sharedPreferences.contains(key)) { + sharedPreferences.getInt(key, 0) + } 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 putInt( + key: String, + value: Int?, + ): Unit = + sharedPreferences.edit { + if (value != null) { + putInt(key, value) + } else { + remove(key) + } + } + protected fun getString( key: String, default: String? = null, 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 new file mode 100644 index 0000000000..bc6a1d0def --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import kotlinx.coroutines.flow.Flow + +/** + * Primary access point for general settings-related disk information. + */ +interface SettingsDiskSource { + /** + * Gets the current vault timeout (in minutes) for the given [userId] (or `null` if the vault + * should never time out). + */ + fun getVaultTimeoutInMinutes(userId: String): Int? + + /** + * Emits updates that track [getVaultTimeoutInMinutes] for the given [userId]. This will replay + * the last known value, if any. + */ + fun getVaultTimeoutInMinutesFlow(userId: String): Flow + + /** + * Stores the given [vaultTimeoutInMinutes] for the given [userId]. + */ + fun storeVaultTimeoutInMinutes(userId: String, vaultTimeoutInMinutes: Int?) +} 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 new file mode 100644 index 0000000000..f48febdb55 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -0,0 +1,46 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import android.content.SharedPreferences +import com.x8bit.bitwarden.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.onSubscription + +private const val VAULT_TIME_IN_MINUTES_KEY = "$BASE_KEY:vaultTimeout" + +/** + * Primary implementation of [SettingsDiskSource]. + */ +class SettingsDiskSourceImpl( + val sharedPreferences: SharedPreferences, +) : BaseDiskSource(sharedPreferences = sharedPreferences), + SettingsDiskSource { + private val mutableVaultTimeoutInMinutesFlowMap = + mutableMapOf>() + + override fun getVaultTimeoutInMinutes(userId: String): Int? = + getInt(key = "${VAULT_TIME_IN_MINUTES_KEY}_$userId") + + override fun getVaultTimeoutInMinutesFlow(userId: String): Flow = + getMutableVaultTimeoutInMinutesFlow(userId = userId) + .onSubscription { emit(getVaultTimeoutInMinutes(userId = userId)) } + + override fun storeVaultTimeoutInMinutes( + userId: String, + vaultTimeoutInMinutes: Int?, + ) { + putInt( + key = "${VAULT_TIME_IN_MINUTES_KEY}_$userId", + value = vaultTimeoutInMinutes, + ) + getMutableVaultTimeoutInMinutesFlow(userId = userId).tryEmit(vaultTimeoutInMinutes) + } + + private fun getMutableVaultTimeoutInMinutesFlow( + userId: String, + ): MutableSharedFlow = + mutableVaultTimeoutInMinutesFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } +} 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 new file mode 100644 index 0000000000..b2558aa732 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt @@ -0,0 +1,98 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import androidx.core.content.edit +import app.cash.turbine.test +import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SettingsDiskSourceTest { + private val fakeSharedPreferences = FakeSharedPreferences() + + private val settingsDiskSource = SettingsDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + ) + + @Test + fun `getVaultTimeoutInMinutes when values are present should pull from SharedPreferences`() { + val vaultTimeoutBaseKey = "bwPreferencesStorage:vaultTimeout" + val mockUserId = "mockUserId" + val vaultTimeoutInMinutes = 360 + fakeSharedPreferences + .edit() + .putInt( + "${vaultTimeoutBaseKey}_$mockUserId", + vaultTimeoutInMinutes, + ) + .apply() + val actual = settingsDiskSource.getVaultTimeoutInMinutes(userId = mockUserId) + assertEquals( + vaultTimeoutInMinutes, + actual, + ) + } + + @Test + fun `getVaultTimeoutInMinutes when values are absent should return null`() { + val mockUserId = "mockUserId" + assertNull(settingsDiskSource.getVaultTimeoutInMinutes(userId = mockUserId)) + } + + @Test + fun `getVaultTimeoutInMinutesFlow should react to changes in getOrganizations`() = runTest { + val mockUserId = "mockUserId" + val vaultTimeoutInMinutes = 360 + settingsDiskSource.getVaultTimeoutInMinutesFlow(userId = mockUserId).test { + // The initial values of the Flow and the property are in sync + assertNull(settingsDiskSource.getVaultTimeoutInMinutes(userId = mockUserId)) + assertNull(awaitItem()) + + // Updating the repository updates shared preferences + settingsDiskSource.storeVaultTimeoutInMinutes( + userId = mockUserId, + vaultTimeoutInMinutes = vaultTimeoutInMinutes, + ) + assertEquals(vaultTimeoutInMinutes, awaitItem()) + } + } + + @Test + fun `storeVaultTimeoutInMinutes for non-null values should update SharedPreferences`() { + val vaultTimeoutBaseKey = "bwPreferencesStorage:vaultTimeout" + val mockUserId = "mockUserId" + val vaultTimeoutInMinutes = 360 + settingsDiskSource.storeVaultTimeoutInMinutes( + userId = mockUserId, + vaultTimeoutInMinutes = vaultTimeoutInMinutes, + ) + val actual = fakeSharedPreferences.getInt( + "${vaultTimeoutBaseKey}_$mockUserId", + 0, + ) + assertEquals( + vaultTimeoutInMinutes, + actual, + ) + } + + @Test + fun `storeVaultTimeoutInMinutes for null values should clear SharedPreferences`() { + val vaultTimeoutBaseKey = "bwPreferencesStorage:vaultTimeout" + val mockUserId = "mockUserId" + val previousValue = 123 + val vaultTimeoutKey = "${vaultTimeoutBaseKey}_$mockUserId" + fakeSharedPreferences.edit { + putInt(vaultTimeoutKey, previousValue) + } + assertTrue(fakeSharedPreferences.contains(vaultTimeoutKey)) + settingsDiskSource.storeVaultTimeoutInMinutes( + userId = mockUserId, + vaultTimeoutInMinutes = null, + ) + assertFalse(fakeSharedPreferences.contains(vaultTimeoutKey)) + } +} 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 new file mode 100644 index 0000000000..54d36d6de2 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt @@ -0,0 +1,44 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk.util + +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.onSubscription + +/** + * Fake, memory-based implementation of [SettingsDiskSource]. + */ +class FakeSettingsDiskSource : SettingsDiskSource { + + private val mutableVaultTimeoutInMinutesFlowMap = + mutableMapOf>() + + private val storedVaultTimeoutInMinutes = mutableMapOf() + + override fun getVaultTimeoutInMinutes(userId: String): Int? = + storedVaultTimeoutInMinutes[userId] + + override fun getVaultTimeoutInMinutesFlow(userId: String): Flow = + getMutableVaultTimeoutInMinutesFlow(userId = userId) + .onSubscription { emit(getVaultTimeoutInMinutes(userId = userId)) } + + override fun storeVaultTimeoutInMinutes( + userId: String, + vaultTimeoutInMinutes: Int?, + ) { + storedVaultTimeoutInMinutes[userId] = vaultTimeoutInMinutes + getMutableVaultTimeoutInMinutesFlow(userId = userId).tryEmit(vaultTimeoutInMinutes) + } + + //region Private helper functions + + private fun getMutableVaultTimeoutInMinutesFlow( + userId: String, + ): MutableSharedFlow = + mutableVaultTimeoutInMinutesFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + + //endregion Private helper functions +}