diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt index e39ad6c02..9c4329a65 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt @@ -77,6 +77,12 @@ interface VaultDiskSource { */ suspend fun replaceVaultData(userId: String, vault: SyncResponseJson) + /** + * Trigger re-emissions from the [getCiphers], [getCollections], [getFolders], and [getSends] + * functions. + */ + suspend fun resyncVaultData(userId: String) + /** * Deletes all stored vault data from the data source for a given [userId]. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt index d11d97214..1ba3a7405 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch @@ -264,6 +265,20 @@ class VaultDiskSourceImpl( } } + override suspend fun resyncVaultData(userId: String) { + coroutineScope { + val deferredCiphers = async { getCiphers(userId = userId).first() } + val deferredCollections = async { getCollections(userId = userId).first() } + val deferredFolders = async { getFolders(userId = userId).first() } + val deferredSends = async { getSends(userId = userId).first() } + + forceCiphersFlow.tryEmit(deferredCiphers.await()) + forceCollectionsFlow.tryEmit(deferredCollections.await()) + forceFolderFlow.tryEmit(deferredFolders.await()) + forceSendFlow.tryEmit(deferredSends.await()) + } + } + override suspend fun deleteVaultData(userId: String) { coroutineScope { val deferredCiphers = async { ciphersDao.deleteAllCiphers(userId = userId) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/SyncApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/SyncApi.kt index 88349303c..1f1c7ece7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/SyncApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/SyncApi.kt @@ -14,4 +14,7 @@ interface SyncApi { */ @GET("sync") suspend fun sync(): Result + + @GET("/accounts/revision-date") + suspend fun getAccountRevisionDateMillis(): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncService.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncService.kt index b39c97fdb..003512b22 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncService.kt @@ -10,4 +10,10 @@ interface SyncService { * Make sync request to get vault items. */ suspend fun sync(): Result + + /** + * Make a request to get the most recent revision date for the account that is returned as an + * epoch time in milliseconds. + */ + suspend fun getAccountRevisionDateMillis(): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceImpl.kt index 60adf59ee..57e478dd7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceImpl.kt @@ -7,4 +7,7 @@ class SyncServiceImpl( private val syncApi: SyncApi, ) : SyncService { override suspend fun sync(): Result = syncApi.sync() + + override suspend fun getAccountRevisionDateMillis(): Result = + syncApi.getAccountRevisionDateMillis() } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 1a8e8c9fb..0d0be17cc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -317,6 +317,27 @@ class VaultRepositoryImpl( mutableCollectionsStateFlow.updateToPendingOrLoading() mutableSendDataStateFlow.updateToPendingOrLoading() syncJob = ioScope.launch { + val lastSyncInstant = settingsDiskSource + .getLastSyncTime(userId = userId) + ?.toEpochMilli() + ?: 0 + + syncService + .getAccountRevisionDateMillis() + .fold( + onSuccess = { serverRevisionDate -> + if (serverRevisionDate < lastSyncInstant) { + // We can skip the actual sync call if there is no new data + vaultDiskSource.resyncVaultData(userId) + return@launch + } + }, + onFailure = { + updateVaultStateFlowsToError(it) + return@launch + }, + ) + syncService .sync() .fold( @@ -350,31 +371,7 @@ class VaultRepositoryImpl( settingsDiskSource.storeLastSyncTime(userId = userId, clock.instant()) }, onFailure = { throwable -> - mutableCiphersStateFlow.update { currentState -> - throwable.toNetworkOrErrorState( - data = currentState.data, - ) - } - mutableDomainsStateFlow.update { currentState -> - throwable.toNetworkOrErrorState( - data = currentState.data, - ) - } - mutableFoldersStateFlow.update { currentState -> - throwable.toNetworkOrErrorState( - data = currentState.data, - ) - } - mutableCollectionsStateFlow.update { currentState -> - throwable.toNetworkOrErrorState( - data = currentState.data, - ) - } - mutableSendDataStateFlow.update { currentState -> - throwable.toNetworkOrErrorState( - data = currentState.data, - ) - } + updateVaultStateFlowsToError(throwable) }, ) } @@ -1407,6 +1404,34 @@ class VaultRepositoryImpl( } .onEach { mutableSendDataStateFlow.value = it } + private fun updateVaultStateFlowsToError(throwable: Throwable) { + mutableCiphersStateFlow.update { currentState -> + throwable.toNetworkOrErrorState( + data = currentState.data, + ) + } + mutableDomainsStateFlow.update { currentState -> + throwable.toNetworkOrErrorState( + data = currentState.data, + ) + } + mutableFoldersStateFlow.update { currentState -> + throwable.toNetworkOrErrorState( + data = currentState.data, + ) + } + mutableCollectionsStateFlow.update { currentState -> + throwable.toNetworkOrErrorState( + data = currentState.data, + ) + } + mutableSendDataStateFlow.update { currentState -> + throwable.toNetworkOrErrorState( + data = currentState.data, + ) + } + } + //region Push notification helpers /** * Deletes the cipher specified by [syncCipherDeleteData] from disk. diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceTest.kt index 988abd2f1..54848e595 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceTest.kt @@ -22,8 +22,21 @@ class SyncServiceTest : BaseServiceTest() { val result = syncService.sync() assertEquals(createMockSyncResponse(number = 1), result.getOrThrow()) } + + @Test + fun `revision date should return the correct response`() = runTest { + server.enqueue(MockResponse().setBody(REVISION_DATE_SUCCESS_JSON)) + val result = syncService.getAccountRevisionDateMillis() + assertEquals( + REVISION_DATE_MILLISECONDS, + result.getOrThrow(), + ) + } } +private const val REVISION_DATE_MILLISECONDS = 1707847670747L +private const val REVISION_DATE_SUCCESS_JSON = """1707847670747""" + private const val SYNC_SUCCESS_JSON = """ { "profile": { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index 5da0fbdb6..dbc7fb130 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -156,12 +156,22 @@ class VaultRepositoryTest { } private val fileManager: FileManager = mockk() private val fakeAuthDiskSource = FakeAuthDiskSource() - private val settingsDiskSource = mockk() - private val syncService: SyncService = mockk() + private val settingsDiskSource = mockk { + every { + getLastSyncTime(userId = any()) + } returns clock.instant() + } + private val syncService: SyncService = mockk { + coEvery { + getAccountRevisionDateMillis() + } returns clock.instant().plus(1, ChronoUnit.MINUTES).toEpochMilli().asSuccess() + } private val sendsService: SendsService = mockk() private val ciphersService: CiphersService = mockk() private val folderService: FolderService = mockk() - private val vaultDiskSource: VaultDiskSource = mockk() + private val vaultDiskSource: VaultDiskSource = mockk { + coEvery { resyncVaultData(any()) } just runs + } private val totpCodeManager: TotpCodeManager = mockk() private val vaultSdkSource: VaultSdkSource = mockk { every { clearCrypto(userId = any()) } just runs @@ -257,6 +267,7 @@ class VaultRepositoryTest { vaultRepository.sync() coVerify(exactly = 2) { // A second sync should have happened now since it was cancelled by the userState change + syncService.getAccountRevisionDateMillis() syncService.sync() } } @@ -933,6 +944,35 @@ class VaultRepositoryTest { coVerify(exactly = 0) { syncService.sync() } } + @Test + fun `sync when the last sync time is older than the revision date should sync the vault`() { + val userId = "mockId-1" + fakeAuthDiskSource.userState = MOCK_USER_STATE + every { + settingsDiskSource.getLastSyncTime(userId = userId) + } returns clock.instant().minus(1, ChronoUnit.MINUTES) + + coEvery { syncService.sync() } just awaits + + vaultRepository.sync() + + coVerify { syncService.sync() } + } + + @Test + fun `sync when the last sync time is more recent than the revision date should not sync `() { + val userId = "mockId-1" + fakeAuthDiskSource.userState = MOCK_USER_STATE + coEvery { syncService.sync() } just awaits + every { + settingsDiskSource.getLastSyncTime(userId = userId) + } returns clock.instant().plus(2, ChronoUnit.MINUTES) + + vaultRepository.sync() + + coVerify(exactly = 0) { syncService.sync() } + } + @Test fun `lockVaultForCurrentUser should delegate to the VaultLockManager`() { vaultRepository.lockVaultForCurrentUser()