Create SyncOrchestrator (#4176)
* Create `SyncOrchestrator` to centralise the sync start/stop flow through the whole app: the decision is based on several inputs: sync state, network available, app in foreground, app in call, app needing to sync an event for a notification. * Make network monitor return network connectivity status, not internet connectivity * Don't stop the `SyncService` when network connection is lost, let it fail instead. This prevents an issue when using the offline mode of the SDK, which made the wrong UI states to be shown when the `SyncState` is `Idle` (that is, after the service being manually stopped). * Rename `NetworkStatus.Online/Offline` to `Connected/Disconnected` so they're not easily mistaken with internet connectivity instead
This commit is contained in:
parent
ce1c01e626
commit
3c87fb05b2
44 changed files with 851 additions and 344 deletions
|
|
@ -13,7 +13,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
|
|||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
|
||||
|
|
@ -21,7 +20,6 @@ import io.element.android.services.appnavstate.api.AppForegroundStateService
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
|
@ -32,27 +30,28 @@ class SyncOnNotifiableEvent @Inject constructor(
|
|||
private val appForegroundStateService: AppForegroundStateService,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
private var syncCounter = AtomicInteger(0)
|
||||
|
||||
suspend operator fun invoke(notifiableEvent: NotifiableEvent) = withContext(dispatchers.io) {
|
||||
val isRingingCallEvent = notifiableEvent is NotifiableRingingCallEvent
|
||||
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush) && !isRingingCallEvent) {
|
||||
return@withContext
|
||||
}
|
||||
val client = matrixClientProvider.getOrRestore(notifiableEvent.sessionId).getOrNull() ?: return@withContext
|
||||
|
||||
client.getRoom(notifiableEvent.roomId)?.use { room ->
|
||||
room.subscribeToSync()
|
||||
|
||||
// If the app is in foreground, sync is already running, so just add the subscription.
|
||||
// If the app is in foreground, sync is already running, so we just add the subscription above.
|
||||
if (!appForegroundStateService.isInForeground.value) {
|
||||
val syncService = client.syncService()
|
||||
syncService.startSyncIfNeeded()
|
||||
if (isRingingCallEvent) {
|
||||
room.waitsUntilUserIsInTheCall(timeout = 60.seconds)
|
||||
} else {
|
||||
room.waitsUntilEventIsKnown(eventId = notifiableEvent.eventId, timeout = 10.seconds)
|
||||
try {
|
||||
appForegroundStateService.updateIsSyncingNotificationEvent(true)
|
||||
room.waitsUntilEventIsKnown(eventId = notifiableEvent.eventId, timeout = 10.seconds)
|
||||
} finally {
|
||||
appForegroundStateService.updateIsSyncingNotificationEvent(false)
|
||||
}
|
||||
}
|
||||
syncService.stopSyncIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -81,16 +80,4 @@ class SyncOnNotifiableEvent @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun SyncService.startSyncIfNeeded() {
|
||||
if (syncCounter.getAndIncrement() == 0) {
|
||||
startSync()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun SyncService.stopSyncIfNeeded() {
|
||||
if (syncCounter.decrementAndGet() == 0 && !appForegroundStateService.isInForeground.value) {
|
||||
stopSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
package io.element.android.libraries.push.impl.push
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
|
|
@ -17,6 +19,7 @@ import io.element.android.libraries.matrix.test.A_UNIQUE_ID
|
|||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
|
|
@ -26,13 +29,16 @@ import io.element.android.services.appnavstate.test.FakeAppForegroundStateServic
|
|||
import io.element.android.tests.testutils.lambda.assert
|
||||
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.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class SyncOnNotifiableEventTest {
|
||||
private val timelineItems = MutableStateFlow<List<MatrixTimelineItem>>(emptyList())
|
||||
|
|
@ -73,60 +79,98 @@ class SyncOnNotifiableEventTest {
|
|||
assert(subscribeToSyncLambda).isNeverCalled()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `when feature flag is enabled, a ringing call starts and stops the sync`() = runTest {
|
||||
val sut = createSyncOnNotifiableEvent(client = client, isAppInForeground = false, isSyncOnPushEnabled = true)
|
||||
fun `when feature flag is enabled, a ringing call waits until the room is in 'in-call' state`() = runTest {
|
||||
val appForegroundStateService = FakeAppForegroundStateService(
|
||||
initialForegroundValue = false,
|
||||
)
|
||||
val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true)
|
||||
|
||||
val unlocked = AtomicBoolean(false)
|
||||
launch {
|
||||
advanceTimeBy(1.seconds)
|
||||
unlocked.set(true)
|
||||
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
|
||||
}
|
||||
sut(incomingCallNotifiableEvent)
|
||||
|
||||
assert(startSyncLambda).isCalledOnce()
|
||||
assert(stopSyncLambda).isCalledOnce()
|
||||
assert(subscribeToSyncLambda).isCalledOnce()
|
||||
// The process was completed before the timeout
|
||||
assertThat(unlocked.get()).isTrue()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `when feature flag is disabled, a ringing call starts and stops the sync`() = runTest {
|
||||
val sut = createSyncOnNotifiableEvent(client = client, isAppInForeground = false, isSyncOnPushEnabled = false)
|
||||
fun `when feature flag is enabled, a ringing call waits until the room is in 'in-call' state or timeouts`() = runTest {
|
||||
val appForegroundStateService = FakeAppForegroundStateService(
|
||||
initialForegroundValue = false,
|
||||
)
|
||||
val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true)
|
||||
|
||||
val unlocked = AtomicBoolean(false)
|
||||
launch {
|
||||
advanceTimeBy(120.seconds)
|
||||
unlocked.set(true)
|
||||
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
|
||||
}
|
||||
sut(incomingCallNotifiableEvent)
|
||||
|
||||
assert(startSyncLambda).isCalledOnce()
|
||||
assert(stopSyncLambda).isCalledOnce()
|
||||
assert(subscribeToSyncLambda).isCalledOnce()
|
||||
// Didn't unlock before the timeout
|
||||
assertThat(unlocked.get()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when feature flag is enabled and app is in foreground, sync is not started`() = runTest {
|
||||
val sut = createSyncOnNotifiableEvent(client = client, isAppInForeground = true, isSyncOnPushEnabled = true)
|
||||
val appForegroundStateService = FakeAppForegroundStateService(
|
||||
initialForegroundValue = true,
|
||||
)
|
||||
val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true)
|
||||
|
||||
sut(notifiableEvent)
|
||||
sut(incomingCallNotifiableEvent)
|
||||
appForegroundStateService.isSyncingNotificationEvent.test {
|
||||
sut(notifiableEvent)
|
||||
sut(incomingCallNotifiableEvent)
|
||||
|
||||
assert(startSyncLambda).isNeverCalled()
|
||||
assert(stopSyncLambda).isNeverCalled()
|
||||
assert(subscribeToSyncLambda).isCalledExactly(2)
|
||||
// It's initially false
|
||||
assertThat(awaitItem()).isFalse()
|
||||
// It never becomes true
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when feature flag is enabled and app is in background, sync is started and stopped`() = runTest {
|
||||
val sut = createSyncOnNotifiableEvent(client = client, isAppInForeground = false, isSyncOnPushEnabled = true)
|
||||
val appForegroundStateService = FakeAppForegroundStateService(
|
||||
initialForegroundValue = false,
|
||||
)
|
||||
val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true)
|
||||
|
||||
timelineItems.emit(
|
||||
listOf(MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()))
|
||||
)
|
||||
syncService.emitSyncState(SyncState.Running)
|
||||
sut(notifiableEvent)
|
||||
|
||||
assert(startSyncLambda).isCalledOnce()
|
||||
assert(stopSyncLambda).isCalledOnce()
|
||||
assert(subscribeToSyncLambda).isCalledOnce()
|
||||
appForegroundStateService.isSyncingNotificationEvent.test {
|
||||
syncService.emitSyncState(SyncState.Running)
|
||||
sut(notifiableEvent)
|
||||
|
||||
// It's initially false
|
||||
assertThat(awaitItem()).isFalse()
|
||||
// Then it becomes true when we receive the push
|
||||
assertThat(awaitItem()).isTrue()
|
||||
// It becomes false once when the push is processed
|
||||
assertThat(awaitItem()).isFalse()
|
||||
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when feature flag is enabled and app is in background, running multiple time only call once`() = runTest {
|
||||
val sut = createSyncOnNotifiableEvent(client = client, isAppInForeground = false, isSyncOnPushEnabled = true)
|
||||
val appForegroundStateService = FakeAppForegroundStateService(
|
||||
initialForegroundValue = false,
|
||||
)
|
||||
val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true)
|
||||
|
||||
coroutineScope {
|
||||
appForegroundStateService.isSyncingNotificationEvent.test {
|
||||
launch { sut(notifiableEvent) }
|
||||
launch { sut(notifiableEvent) }
|
||||
launch {
|
||||
|
|
@ -135,26 +179,30 @@ class SyncOnNotifiableEventTest {
|
|||
listOf(MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
assert(startSyncLambda).isCalledOnce()
|
||||
assert(stopSyncLambda).isCalledOnce()
|
||||
assert(subscribeToSyncLambda).isCalledExactly(2)
|
||||
// It's initially false
|
||||
assertThat(awaitItem()).isFalse()
|
||||
// Then it becomes true once, for the first received push
|
||||
assertThat(awaitItem()).isTrue()
|
||||
// It becomes false once all pushes are processed
|
||||
assertThat(awaitItem()).isFalse()
|
||||
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createSyncOnNotifiableEvent(
|
||||
client: MatrixClient = FakeMatrixClient(),
|
||||
isSyncOnPushEnabled: Boolean = true,
|
||||
isAppInForeground: Boolean = true,
|
||||
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(
|
||||
initialForegroundValue = true,
|
||||
)
|
||||
): SyncOnNotifiableEvent {
|
||||
val featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(
|
||||
FeatureFlags.SyncOnPush.key to isSyncOnPushEnabled
|
||||
)
|
||||
)
|
||||
val appForegroundStateService = FakeAppForegroundStateService(
|
||||
initialValue = isAppInForeground
|
||||
)
|
||||
val matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }
|
||||
return SyncOnNotifiableEvent(
|
||||
matrixClientProvider = matrixClientProvider,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue