PM-25642: Force sync or clear last sync time on sync notification (#5958)

This commit is contained in:
David Perez 2025-09-29 14:45:56 -05:00 committed by GitHub
parent df63bb4b6c
commit a02a84ee08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 63 additions and 28 deletions

View File

@ -15,9 +15,9 @@ import kotlinx.coroutines.flow.Flow
*/ */
interface PushManager { interface PushManager {
/** /**
* Flow that represents requests intended for full syncs. * Flow that represents requests intended for full syncs for the user ID provided.
*/ */
val fullSyncFlow: Flow<Unit> val fullSyncFlow: Flow<String>
/** /**
* Flow that represents requests intended to log a user out. * Flow that represents requests intended to log a user out.

View File

@ -55,7 +55,7 @@ class PushManagerImpl @Inject constructor(
private val ioScope = CoroutineScope(dispatcherManager.io) private val ioScope = CoroutineScope(dispatcherManager.io)
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val mutableFullSyncSharedFlow = bufferedMutableSharedFlow<Unit>() private val mutableFullSyncSharedFlow = bufferedMutableSharedFlow<String>()
private val mutableLogoutSharedFlow = bufferedMutableSharedFlow<NotificationLogoutData>() private val mutableLogoutSharedFlow = bufferedMutableSharedFlow<NotificationLogoutData>()
private val mutablePasswordlessRequestSharedFlow = private val mutablePasswordlessRequestSharedFlow =
bufferedMutableSharedFlow<PasswordlessRequestData>() bufferedMutableSharedFlow<PasswordlessRequestData>()
@ -73,7 +73,7 @@ class PushManagerImpl @Inject constructor(
private val mutableSyncSendUpsertSharedFlow = private val mutableSyncSendUpsertSharedFlow =
bufferedMutableSharedFlow<SyncSendUpsertData>() bufferedMutableSharedFlow<SyncSendUpsertData>()
override val fullSyncFlow: SharedFlow<Unit> override val fullSyncFlow: SharedFlow<String>
get() = mutableFullSyncSharedFlow.asSharedFlow() get() = mutableFullSyncSharedFlow.asSharedFlow()
override val logoutFlow: SharedFlow<NotificationLogoutData> override val logoutFlow: SharedFlow<NotificationLogoutData>
@ -204,7 +204,10 @@ class PushManagerImpl @Inject constructor(
NotificationType.SYNC_SETTINGS, NotificationType.SYNC_SETTINGS,
NotificationType.SYNC_VAULT, NotificationType.SYNC_VAULT,
-> { -> {
mutableFullSyncSharedFlow.tryEmit(Unit) json
.decodeFromString<NotificationPayload.SyncNotification>(notification.payload)
.userId
?.let { mutableFullSyncSharedFlow.tryEmit(it) }
} }
NotificationType.SYNC_FOLDER_CREATE, NotificationType.SYNC_FOLDER_CREATE,

View File

@ -83,4 +83,12 @@ sealed class NotificationPayload {
@JsonNames("UserId", "userId") override val userId: String?, @JsonNames("UserId", "userId") override val userId: String?,
@JsonNames("Id", "id") val loginRequestId: String?, @JsonNames("Id", "id") val loginRequestId: String?,
) : NotificationPayload() ) : NotificationPayload()
/**
* A notification payload for syncing a users vault.
*/
@Serializable
data class SyncNotification(
@JsonNames("UserId", "userId") override val userId: String?,
) : NotificationPayload()
} }

View File

@ -209,7 +209,13 @@ class VaultSyncManagerImpl(
pushManager pushManager
.fullSyncFlow .fullSyncFlow
.onEach { sync(forced = false) } .onEach { userId ->
if (userId == activeUserId) {
sync(forced = false)
} else {
settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = null)
}
}
.launchIn(unconfinedScope) .launchIn(unconfinedScope)
databaseSchemeManager databaseSchemeManager

View File

@ -205,7 +205,7 @@ class ImportLoginsViewModel @Inject constructor(
private fun syncVault() { private fun syncVault() {
viewModelScope.launch { viewModelScope.launch {
val result = vaultRepository.syncForResult() val result = vaultRepository.syncForResult(forced = true)
sendAction(ImportLoginsAction.Internal.VaultSyncResultReceived(result)) sendAction(ImportLoginsAction.Internal.VaultSyncResultReceived(result))
} }
} }

View File

@ -161,7 +161,7 @@ class PushManagerTest {
pushManager.fullSyncFlow.test { pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_CIPHERS_NOTIFICATION_MAP) pushManager.onMessageReceived(SYNC_CIPHERS_NOTIFICATION_MAP)
assertEquals( assertEquals(
Unit, "078966a2-93c2-4618-ae2a-0a2394c88d37",
awaitItem(), awaitItem(),
) )
} }
@ -180,7 +180,7 @@ class PushManagerTest {
pushManager.fullSyncFlow.test { pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_SETTINGS_NOTIFICATION_MAP) pushManager.onMessageReceived(SYNC_SETTINGS_NOTIFICATION_MAP)
assertEquals( assertEquals(
Unit, "078966a2-93c2-4618-ae2a-0a2394c88d37",
awaitItem(), awaitItem(),
) )
} }
@ -191,7 +191,7 @@ class PushManagerTest {
pushManager.fullSyncFlow.test { pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_VAULT_NOTIFICATION_MAP) pushManager.onMessageReceived(SYNC_VAULT_NOTIFICATION_MAP)
assertEquals( assertEquals(
Unit, "078966a2-93c2-4618-ae2a-0a2394c88d37",
awaitItem(), awaitItem(),
) )
} }
@ -580,7 +580,7 @@ class PushManagerTest {
pushManager.fullSyncFlow.test { pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_CIPHERS_NOTIFICATION_MAP) pushManager.onMessageReceived(SYNC_CIPHERS_NOTIFICATION_MAP)
assertEquals( assertEquals(
Unit, "078966a2-93c2-4618-ae2a-0a2394c88d37",
awaitItem(), awaitItem(),
) )
} }
@ -599,7 +599,7 @@ class PushManagerTest {
pushManager.fullSyncFlow.test { pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_SETTINGS_NOTIFICATION_MAP) pushManager.onMessageReceived(SYNC_SETTINGS_NOTIFICATION_MAP)
assertEquals( assertEquals(
Unit, "078966a2-93c2-4618-ae2a-0a2394c88d37",
awaitItem(), awaitItem(),
) )
} }
@ -610,7 +610,7 @@ class PushManagerTest {
pushManager.fullSyncFlow.test { pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_VAULT_NOTIFICATION_MAP) pushManager.onMessageReceived(SYNC_VAULT_NOTIFICATION_MAP)
assertEquals( assertEquals(
Unit, "078966a2-93c2-4618-ae2a-0a2394c88d37",
awaitItem(), awaitItem(),
) )
} }

