Skip to content

Commit

Permalink
Add tests for SyncObserver, make sure we can stop it when we want to
Browse files Browse the repository at this point in the history
  • Loading branch information
jmartinesp committed Jan 22, 2025
1 parent 947c615 commit 396fe48
Show file tree
Hide file tree
Showing 2 changed files with 347 additions and 1 deletion.
16 changes: 15 additions & 1 deletion appnav/src/main/kotlin/io/element/android/appnav/SyncObserver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
Expand Down Expand Up @@ -45,6 +47,8 @@ class SyncObserver @Inject constructor(

private val initialSyncMutex = Mutex()

private var coroutineScope: CoroutineScope? = null

/**
* Observe the app state and network state to start/stop the sync service.
*
Expand All @@ -67,7 +71,9 @@ class SyncObserver @Inject constructor(
}
}

sessionCoroutineScope.launch(dispatchers.io) {
coroutineScope = CoroutineScope(sessionCoroutineScope.coroutineContext + CoroutineName("SyncObserver") + dispatchers.io)

coroutineScope?.launch {
// Wait until the initial sync is done, either successfully or failing
initialSyncMutex.lock()

Expand Down Expand Up @@ -106,6 +112,14 @@ class SyncObserver @Inject constructor(
}
}
}

/**
* Stop observing the app state and network state.
*/
fun stop() {
coroutineScope?.cancel()
coroutineScope = null
}
}

private enum class SyncObserverAction {
Expand Down
332 changes: 332 additions & 0 deletions appnav/src/test/kotlin/io/element/android/appnav/SyncObserverTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

package io.element.android.appnav

import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds

@OptIn(ExperimentalCoroutinesApi::class)
class SyncObserverTest {
@get:Rule
val warmUpRule = WarmUpRule()

@Test
fun `when the sync wasn't running before, an initial sync will always take place, even with no network`() = runTest {
val stateFlow = MutableStateFlow<SyncState>(SyncState.Idle)
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(syncStateFlow = stateFlow).apply {
startSyncLambda = startSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Offline)
val syncObserver = createSyncObserver(syncService, networkMonitor)

// We start observing
syncObserver.observe()

// Advance the time to make sure we left the initial sync behind
advanceTimeBy(1.seconds)

// Start sync will be called shortly after
startSyncRecorder.assertions().isCalledOnce()

// Stop observing
syncObserver.stop()
}

@Test
fun `when the app goes to background and the sync was running, it will be stopped after a delay`() = runTest {
val stateFlow = MutableStateFlow<SyncState>(SyncState.Running)
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(syncStateFlow = stateFlow).apply {
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Online)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncObserver = createSyncObserver(syncService, networkMonitor, appForegroundStateService)

// We start observing, we skip the initial sync attempt since the state is running
syncObserver.observe()

// Advance the time to make sure we left the initial sync behind
advanceTimeBy(1.seconds)

// Stop sync was never called
stopSyncRecorder.assertions().isNeverCalled()

// Now we send the app to background
appForegroundStateService.isInForeground.value = false

// Stop sync will be called after some delay
stopSyncRecorder.assertions().isNeverCalled()
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledOnce()

// Stop observing
syncObserver.stop()
}

@Test
fun `when the app was in background and we receive a notification, a sync will be started then stopped`() = runTest {
val stateFlow = MutableStateFlow<SyncState>(SyncState.Running)
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(syncStateFlow = stateFlow).apply {
startSyncLambda = startSyncRecorder
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Online)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = false,
initialIsSyncingNotificationEventValue = false,
)
val syncObserver = createSyncObserver(syncService, networkMonitor, appForegroundStateService)

// We start observing, we skip the initial sync attempt since the state is running
syncObserver.observe()

// Advance the time to make sure we left the initial sync behind
advanceTimeBy(1.seconds)

// Start sync was never called
startSyncRecorder.assertions().isNeverCalled()

// We stop the ongoing sync, give the sync service some time to stop
stateFlow.value = SyncState.Idle
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledOnce()

// Now we receive a notification and need to sync
appForegroundStateService.updateIsSyncingNotificationEvent(true)

// Start sync will be called shortly after
advanceTimeBy(1.milliseconds)
startSyncRecorder.assertions().isCalledOnce()

// If the sync is running and we mark the notification sync as no longer necessary, the sync stops after a delay
stateFlow.value = SyncState.Running
appForegroundStateService.updateIsSyncingNotificationEvent(false)

advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledExactly(2)

// Stop observing
syncObserver.stop()
}

@Test
fun `when the app was in background and we join a call, a sync will be started`() = runTest {
val stateFlow = MutableStateFlow<SyncState>(SyncState.Running)
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(syncStateFlow = stateFlow).apply {
startSyncLambda = startSyncRecorder
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Online)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = false,
initialIsSyncingNotificationEventValue = false,
)
val syncObserver = createSyncObserver(syncService, networkMonitor, appForegroundStateService)

// We start observing, we skip the initial sync attempt since the state is running
syncObserver.observe()

// Advance the time to make sure we left the initial sync behind
advanceTimeBy(1.seconds)

// Start sync was never called
startSyncRecorder.assertions().isNeverCalled()

// We stop the ongoing sync, give the sync service some time to stop
stateFlow.value = SyncState.Idle
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledOnce()

// Now we join a call
appForegroundStateService.updateIsInCallState(true)

// Start sync will be called shortly after
advanceTimeBy(1.milliseconds)
startSyncRecorder.assertions().isCalledOnce()

// If the sync is running and we mark the in-call state as false, the sync stops after a delay
stateFlow.value = SyncState.Running
appForegroundStateService.updateIsInCallState(false)

advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledExactly(2)

// Stop observing
syncObserver.stop()
}

