Skip to content

Commit 3c87fb0

Browse files
authored
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
1 parent ce1c01e commit 3c87fb0

File tree

44 files changed

+851
-344
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+851
-344
lines changed

appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt

+1-43
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@ import androidx.compose.runtime.Composable
1414
import androidx.compose.runtime.collectAsState
1515
import androidx.compose.runtime.getValue
1616
import androidx.compose.ui.Modifier
17-
import androidx.lifecycle.Lifecycle
1817
import androidx.lifecycle.lifecycleScope
19-
import androidx.lifecycle.repeatOnLifecycle
2018
import com.bumble.appyx.core.composable.PermanentChild
2119
import com.bumble.appyx.core.lifecycle.subscribe
2220
import com.bumble.appyx.core.modality.BuildContext
@@ -52,8 +50,6 @@ import io.element.android.features.ftue.api.FtueEntryPoint
5250
import io.element.android.features.ftue.api.state.FtueService
5351
import io.element.android.features.ftue.api.state.FtueState
5452
import io.element.android.features.logout.api.LogoutEntryPoint
55-
import io.element.android.features.networkmonitor.api.NetworkMonitor
56-
import io.element.android.features.networkmonitor.api.NetworkStatus
5753
import io.element.android.features.preferences.api.PreferencesEntryPoint
5854
import io.element.android.features.roomdirectory.api.RoomDescription
5955
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
@@ -77,18 +73,13 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
7773
import io.element.android.libraries.matrix.api.core.UserId
7874
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
7975
import io.element.android.libraries.matrix.api.permalink.PermalinkData
80-
import io.element.android.libraries.matrix.api.sync.SyncState
8176
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
8277
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
8378
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
8479
import io.element.android.services.appnavstate.api.AppNavigationStateService
8580
import kotlinx.coroutines.CoroutineScope
86-
import kotlinx.coroutines.FlowPreview
87-
import kotlinx.coroutines.flow.combine
88-
import kotlinx.coroutines.flow.debounce
8981
import kotlinx.coroutines.flow.launchIn
9082
import kotlinx.coroutines.flow.onEach
91-
import kotlinx.coroutines.flow.onStart
9283
import kotlinx.coroutines.launch
9384
import kotlinx.parcelize.Parcelize
9485
import timber.log.Timber
@@ -107,7 +98,6 @@ class LoggedInFlowNode @AssistedInject constructor(
10798
private val userProfileEntryPoint: UserProfileEntryPoint,
10899
private val ftueEntryPoint: FtueEntryPoint,
109100
private val coroutineScope: CoroutineScope,
110-
private val networkMonitor: NetworkMonitor,
111101
private val ftueService: FtueService,
112102
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
113103
private val shareEntryPoint: ShareEntryPoint,
@@ -133,7 +123,6 @@ class LoggedInFlowNode @AssistedInject constructor(
133123
fun onOpenBugReport()
134124
}
135125

136-
private val syncService = matrixClient.syncService()
137126
private val loggedInFlowProcessor = LoggedInEventProcessor(
138127
snackbarDispatcher,
139128
matrixClient.roomMembershipObserver(),
@@ -147,6 +136,7 @@ class LoggedInFlowNode @AssistedInject constructor(
147136

148137
override fun onBuilt() {
149138
super.onBuilt()
139+
150140
lifecycle.subscribe(
151141
onCreate = {
152142
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
@@ -165,52 +155,20 @@ class LoggedInFlowNode @AssistedInject constructor(
165155
}
166156
.launchIn(lifecycleScope)
167157
},
168-
onStop = {
169-
coroutineScope.launch {
170-
// Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
171-
syncService.stopSync()
172-
}
173-
},
174158
onDestroy = {
175159
appNavigationStateService.onLeavingSpace(id)
176160
appNavigationStateService.onLeavingSession(id)
177161
loggedInFlowProcessor.stopObserving()
178162
matrixClient.sessionVerificationService().setListener(null)
179163
}
180164
)
181-
observeSyncStateAndNetworkStatus()
182165
setupSendingQueue()
183166
}
184167

185168
private fun setupSendingQueue() {
186169
sendingQueue.launchIn(lifecycleScope)
187170
}
188171

189-
@OptIn(FlowPreview::class)
190-
private fun observeSyncStateAndNetworkStatus() {
191-
lifecycleScope.launch {
192-
repeatOnLifecycle(Lifecycle.State.STARTED) {
193-
combine(
194-
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
195-
syncService.syncState.debounce(100),
196-
networkMonitor.connectivity
197-
) { syncState, networkStatus ->
198-
Pair(syncState, networkStatus)
199-
}
200-
.onStart {
201-
// Temporary fix to ensure that the sync is started even if the networkStatus is offline.
202-
syncService.startSync()
203-
}
204-
.collect { (syncState, networkStatus) ->
205-
Timber.d("Sync state: $syncState, network status: $networkStatus")
206-
if (syncState != SyncState.Running && networkStatus == NetworkStatus.Online) {
207-
syncService.startSync()
208-
}
209-
}
210-
}
211-
}
212-
}
213-
214172
sealed interface NavTarget : Parcelable {
215173
@Parcelize
216174
data object Placeholder : NavTarget

appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt

+7-7
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import dagger.assisted.Assisted
2727
import dagger.assisted.AssistedInject
2828
import im.vector.app.features.analytics.plan.JoinedRoom
2929
import io.element.android.anvilannotations.ContributesNode
30-
import io.element.android.appnav.di.MatrixClientsHolder
30+
import io.element.android.appnav.di.MatrixSessionCache
3131
import io.element.android.appnav.intent.IntentResolver
3232
import io.element.android.appnav.intent.ResolvedIntent
3333
import io.element.android.appnav.root.RootNavStateFlowFactory
@@ -62,7 +62,7 @@ class RootFlowNode @AssistedInject constructor(
6262
@Assisted plugins: List<Plugin>,
6363
private val authenticationService: MatrixAuthenticationService,
6464
private val navStateFlowFactory: RootNavStateFlowFactory,
65-
private val matrixClientsHolder: MatrixClientsHolder,
65+
private val matrixSessionCache: MatrixSessionCache,
6666
private val presenter: RootPresenter,
6767
private val bugReportEntryPoint: BugReportEntryPoint,
6868
private val viewFolderEntryPoint: ViewFolderEntryPoint,
@@ -78,14 +78,14 @@ class RootFlowNode @AssistedInject constructor(
7878
plugins = plugins
7979
) {
8080
override fun onBuilt() {
81-
matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap)
81+
matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap)
8282
super.onBuilt()
8383
observeNavState()
8484
}
8585

8686
override fun onSaveInstanceState(state: MutableSavedStateMap) {
8787
super.onSaveInstanceState(state)
88-
matrixClientsHolder.saveIntoSavedState(state)
88+
matrixSessionCache.saveIntoSavedState(state)
8989
navStateFlowFactory.saveIntoSavedState(state)
9090
}
9191

@@ -118,7 +118,7 @@ class RootFlowNode @AssistedInject constructor(
118118
}
119119

120120
private fun switchToNotLoggedInFlow() {
121-
matrixClientsHolder.removeAll()
121+
matrixSessionCache.removeAll()
122122
backstack.safeRoot(NavTarget.NotLoggedInFlow)
123123
}
124124

@@ -131,7 +131,7 @@ class RootFlowNode @AssistedInject constructor(
131131
onFailure: () -> Unit,
132132
onSuccess: (SessionId) -> Unit,
133133
) {
134-
matrixClientsHolder.getOrRestore(sessionId)
134+
matrixSessionCache.getOrRestore(sessionId)
135135
.onSuccess {
136136
Timber.v("Succeed to restore session $sessionId")
137137
onSuccess(sessionId)
@@ -200,7 +200,7 @@ class RootFlowNode @AssistedInject constructor(
200200
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
201201
return when (navTarget) {
202202
is NavTarget.LoggedInFlow -> {
203-
val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
203+
val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
204204
Timber.w("Couldn't find any session, go through SplashScreen")
205205
}
206206
val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient)

appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt

+38-11
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
package io.element.android.appnav.di
99

10+
import androidx.annotation.VisibleForTesting
1011
import com.bumble.appyx.core.state.MutableSavedStateMap
1112
import com.bumble.appyx.core.state.SavedStateMap
1213
import com.squareup.anvil.annotations.ContributesBinding
@@ -25,45 +26,61 @@ import javax.inject.Inject
2526

2627
private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey"
2728

29+
/**
30+
* In-memory cache for logged in Matrix sessions.
31+
*
32+
* This component contains both the [MatrixClient] and the [SyncOrchestrator] for each session.
33+
*/
2834
@SingleIn(AppScope::class)
2935
@ContributesBinding(AppScope::class)
30-
class MatrixClientsHolder @Inject constructor(
36+
class MatrixSessionCache @Inject constructor(
3137
private val authenticationService: MatrixAuthenticationService,
38+
private val syncOrchestratorFactory: SyncOrchestrator.Factory,
3239
) : MatrixClientProvider {
33-
private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>()
40+
private val sessionIdsToMatrixSession = ConcurrentHashMap<SessionId, InMemoryMatrixSession>()
3441
private val restoreMutex = Mutex()
3542

3643
init {
3744
authenticationService.listenToNewMatrixClients { matrixClient ->
38-
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
45+
val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
46+
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
47+
matrixClient = matrixClient,
48+
syncOrchestrator = syncOrchestrator,
49+
)
50+
syncOrchestrator.start()
3951
}
4052
}
4153

4254
fun removeAll() {
43-
sessionIdsToMatrixClient.clear()
55+
sessionIdsToMatrixSession.clear()
4456
}
4557

4658
fun remove(sessionId: SessionId) {
47-
sessionIdsToMatrixClient.remove(sessionId)
59+
sessionIdsToMatrixSession.remove(sessionId)
4860
}
4961

5062
override fun getOrNull(sessionId: SessionId): MatrixClient? {
51-
return sessionIdsToMatrixClient[sessionId]
63+
return sessionIdsToMatrixSession[sessionId]?.matrixClient
5264
}
5365

5466
override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> {
5567
return restoreMutex.withLock {
56-
when (val matrixClient = getOrNull(sessionId)) {
68+
when (val cached = getOrNull(sessionId)) {
5769
null -> restore(sessionId)
58-
else -> Result.success(matrixClient)
70+
else -> Result.success(cached)
5971
}
6072
}
6173
}
6274

75+
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
76+
internal fun getSyncOrchestrator(sessionId: SessionId): SyncOrchestrator? {
77+
return sessionIdsToMatrixSession[sessionId]?.syncOrchestrator
78+
}
79+
6380
@Suppress("UNCHECKED_CAST")
6481
fun restoreWithSavedState(state: SavedStateMap?) {
6582
Timber.d("Restore state")
66-
if (state == null || sessionIdsToMatrixClient.isNotEmpty()) {
83+
if (state == null || sessionIdsToMatrixSession.isNotEmpty()) {
6784
Timber.w("Restore with non-empty map")
6885
return
6986
}
@@ -79,7 +96,7 @@ class MatrixClientsHolder @Inject constructor(
7996
}
8097

8198
fun saveIntoSavedState(state: MutableSavedStateMap) {
82-
val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray()
99+
val sessionKeys = sessionIdsToMatrixSession.keys.toTypedArray()
83100
Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
84101
state[SAVE_INSTANCE_KEY] = sessionKeys
85102
}
@@ -88,10 +105,20 @@ class MatrixClientsHolder @Inject constructor(
88105
Timber.d("Restore matrix session: $sessionId")
89106
return authenticationService.restoreSession(sessionId)
90107
.onSuccess { matrixClient ->
91-
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
108+
val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
109+
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
110+
matrixClient = matrixClient,
111+
syncOrchestrator = syncOrchestrator,
112+
)
113+
syncOrchestrator.start()
92114
}
93115
.onFailure {
94116
Timber.e(it, "Fail to restore session")
95117
}
96118
}
97119
}
120+
121+
private data class InMemoryMatrixSession(
122+
val matrixClient: MatrixClient,
123+
val syncOrchestrator: SyncOrchestrator,
124+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.appnav.di
9+
10+
import dagger.assisted.Assisted
11+
import dagger.assisted.AssistedFactory
12+
import dagger.assisted.AssistedInject
13+
import io.element.android.features.networkmonitor.api.NetworkMonitor
14+
import io.element.android.features.networkmonitor.api.NetworkStatus
15+
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
16+
import io.element.android.libraries.core.coroutine.childScope
17+
import io.element.android.libraries.matrix.api.MatrixClient
18+
import io.element.android.libraries.matrix.api.sync.SyncState
19+
import io.element.android.services.appnavstate.api.AppForegroundStateService
20+
import kotlinx.coroutines.FlowPreview
21+
import kotlinx.coroutines.flow.combine
22+
import kotlinx.coroutines.flow.debounce
23+
import kotlinx.coroutines.flow.distinctUntilChanged
24+
import kotlinx.coroutines.flow.launchIn
25+
import kotlinx.coroutines.flow.onCompletion
26+
import kotlinx.coroutines.flow.onEach
27+
import timber.log.Timber
28+
import java.util.concurrent.atomic.AtomicBoolean
29+
import kotlin.time.Duration.Companion.milliseconds
30+
import kotlin.time.Duration.Companion.seconds
31+
32+
class SyncOrchestrator @AssistedInject constructor(
33+
@Assisted matrixClient: MatrixClient,
34+
private val appForegroundStateService: AppForegroundStateService,
35+
private val networkMonitor: NetworkMonitor,
36+
dispatchers: CoroutineDispatchers,
37+
) {
38+
@AssistedFactory
39+
interface Factory {
40+
fun create(matrixClient: MatrixClient): SyncOrchestrator
41+
}
42+
43+
private val syncService = matrixClient.syncService()
44+
45+
private val tag = "SyncOrchestrator"
46+
47+
private val coroutineScope = matrixClient.sessionCoroutineScope.childScope(dispatchers.io, tag)
48+
49+
private val started = AtomicBoolean(false)
50+
51+
/**
52+
* Starting observing the app state and network state to start/stop the sync service.
53+
*
54+
* Before observing the state, a first attempt at starting the sync service will happen if it's not already running.
55+
*/
56+
@OptIn(FlowPreview::class)
57+
fun start() {
58+
if (!started.compareAndSet(false, true)) {
59+
Timber.tag(tag).d("already started, exiting early")
60+
return
61+
}
62+
63+
Timber.tag(tag).d("start observing the app and network state")
64+
65+
combine(
66+
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
67+
syncService.syncState.debounce(100.milliseconds),
68+
networkMonitor.connectivity,
69+
appForegroundStateService.isInForeground,
70+
appForegroundStateService.isInCall,
71+
appForegroundStateService.isSyncingNotificationEvent,
72+
) { syncState, networkState, isInForeground, isInCall, isSyncingNotificationEvent ->
73+
val isAppActive = isInForeground || isInCall || isSyncingNotificationEvent
74+
val isNetworkAvailable = networkState == NetworkStatus.Connected
75+
76+
Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable")
77+
if (syncState == SyncState.Running && !isAppActive) {
78+
SyncStateAction.StopSync
79+
} else if (syncState != SyncState.Running && isAppActive && isNetworkAvailable) {
80+
SyncStateAction.StartSync
81+
} else {
82+
SyncStateAction.NoOp
83+
}
84+
}
85+
.distinctUntilChanged()
86+
.debounce { action ->
87+
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
88+
if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds
89+
}
90+
.onEach { action ->
91+
when (action) {
92+
SyncStateAction.StartSync -> {
93+
syncService.startSync()
94+
}
95+
SyncStateAction.StopSync -> {
96+
syncService.stopSync()
97+
}
98+
SyncStateAction.NoOp -> Unit
99+
}
100+
}
101+
.onCompletion {
102+
Timber.tag(tag).d("has been stopped")
103+
}
104+
.launchIn(coroutineScope)
105+
}
106+
}
107+
108+
private enum class SyncStateAction {
109+
StartSync,
110+
StopSync,
111+
NoOp,
112+
}

0 commit comments

Comments
 (0)