View File

@ -126,7 +126,7 @@ class VaultSyncManagerTest {
private val userLogoutManager: UserLogoutManager = mockk { private val userLogoutManager: UserLogoutManager = mockk {
every { softLogout(any(), any()) } just runs every { softLogout(any(), any()) } just runs
} }
private val mutableFullSyncFlow = bufferedMutableSharedFlow<Unit>() private val mutableFullSyncFlow = bufferedMutableSharedFlow<String>()
private val pushManager: PushManager = mockk { private val pushManager: PushManager = mockk {
every { fullSyncFlow } returns mutableFullSyncFlow every { fullSyncFlow } returns mutableFullSyncFlow
} }
@ -1105,15 +1105,27 @@ class VaultSyncManagerTest {
} }
@Test @Test
fun `fullSyncFlow emission should trigger unforced sync`() { fun `fullSyncFlow emission with active user ID should trigger an unforced sync`() {
val userId = "mockId-1" val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE fakeAuthDiskSource.userState = MOCK_USER_STATE
every { settingsDiskSource.getLastSyncTime(userId = userId) } returns null
coEvery { syncService.sync() } just awaits coEvery { syncService.sync() } just awaits
mutableFullSyncFlow.tryEmit(Unit) mutableFullSyncFlow.tryEmit(userId)
coVerify { syncService.sync() } coVerify(exactly = 1) { syncService.sync() }
}
@Suppress("MaxLineLength")
@Test
fun `fullSyncFlow emission with non-active user ID should clear last sync time for that user`() {
val userId = "mockId-2"
fakeAuthDiskSource.userState = MOCK_USER_STATE
mutableFullSyncFlow.tryEmit(userId)
coVerify(exactly = 1) {
settingsDiskSource.storeLastSyncTime(userId = userId, lastSyncTime = null)
}
} }
@Suppress("MaxLineLength") @Suppress("MaxLineLength")