@Test
fun `when the app is in foreground, we sync for a notification and a call is ongoing, the sync will only stop when all conditions are false`() = runTest {
val stateFlow = MutableStateFlow<SyncState>(SyncState.Running)
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(syncStateFlow = stateFlow).apply {
startSyncLambda = startSyncRecorder
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Online)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = true,
initialIsSyncingNotificationEventValue = true,
initialIsInCallValue = true,
)
val syncObserver = createSyncObserver(syncService, networkMonitor, appForegroundStateService)

// We start observing, we skip the initial sync attempt since the state is running
syncObserver.observe()

// Advance the time to make sure we left the initial sync behind
advanceTimeBy(1.seconds)

// Start sync was never called
startSyncRecorder.assertions().isNeverCalled()

// We send the app to background, it's still syncing
appForegroundStateService.givenIsInForeground(false)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isNeverCalled()

// We stop the notification sync, it's still syncing
appForegroundStateService.updateIsSyncingNotificationEvent(false)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isNeverCalled()

// We set the in-call state to false, now it stops syncing after a delay
appForegroundStateService.updateIsInCallState(false)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledOnce()

// Stop observing
syncObserver.stop()
}

@Test
fun `if the sync was running, it's set to be stopped but something triggers a sync again, the sync is not stopped`() = runTest {
val stateFlow = MutableStateFlow<SyncState>(SyncState.Running)
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(syncStateFlow = stateFlow).apply {
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Online)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = true,
initialIsSyncingNotificationEventValue = true,
initialIsInCallValue = true,
)
val syncObserver = createSyncObserver(syncService, networkMonitor, appForegroundStateService)

// We start observing, we skip the initial sync attempt since the state is running
syncObserver.observe()

// Advance the time to make sure we left the initial sync behind
advanceTimeBy(1.seconds)

// This will set the sync to stop
appForegroundStateService.givenIsInForeground(false)

// But if we reset it quickly before the stop sync takes place, the sync is not stopped
appForegroundStateService.givenIsInForeground(true)

advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isNeverCalled()

// Stop observing
syncObserver.stop()
}

@Test
fun `when network is offline, sync service should not start`() = runTest {
val stateFlow = MutableStateFlow<SyncState>(SyncState.Running)
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(syncStateFlow = stateFlow).apply {
startSyncLambda = startSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Offline)
val syncObserver = createSyncObserver(syncService, networkMonitor)

// We start observing, we skip the initial sync attempt since the state is running
syncObserver.observe()

// Advance the time to make sure we left the initial sync behind
advanceTimeBy(1.seconds)

// Set the sync state to idle
stateFlow.value = SyncState.Idle

// This should still not trigger a sync, since there is no network
advanceTimeBy(10.seconds)
startSyncRecorder.assertions().isNeverCalled()

// Stop observing
syncObserver.stop()
}

@Test
fun `when sync was running and network is now offline, sync service should be stopped`() = runTest {
val stateFlow = MutableStateFlow<SyncState>(SyncState.Running)
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(syncStateFlow = stateFlow).apply {
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Online)
val syncObserver = createSyncObserver(syncService, networkMonitor)

// We start observing, we skip the initial sync attempt since the state is running
syncObserver.observe()

// Advance the time to make sure we left the initial sync behind
advanceTimeBy(1.seconds)

// Network is now offline
networkMonitor.connectivity.value = NetworkStatus.Offline

// This will stop the sync after some delay
stopSyncRecorder.assertions().isNeverCalled()
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledOnce()

// Stop observing
syncObserver.stop()
}

private fun TestScope.createSyncObserver(
syncService: FakeSyncService = FakeSyncService(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
) = SyncObserver(
matrixClient = FakeMatrixClient(syncService = syncService),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
sessionCoroutineScope = CoroutineScope(coroutineContext + SupervisorJob()),
dispatchers = testCoroutineDispatchers(),
)
}

0 comments on commit 396fe48

Please sign in to comment.