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:
Jorge Martin Espinosa 2025-02-06 16:36:57 +01:00 committed by GitHub
parent ce1c01e626
commit 3c87fb05b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 851 additions and 344 deletions

View file

@ -8,14 +8,13 @@
package io.element.android.libraries.designsystem.utils
import android.content.pm.ActivityInfo
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
@Composable
fun ForceOrientation(orientation: ScreenOrientation) {
val activity = LocalContext.current as? ComponentActivity ?: return
val activity = LocalActivity.current ?: return
val orientationFlags = when (orientation) {
ScreenOrientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
ScreenOrientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE

View file

@ -495,6 +495,7 @@ class RustMatrixClient(
override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean): String? {
var result: String? = null
sessionCoroutineScope.cancel()
// Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy()
clientDelegateTaskHandle = null

View file

@ -48,6 +48,7 @@ import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
@ -296,7 +297,7 @@ class RustTimeline(
htmlBody: String?,
intentionalMentions: List<IntentionalMention>,
): Result<Unit> = withContext(dispatcher) {
runCatching<Unit> {
runCatching {
val editedContent = EditedContent.RoomMessage(
content = MessageEventContent.from(
body = body,
@ -324,10 +325,12 @@ class RustTimeline(
},
mentions = null,
)
inner.edit(
newContent = editedContent,
eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
)
withContext(Dispatchers.IO) {
inner.edit(
newContent = editedContent,
eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(),
)
}
}
}
@ -519,7 +522,7 @@ class RustTimeline(
newContent = editedContent,
eventOrTransactionId = RustEventOrTransactionId.EventId(pollStartId.value),
)
}.map { }
}
}
override suspend fun sendPollResponse(

View file

@ -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()
}
}
}

View file

@ -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,