View File

@ -33,7 +33,9 @@ import org.junit.jupiter.api.Test
class ImportLoginsViewModelTest : BaseViewModelTest() { class ImportLoginsViewModelTest : BaseViewModelTest() {
private val vaultRepository: VaultRepository = mockk { private val vaultRepository: VaultRepository = mockk {
coEvery { syncForResult() } returns SyncVaultDataResult.Success(itemsAvailable = true) coEvery {
syncForResult(forced = true)
} returns SyncVaultDataResult.Success(itemsAvailable = true)
} }
private val firstTimeActionManager: FirstTimeActionManager = mockk { private val firstTimeActionManager: FirstTimeActionManager = mockk {
@ -312,21 +314,23 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
) )
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
coVerify { vaultRepository.syncForResult() } coVerify(exactly = 1) { vaultRepository.syncForResult(forced = true) }
} }
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `RetryVaultSync sets isVaultSyncing to true and clears dialog state and calls syncForResult`() = fun `RetryVaultSync sets isVaultSyncing to true and clears dialog state and calls syncForResult`() =
runTest { runTest {
coEvery { vaultRepository.syncForResult() } returns SyncVaultDataResult.Error(Exception()) coEvery {
vaultRepository.syncForResult(forced = true)
} returns SyncVaultDataResult.Error(Exception())
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress) viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress)
viewModel.stateFlow.test { viewModel.stateFlow.test {
assertNotNull(awaitItem().dialogState) assertNotNull(awaitItem().dialogState)
coEvery { vaultRepository.syncForResult() } returns SyncVaultDataResult.Success( coEvery {
itemsAvailable = true, vaultRepository.syncForResult(forced = true)
) } returns SyncVaultDataResult.Success(itemsAvailable = true)
viewModel.trySendAction(ImportLoginsAction.RetryVaultSync) viewModel.trySendAction(ImportLoginsAction.RetryVaultSync)
assertEquals( assertEquals(
ImportLoginsState( ImportLoginsState(
@ -339,7 +343,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
) )
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
coVerify { vaultRepository.syncForResult() } coVerify(exactly = 2) { vaultRepository.syncForResult(forced = true) }
} }
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@ -367,7 +371,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
fun `MoveToSyncInProgress should set no items imported error dialog state when sync succeeds but no items are available`() = fun `MoveToSyncInProgress should set no items imported error dialog state when sync succeeds but no items are available`() =
runTest { runTest {
coEvery { coEvery {
vaultRepository.syncForResult() vaultRepository.syncForResult(forced = true)
} returns SyncVaultDataResult.Success(itemsAvailable = false) } returns SyncVaultDataResult.Success(itemsAvailable = false)
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.stateFlow.test { viewModel.stateFlow.test {
@ -402,7 +406,9 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
@Test @Test
fun `SyncVaultDataResult Error should remove loading state and show error dialog`() = runTest { fun `SyncVaultDataResult Error should remove loading state and show error dialog`() = runTest {
coEvery { vaultRepository.syncForResult() } returns SyncVaultDataResult.Error(Exception()) coEvery {
vaultRepository.syncForResult(forced = true)
} returns SyncVaultDataResult.Error(Exception())
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress) viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress)
assertEquals( assertEquals(
@ -420,7 +426,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
fun `FailSyncAcknowledged should remove dialog state and send NavigateBack event`() = fun `FailSyncAcknowledged should remove dialog state and send NavigateBack event`() =
runTest { runTest {
coEvery { coEvery {
vaultRepository.syncForResult() vaultRepository.syncForResult(forced = true)
} returns SyncVaultDataResult.Error(Exception()) } returns SyncVaultDataResult.Error(Exception())
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.eventFlow.test { viewModel.eventFlow.test {