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 2315b5ee1..e36973874 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 @@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import kotlinx.coroutines.flow.Flow +import java.time.Instant /** * Primary access point for general settings-related disk information. @@ -41,6 +42,23 @@ interface SettingsDiskSource { */ fun clearData(userId: String) + /** + * Gets the last time the app synced the vault data for a given [userId] (or `null` if the + * vault has never been synced). + */ + fun getLastSyncTime(userId: String): Instant? + + /** + * Emits updates that track [getLastSyncTime] for the given [userId]. This will replay the + * last known value, if any. + */ + fun getLastSyncTimeFlow(userId: String): Flow + + /** + * Stores the given [lastSyncTime] for the given [userId]. + */ + fun storeLastSyncTime(userId: String, lastSyncTime: Instant?) + /** * Gets the current vault timeout (in minutes) for the given [userId] (or `null` if the vault * should never time out). 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 1216a0c8b..6ce5c5b71 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 @@ -11,12 +11,14 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.onSubscription import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import java.time.Instant private const val APP_LANGUAGE_KEY = "$BASE_KEY:appLocale" private const val APP_THEME_KEY = "$BASE_KEY:theme" private const val PULL_TO_REFRESH_KEY = "$BASE_KEY:syncOnRefresh" private const val INLINE_AUTOFILL_ENABLED_KEY = "$BASE_KEY:inlineAutofillEnabled" private const val BLOCKED_AUTOFILL_URIS_KEY = "$BASE_KEY:autofillBlacklistedUris" +private const val VAULT_LAST_SYNC_TIME = "$BASE_KEY:vaultLastSyncTime" private const val VAULT_TIMEOUT_ACTION_KEY = "$BASE_KEY:vaultTimeoutAction" private const val VAULT_TIME_IN_MINUTES_KEY = "$BASE_KEY:vaultTimeout" private const val DISABLE_ICON_LOADING_KEY = "$BASE_KEY:disableFavicon" @@ -34,6 +36,8 @@ class SettingsDiskSourceImpl( private val mutableAppThemeFlow = bufferedMutableSharedFlow(replay = 1) + private val mutableLastSyncFlowMap = mutableMapOf>() + private val mutableVaultTimeoutActionFlowMap = mutableMapOf>() @@ -97,8 +101,24 @@ class SettingsDiskSourceImpl( userId = userId, isApprovePasswordlessLoginsEnabled = null, ) + storeLastSyncTime(userId = userId, lastSyncTime = null) } + override fun getLastSyncTime(userId: String): Instant? = + getLong(key = "${VAULT_LAST_SYNC_TIME}_$userId")?.let { Instant.ofEpochMilli(it) } + + override fun storeLastSyncTime(userId: String, lastSyncTime: Instant?) { + putLong( + key = "${VAULT_LAST_SYNC_TIME}_$userId", + value = lastSyncTime?.toEpochMilli(), + ) + getMutableLastSyncFlow(userId = userId).tryEmit(lastSyncTime) + } + + override fun getLastSyncTimeFlow(userId: String): Flow = + getMutableLastSyncFlow(userId = userId) + .onSubscription { emit(getLastSyncTime(userId = userId)) } + override fun getVaultTimeoutInMinutes(userId: String): Int? = getInt(key = "${VAULT_TIME_IN_MINUTES_KEY}_$userId") @@ -177,6 +197,13 @@ class SettingsDiskSourceImpl( ) } + private fun getMutableLastSyncFlow( + userId: String, + ): MutableSharedFlow = + mutableLastSyncFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + private fun getMutableVaultTimeoutActionFlow( userId: String, ): MutableSharedFlow = 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 fd42e3fd1..ceee21f1a 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 @@ -13,6 +13,7 @@ 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 +import java.time.Instant class SettingsDiskSourceTest { private val fakeSharedPreferences = FakeSharedPreferences() @@ -89,6 +90,10 @@ class SettingsDiskSourceTest { userId = userId, isApprovePasswordlessLoginsEnabled = true, ) + settingsDiskSource.storeLastSyncTime( + userId = userId, + lastSyncTime = Instant.parse("2023-10-27T12:00:00Z"), + ) settingsDiskSource.clearData(userId = userId) @@ -98,6 +103,29 @@ class SettingsDiskSourceTest { assertNull(settingsDiskSource.getInlineAutofillEnabled(userId = userId)) assertNull(settingsDiskSource.getBlockedAutofillUris(userId = userId)) assertNull(settingsDiskSource.getApprovePasswordlessLoginsEnabled(userId = userId)) + assertNull(settingsDiskSource.getLastSyncTime(userId = userId)) + } + + @Test + fun `getLastSyncTime should pull from and update SharedPreferences`() { + val userId = "userId-1234" + val lastVaultSync = "bwPreferencesStorage:vaultLastSyncTime_$userId" + val instantLong = 1_698_408_000_000L + val instant = Instant.ofEpochMilli(instantLong) + + // Assert that the default value in disk source is null + assertNull(settingsDiskSource.getLastSyncTime(userId = userId)) + + // Updating the shared preferences should update disk source. + fakeSharedPreferences.edit { putLong(lastVaultSync, instantLong) } + assertEquals(instant, settingsDiskSource.getLastSyncTime(userId = userId)) + + // Updating the disk source updates the shared preferences + settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = instant) + assertEquals( + fakeSharedPreferences.getLong(lastVaultSync, 0L), + instantLong, + ) } @Test 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 66c557ec1..5f1d63d24 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 @@ -8,6 +8,7 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppThem import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.onSubscription +import java.time.Instant /** * Fake, memory-based implementation of [SettingsDiskSource]. @@ -17,6 +18,8 @@ class FakeSettingsDiskSource : SettingsDiskSource { private val mutableAppThemeFlow = bufferedMutableSharedFlow(replay = 1) + private val mutableLastSyncCallFlowMap = mutableMapOf>() + private val mutableVaultTimeoutActionsFlowMap = mutableMapOf>() @@ -30,6 +33,7 @@ class FakeSettingsDiskSource : SettingsDiskSource { bufferedMutableSharedFlow() private var storedAppTheme: AppTheme = AppTheme.DEFAULT + private val storedLastSyncTime = mutableMapOf() private val storedVaultTimeoutActions = mutableMapOf() private val storedVaultTimeoutInMinutes = mutableMapOf() @@ -76,6 +80,18 @@ class FakeSettingsDiskSource : SettingsDiskSource { mutableVaultTimeoutActionsFlowMap.remove(userId) mutableVaultTimeoutInMinutesFlowMap.remove(userId) + mutableLastSyncCallFlowMap.remove(userId) + } + + override fun getLastSyncTime(userId: String): Instant? = storedLastSyncTime[userId] + + override fun getLastSyncTimeFlow(userId: String): Flow = + getMutableLastSyncTimeFlow(userId = userId) + .onSubscription { emit(getLastSyncTime(userId = userId)) } + + override fun storeLastSyncTime(userId: String, lastSyncTime: Instant?) { + storedLastSyncTime[userId] = lastSyncTime + getMutableLastSyncTimeFlow(userId = userId).tryEmit(lastSyncTime) } override fun getVaultTimeoutInMinutes(userId: String): Int? = @@ -152,6 +168,13 @@ class FakeSettingsDiskSource : SettingsDiskSource { //region Private helper functions + private fun getMutableLastSyncTimeFlow( + userId: String, + ): MutableSharedFlow = + mutableLastSyncCallFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + private fun getMutableVaultTimeoutActionsFlow( userId: String, ): MutableSharedFlow =