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

@ -14,9 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.composable.PermanentChild
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
@ -52,8 +50,6 @@ import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.logout.api.LogoutEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
@ -77,18 +73,13 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ -107,7 +98,6 @@ class LoggedInFlowNode @AssistedInject constructor(
private val userProfileEntryPoint: UserProfileEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor,
private val ftueService: FtueService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint,
@ -133,7 +123,6 @@ class LoggedInFlowNode @AssistedInject constructor(
fun onOpenBugReport()
}
private val syncService = matrixClient.syncService()
private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher,
matrixClient.roomMembershipObserver(),
@ -147,6 +136,7 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
@ -165,12 +155,6 @@ class LoggedInFlowNode @AssistedInject constructor(
}
.launchIn(lifecycleScope)
},
onStop = {
coroutineScope.launch {
// Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
syncService.stopSync()
}
},
onDestroy = {
appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id)
@ -178,7 +162,6 @@ class LoggedInFlowNode @AssistedInject constructor(
matrixClient.sessionVerificationService().setListener(null)
}
)
observeSyncStateAndNetworkStatus()
setupSendingQueue()
}
@ -186,31 +169,6 @@ class LoggedInFlowNode @AssistedInject constructor(
sendingQueue.launchIn(lifecycleScope)
}
@OptIn(FlowPreview::class)
private fun observeSyncStateAndNetworkStatus() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
syncService.syncState.debounce(100),
networkMonitor.connectivity
) { syncState, networkStatus ->
Pair(syncState, networkStatus)
}
.onStart {
// Temporary fix to ensure that the sync is started even if the networkStatus is offline.
syncService.startSync()
}
.collect { (syncState, networkStatus) ->
Timber.d("Sync state: $syncState, network status: $networkStatus")
if (syncState != SyncState.Running && networkStatus == NetworkStatus.Online) {
syncService.startSync()
}
}
}
}
}
sealed interface NavTarget : Parcelable {
@Parcelize
data object Placeholder : NavTarget

View file

@ -27,7 +27,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.di.MatrixClientsHolder
import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.root.RootNavStateFlowFactory
@ -62,7 +62,7 @@ class RootFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val authenticationService: MatrixAuthenticationService,
private val navStateFlowFactory: RootNavStateFlowFactory,
private val matrixClientsHolder: MatrixClientsHolder,
private val matrixSessionCache: MatrixSessionCache,
private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint,
private val viewFolderEntryPoint: ViewFolderEntryPoint,
@ -78,14 +78,14 @@ class RootFlowNode @AssistedInject constructor(
plugins = plugins
) {
override fun onBuilt() {
matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap)
matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap)
super.onBuilt()
observeNavState()
}
override fun onSaveInstanceState(state: MutableSavedStateMap) {
super.onSaveInstanceState(state)
matrixClientsHolder.saveIntoSavedState(state)
matrixSessionCache.saveIntoSavedState(state)
navStateFlowFactory.saveIntoSavedState(state)
}
@ -118,7 +118,7 @@ class RootFlowNode @AssistedInject constructor(
}
private fun switchToNotLoggedInFlow() {
matrixClientsHolder.removeAll()
matrixSessionCache.removeAll()
backstack.safeRoot(NavTarget.NotLoggedInFlow)
}
@ -131,7 +131,7 @@ class RootFlowNode @AssistedInject constructor(
onFailure: () -> Unit,
onSuccess: (SessionId) -> Unit,
) {
matrixClientsHolder.getOrRestore(sessionId)
matrixSessionCache.getOrRestore(sessionId)
.onSuccess {
Timber.v("Succeed to restore session $sessionId")
onSuccess(sessionId)
@ -200,7 +200,7 @@ class RootFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.LoggedInFlow -> {
val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
Timber.w("Couldn't find any session, go through SplashScreen")
}
val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient)

View file

@ -7,6 +7,7 @@
package io.element.android.appnav.di
import androidx.annotation.VisibleForTesting
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
import com.squareup.anvil.annotations.ContributesBinding
@ -25,45 +26,61 @@ import javax.inject.Inject
private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey"
/**
* In-memory cache for logged in Matrix sessions.
*
* This component contains both the [MatrixClient] and the [SyncOrchestrator] for each session.
*/
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class MatrixClientsHolder @Inject constructor(
class MatrixSessionCache @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val syncOrchestratorFactory: SyncOrchestrator.Factory,
) : MatrixClientProvider {
private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>()
private val sessionIdsToMatrixSession = ConcurrentHashMap<SessionId, InMemoryMatrixSession>()
private val restoreMutex = Mutex()
init {
authenticationService.listenToNewMatrixClients { matrixClient ->
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
matrixClient = matrixClient,
syncOrchestrator = syncOrchestrator,
)
syncOrchestrator.start()
}
}
fun removeAll() {
sessionIdsToMatrixClient.clear()
sessionIdsToMatrixSession.clear()
}
fun remove(sessionId: SessionId) {
sessionIdsToMatrixClient.remove(sessionId)
sessionIdsToMatrixSession.remove(sessionId)
}
override fun getOrNull(sessionId: SessionId): MatrixClient? {
return sessionIdsToMatrixClient[sessionId]
return sessionIdsToMatrixSession[sessionId]?.matrixClient
}
override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> {
return restoreMutex.withLock {
when (val matrixClient = getOrNull(sessionId)) {
when (val cached = getOrNull(sessionId)) {
null -> restore(sessionId)
else -> Result.success(matrixClient)
else -> Result.success(cached)
}
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun getSyncOrchestrator(sessionId: SessionId): SyncOrchestrator? {
return sessionIdsToMatrixSession[sessionId]?.syncOrchestrator
}
@Suppress("UNCHECKED_CAST")
fun restoreWithSavedState(state: SavedStateMap?) {
Timber.d("Restore state")
if (state == null || sessionIdsToMatrixClient.isNotEmpty()) {
if (state == null || sessionIdsToMatrixSession.isNotEmpty()) {
Timber.w("Restore with non-empty map")
return
}
@ -79,7 +96,7 @@ class MatrixClientsHolder @Inject constructor(
}
fun saveIntoSavedState(state: MutableSavedStateMap) {
val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray()
val sessionKeys = sessionIdsToMatrixSession.keys.toTypedArray()
Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
state[SAVE_INSTANCE_KEY] = sessionKeys
}
@ -88,10 +105,20 @@ class MatrixClientsHolder @Inject constructor(
Timber.d("Restore matrix session: $sessionId")
return authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
val syncOrchestrator = syncOrchestratorFactory.create(matrixClient)
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
matrixClient = matrixClient,
syncOrchestrator = syncOrchestrator,
)
syncOrchestrator.start()
}
.onFailure {
Timber.e(it, "Fail to restore session")
}
}
}
private data class InMemoryMatrixSession(
val matrixClient: MatrixClient,
val syncOrchestrator: SyncOrchestrator,
)

View file

@ -0,0 +1,112 @@
/*
* 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.di
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
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.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class SyncOrchestrator @AssistedInject constructor(
@Assisted matrixClient: MatrixClient,
private val appForegroundStateService: AppForegroundStateService,
private val networkMonitor: NetworkMonitor,
dispatchers: CoroutineDispatchers,
) {
@AssistedFactory
interface Factory {
fun create(matrixClient: MatrixClient): SyncOrchestrator
}
private val syncService = matrixClient.syncService()
private val tag = "SyncOrchestrator"
private val coroutineScope = matrixClient.sessionCoroutineScope.childScope(dispatchers.io, tag)
private val started = AtomicBoolean(false)
/**
* Starting observing the app state and network state to start/stop the sync service.
*
* Before observing the state, a first attempt at starting the sync service will happen if it's not already running.
*/
@OptIn(FlowPreview::class)
fun start() {
if (!started.compareAndSet(false, true)) {
Timber.tag(tag).d("already started, exiting early")
return
}
Timber.tag(tag).d("start observing the app and network state")
combine(
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
syncService.syncState.debounce(100.milliseconds),
networkMonitor.connectivity,
appForegroundStateService.isInForeground,
appForegroundStateService.isInCall,
appForegroundStateService.isSyncingNotificationEvent,
) { syncState, networkState, isInForeground, isInCall, isSyncingNotificationEvent ->
val isAppActive = isInForeground || isInCall || isSyncingNotificationEvent
val isNetworkAvailable = networkState == NetworkStatus.Connected
Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable")
if (syncState == SyncState.Running && !isAppActive) {
SyncStateAction.StopSync
} else if (syncState != SyncState.Running && isAppActive && isNetworkAvailable) {
SyncStateAction.StartSync
} else {
SyncStateAction.NoOp
}
}
.distinctUntilChanged()
.debounce { action ->
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds
}
.onEach { action ->
when (action) {
SyncStateAction.StartSync -> {
syncService.startSync()
}
SyncStateAction.StopSync -> {
syncService.stopSync()
}
SyncStateAction.NoOp -> Unit
}
}
.onCompletion {
Timber.tag(tag).d("has been stopped")
}
.launchIn(coroutineScope)
}
}
private enum class SyncStateAction {
StartSync,
StopSync,
NoOp,
}

View file

@ -32,7 +32,7 @@ class SendQueues @Inject constructor(
) {
/**
* Launches the send queues retry mechanism in the given [coroutineScope].
* Makes sure to re-enable all send queues when the network status is [NetworkStatus.Online].
* Makes sure to re-enable all send queues when the network status is [NetworkStatus.Connected].
*/
@OptIn(FlowPreview::class)
fun launchIn(coroutineScope: CoroutineScope) {

View file

@ -9,7 +9,7 @@ package io.element.android.appnav.root
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
import io.element.android.appnav.di.MatrixClientsHolder
import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.features.login.api.LoginUserStory
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@ -31,7 +31,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFact
class RootNavStateFlowFactory @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val cacheService: CacheService,
private val matrixClientsHolder: MatrixClientsHolder,
private val matrixSessionCache: MatrixSessionCache,
private val imageLoaderHolder: ImageLoaderHolder,
private val loginUserStory: LoginUserStory,
private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory,
@ -63,7 +63,7 @@ class RootNavStateFlowFactory @Inject constructor(
val initialCacheIndex = savedStateMap.getCacheIndexOrDefault()
return cacheService.clearedCacheEventFlow
.onEach { sessionId ->
matrixClientsHolder.remove(sessionId)
matrixSessionCache.remove(sessionId)
// Ensure image loader will be recreated with the new MatrixClient
imageLoaderHolder.remove(sessionId)
// Also remove cached value for SessionPreferencesStore

View file

@ -0,0 +1,305 @@
/*
* 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.appnav.di.SyncOrchestrator
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.ExperimentalCoroutinesApi
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 SyncOrchestratorTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `when the app goes to background and the sync was running, it will be stopped after a delay`() = runTest {
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.start()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// 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()
}
@Test
fun `when the app state changes several times in a short while, stop sync is only called once`() = runTest {
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.start()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// Stop sync was never called
stopSyncRecorder.assertions().isNeverCalled()
// Now we send the app to background
appForegroundStateService.isInForeground.value = false
// Ensure the stop action wasn't called yet
stopSyncRecorder.assertions().isNeverCalled()
advanceTimeBy(1.seconds)
appForegroundStateService.isInForeground.value = true
advanceTimeBy(1.seconds)
// Ensure the stop action wasn't called yet either, since we didn't give it enough time to emit after the expected delay
stopSyncRecorder.assertions().isNeverCalled()
// Now change it again and wait for enough time
appForegroundStateService.isInForeground.value = false
advanceTimeBy(4.seconds)
// And confirm it's now called
stopSyncRecorder.assertions().isCalledOnce()
}
@Test
fun `when the app was in background and we receive a notification, a sync will be started then stopped`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
startSyncLambda = startSyncRecorder
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = false,
initialIsSyncingNotificationEventValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.start()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// Start sync was never called
startSyncRecorder.assertions().isNeverCalled()
// 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
syncService.emitSyncState(SyncState.Running)
appForegroundStateService.updateIsSyncingNotificationEvent(false)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledOnce()
}
@Test
fun `when the app was in background and we join a call, a sync will be started`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
startSyncLambda = startSyncRecorder
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = false,
initialIsSyncingNotificationEventValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.start()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// Start sync was never called
startSyncRecorder.assertions().isNeverCalled()
// 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
syncService.emitSyncState(SyncState.Running)
appForegroundStateService.updateIsInCallState(false)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledOnce()
}
@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 startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
startSyncLambda = startSyncRecorder
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = true,
initialIsSyncingNotificationEventValue = true,
initialIsInCallValue = true,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.start()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// 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()
}
@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 stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = true,
initialIsSyncingNotificationEventValue = false,
initialIsInCallValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.start()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// 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
advanceTimeBy(2.seconds)
appForegroundStateService.givenIsInForeground(true)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isNeverCalled()
}
@Test
fun `when network is offline, sync service should not start`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
startSyncLambda = startSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
)
// We start observing
syncOrchestrator.start()
// This should still not trigger a sync, since there is no network
advanceTimeBy(10.seconds)
startSyncRecorder.assertions().isNeverCalled()
}
private fun TestScope.createSyncOrchestrator(
syncService: FakeSyncService = FakeSyncService(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
) = SyncOrchestrator(
matrixClient = FakeMatrixClient(syncService = syncService, sessionCoroutineScope = backgroundScope),
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
dispatchers = testCoroutineDispatchers(),
)
}

View file

@ -1,97 +0,0 @@
/*
* Copyright 2023, 2024 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.di
import com.bumble.appyx.core.state.MutableSavedStateMapImpl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MatrixClientsHolderTest {
@Test
fun `test getOrNull`() {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test getOrRestore`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
val fakeMatrixClient = FakeMatrixClient()
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
// Do it again to hit the cache
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
}
@Test
fun `test remove`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
val fakeMatrixClient = FakeMatrixClient()
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
// Remove
matrixClientsHolder.remove(A_SESSION_ID)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test remove all`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
val fakeMatrixClient = FakeMatrixClient()
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
// Remove all
matrixClientsHolder.removeAll()
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test save and restore`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
val fakeMatrixClient = FakeMatrixClient()
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
matrixClientsHolder.getOrRestore(A_SESSION_ID)
val savedStateMap = MutableSavedStateMapImpl { true }
matrixClientsHolder.saveIntoSavedState(savedStateMap)
assertThat(savedStateMap.size).isEqualTo(1)
// Test Restore with non-empty map
matrixClientsHolder.restoreWithSavedState(savedStateMap)
// Empty the map
matrixClientsHolder.removeAll()
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
// Restore again
matrixClientsHolder.restoreWithSavedState(savedStateMap)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
}
@Test
fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID))
val loginSucceeded = fakeAuthenticationService.login("user", "pass")
assertThat(loginSucceeded.isSuccess).isTrue()
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNotNull()
}
}

View file

@ -0,0 +1,131 @@
/*
* Copyright 2023, 2024 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.di
import com.bumble.appyx.core.state.MutableSavedStateMapImpl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MatrixSessionCacheTest {
@Test
fun `test getOrNull`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `test getSyncOrchestratorOrNull`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
// With no matrix client there is no sync orchestrator
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNull()
// But as soon as we receive a client, we can get the sync orchestrator
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNotNull()
}
@Test
fun `test getOrRestore`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
// Do it again to hit the cache
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
}
@Test
fun `test remove`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
// Remove
matrixSessionCache.remove(A_SESSION_ID)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test remove all`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
// Remove all
matrixSessionCache.removeAll()
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test save and restore`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
matrixSessionCache.getOrRestore(A_SESSION_ID)
val savedStateMap = MutableSavedStateMapImpl { true }
matrixSessionCache.saveIntoSavedState(savedStateMap)
assertThat(savedStateMap.size).isEqualTo(1)
// Test Restore with non-empty map
matrixSessionCache.restoreWithSavedState(savedStateMap)
// Empty the map
matrixSessionCache.removeAll()
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
// Restore again
matrixSessionCache.restoreWithSavedState(savedStateMap)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
}
@Test
fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID, sessionCoroutineScope = backgroundScope))
val loginSucceeded = fakeAuthenticationService.login("user", "pass")
assertThat(loginSucceeded.isSuccess).isTrue()
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNotNull()
}
private fun TestScope.createSyncOrchestratorFactory() = object : SyncOrchestrator.Factory {
override fun create(matrixClient: MatrixClient): SyncOrchestrator {
return SyncOrchestrator(
matrixClient,
appForegroundStateService = FakeAppForegroundStateService(),
networkMonitor = FakeNetworkMonitor(),
dispatchers = testCoroutineDispatchers(),
)
}
}
}

View file

@ -8,9 +8,9 @@
package io.element.android.features.analytics.impl
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -34,7 +34,7 @@ class AnalyticsOptInNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val activity = LocalContext.current as Activity
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
val state = presenter.present()
AnalyticsOptInView(

View file

@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.push.api)
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api)
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api)
implementation(libs.androidx.webkit)
implementation(libs.coil.compose)
@ -59,6 +60,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)

View file

@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
@ -58,9 +59,9 @@ class CallScreenPresenter @AssistedInject constructor(
private val dispatchers: CoroutineDispatchers,
private val matrixClientsProvider: MatrixClientProvider,
private val screenTracker: ScreenTracker,
private val appCoroutineScope: CoroutineScope,
private val activeCallManager: ActiveCallManager,
private val languageTagProvider: LanguageTagProvider,
private val appForegroundStateService: AppForegroundStateService,
) : Presenter<CallScreenState> {
@AssistedFactory
interface Factory {
@ -226,19 +227,13 @@ class CallScreenPresenter @AssistedInject constructor(
if (state == SyncState.Running) {
client.notifyCallStartIfNeeded(callType.roomId)
} else {
client.syncService().startSync()
appForegroundStateService.updateIsInCallState(true)
}
}
}
onDispose {
// We can't use the local coroutine scope here because it will be disposed before this effect
appCoroutineScope.launch {
client.syncService().run {
if (syncState.value == SyncState.Running) {
stopSync()
}
}
}
// Make sure we mark the call as ended in the app state
appForegroundStateService.updateIsInCallState(false)
}
}
}

View file

@ -32,10 +32,9 @@ import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker
import io.element.android.services.analytics.test.FakeScreenTracker
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
@ -243,7 +242,7 @@ class CallScreenPresenterTest {
}
@Test
fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest {
fun `present - automatically sets the isInCall state when starting the call and disposing the screen`() = runTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val startSyncLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
@ -251,6 +250,7 @@ class CallScreenPresenterTest {
this.startSyncLambda = startSyncLambda
}
val matrixClient = FakeMatrixClient(syncService = syncService)
val appForegroundStateService = FakeAppForegroundStateService()
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
@ -258,34 +258,7 @@ class CallScreenPresenterTest {
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }),
screenTracker = FakeScreenTracker {},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
consumeItemsUntilTimeout()
assert(startSyncLambda).isCalledOnce()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - automatically stops the Matrix client sync on dispose`() = runTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val stopSyncLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(SyncState.Running).apply {
this.stopSyncLambda = stopSyncLambda
}
val matrixClient = FakeMatrixClient(syncService = syncService)
val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }),
screenTracker = FakeScreenTracker {},
appForegroundStateService = appForegroundStateService,
)
val hasRun = Mutex(true)
val job = launch {
@ -296,11 +269,25 @@ class CallScreenPresenterTest {
}
}
hasRun.lock()
appForegroundStateService.isInCall.test {
// The initial isInCall state will always be false
assertThat(awaitItem()).isFalse()
job.cancelAndJoin()
// Wait until the call starts
hasRun.lock()
assert(stopSyncLambda).isCalledOnce()
// Then it'll be true once the call is active
assertThat(awaitItem()).isTrue()
// If we dispose the screen
job.cancelAndJoin()
// The isInCall state is now false
assertThat(awaitItem()).isFalse()
// And there are no more events
ensureAllEventsConsumed()
}
}
@Test
@ -354,6 +341,7 @@ class CallScreenPresenterTest {
matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
screenTracker: ScreenTracker = FakeScreenTracker(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
): CallScreenPresenter {
val userAgentProvider = object : UserAgentProvider {
override fun provide(): String {
@ -369,10 +357,10 @@ class CallScreenPresenterTest {
clock = clock,
dispatchers = dispatchers,
matrixClientsProvider = matrixClientsProvider,
appCoroutineScope = this,
activeCallManager = activeCallManager,
screenTracker = screenTracker,
languageTagProvider = FakeLanguageTagProvider("en-US"),
appForegroundStateService = appForegroundStateService,
)
}
}

View file

@ -8,9 +8,9 @@
package io.element.android.features.createroom.impl.root
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -55,7 +55,7 @@ class CreateRoomRootNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalContext.current as Activity
val activity = requireNotNull(LocalActivity.current)
CreateRoomRootView(
state = state,
modifier = modifier,

View file

@ -94,7 +94,7 @@ class DefaultLockScreenService @Inject constructor(
*/
private fun observeAppForegroundState() {
coroutineScope.launch {
appForegroundStateService.start()
appForegroundStateService.startObservingForeground()
appForegroundStateService.isInForeground.collect { isInForeground ->
if (isInForeground) {
lockJob?.cancel()

View file

@ -7,11 +7,10 @@
package io.element.android.features.lockscreen.impl.unlock
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -42,7 +41,7 @@ class PinUnlockNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalContext.current as Activity
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
LaunchedEffect(state.isUnlocked) {
if (state.isUnlocked) {

View file

@ -9,10 +9,10 @@ package io.element.android.features.login.impl
import android.app.Activity
import android.os.Parcelable
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
@ -199,7 +199,7 @@ class LoginFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
activity = LocalContext.current as? Activity
activity = requireNotNull(LocalActivity.current)
darkTheme = !ElementTheme.isLightTheme
DisposableEffect(Unit) {
onDispose {

View file

@ -8,9 +8,9 @@
package io.element.android.features.login.impl.screens.createaccount
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -41,7 +41,7 @@ class CreateAccountNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val activity = LocalContext.current as Activity
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
val state = presenter.present()
CreateAccountView(

View file

@ -7,10 +7,9 @@
package io.element.android.features.logout.impl
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -42,7 +41,7 @@ class LogoutNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalContext.current as Activity
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
LogoutView(
state = state,

View file

@ -9,6 +9,7 @@ package io.element.android.features.messages.impl
import android.app.Activity
import android.content.Context
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@ -17,7 +18,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
@ -223,7 +223,7 @@ class MessagesNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val activity = LocalContext.current as Activity
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.pinned
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import javax.inject.Inject
@SingleIn(RoomScope::class)
@ -33,6 +35,7 @@ class PinnedEventsTimelineProvider @Inject constructor(
private val room: MatrixRoom,
private val syncService: SyncService,
private val featureFlagService: FeatureFlagService,
private val dispatchers: CoroutineDispatchers,
) : TimelineProvider {
private val _timelineStateFlow: MutableStateFlow<AsyncData<Timeline>> =
MutableStateFlow(AsyncData.Uninitialized)
@ -100,7 +103,9 @@ class PinnedEventsTimelineProvider @Inject constructor(
when (timelineStateFlow.value) {
is AsyncData.Uninitialized, is AsyncData.Failure -> {
timelineStateFlow.emit(AsyncData.Loading())
room.pinnedEventsTimeline()
withContext(dispatchers.io) {
room.pinnedEventsTimeline()
}
.fold(
{ timelineStateFlow.emit(AsyncData.Success(it)) },
{ timelineStateFlow.emit(AsyncData.Failure(it)) }

View file

@ -194,7 +194,8 @@ class PinnedMessagesBannerPresenterTest {
syncService = syncService,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.PinnedEvents.key to isFeatureEnabled)
)
),
dispatchers = testCoroutineDispatchers(),
)
timelineProvider.launchIn(backgroundScope)

View file

@ -38,6 +38,7 @@ import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
@ -302,7 +303,8 @@ class PinnedMessagesListPresenterTest {
syncService = syncService,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.PinnedEvents.key to isFeatureEnabled)
)
),
dispatchers = testCoroutineDispatchers(),
)
timelineProvider.launchIn(backgroundScope)
return PinnedMessagesListPresenter(

View file

@ -9,6 +9,14 @@ package io.element.android.features.networkmonitor.api
import kotlinx.coroutines.flow.StateFlow
/**
* Monitors the network status of the device, providing the current network connectivity status as a flow.
*
* **Note:** network connectivity does not imply internet connectivity. The device can be connected to a network that can't reach the homeserver.
*/
interface NetworkMonitor {
/**
* A flow containing the current network connectivity status.
*/
val connectivity: StateFlow<NetworkStatus>
}

View file

@ -7,7 +7,19 @@
package io.element.android.features.networkmonitor.api
/**
* Network connectivity status of the device.
*
* **Note:** this is *network* connectivity status, not *internet* connectivity status.
*/
enum class NetworkStatus {
Online,
Offline
/**
* The device is connected to a network.
*/
Connected,
/**
* The device is not connected to any networks.
*/
Disconnected
}

View file

@ -12,7 +12,6 @@ package io.element.android.features.networkmonitor.impl
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.networkmonitor.api.NetworkMonitor
@ -55,20 +54,18 @@ class DefaultNetworkMonitor @Inject constructor(
override fun onLost(network: Network) {
if (activeNetworksCount.decrementAndGet() == 0) {
trySendBlocking(NetworkStatus.Offline)
trySendBlocking(NetworkStatus.Disconnected)
}
}
override fun onAvailable(network: Network) {
if (activeNetworksCount.incrementAndGet() > 0) {
trySendBlocking(NetworkStatus.Online)
trySendBlocking(NetworkStatus.Connected)
}
}
}
trySendBlocking(connectivityManager.activeNetworkStatus())
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
val request = NetworkRequest.Builder().build()
connectivityManager.registerNetworkCallback(request, callback)
Timber.d("Subscribe")
@ -85,17 +82,6 @@ class DefaultNetworkMonitor @Inject constructor(
.stateIn(appCoroutineScope, SharingStarted.WhileSubscribed(), connectivityManager.activeNetworkStatus())
private fun ConnectivityManager.activeNetworkStatus(): NetworkStatus {
return activeNetwork?.let {
getNetworkCapabilities(it)?.getNetworkStatus()
} ?: NetworkStatus.Offline
}
private fun NetworkCapabilities.getNetworkStatus(): NetworkStatus {
val hasInternet = hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
return if (hasInternet) {
NetworkStatus.Online
} else {
NetworkStatus.Offline
}
return if (activeNetwork != null) NetworkStatus.Connected else NetworkStatus.Disconnected
}
}

View file

@ -11,6 +11,6 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import kotlinx.coroutines.flow.MutableStateFlow
class FakeNetworkMonitor(initialStatus: NetworkStatus = NetworkStatus.Online) : NetworkMonitor {
class FakeNetworkMonitor(initialStatus: NetworkStatus = NetworkStatus.Connected) : NetworkMonitor {
override val connectivity = MutableStateFlow(initialStatus)
}

View file

@ -8,9 +8,9 @@
package io.element.android.features.preferences.impl.about
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -41,7 +41,7 @@ class AboutNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val activity = LocalContext.current as Activity
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
val state = presenter.present()
AboutView(

View file

@ -7,10 +7,9 @@
package io.element.android.features.preferences.impl.developer
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.airbnb.android.showkase.models.Showkase
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -29,7 +28,7 @@ class DeveloperSettingsNode @AssistedInject constructor(
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val activity = LocalContext.current as Activity
val activity = requireNotNull(LocalActivity.current)
fun openShowkase() {
val intent = Showkase.getBrowserIntent(activity)
activity.startActivity(intent)

View file

@ -79,10 +79,10 @@ class DeveloperSettingsPresenter @Inject constructor(
.doesHideImagesAndVideosFlow()
.collectAsState(initial = false)
val tracingLogLevel by appPreferencesStore
.getTracingLogLevelFlow()
.map { AsyncData.Success(it.toLogLevelItem()) }
.collectAsState(initial = AsyncData.Uninitialized)
val tracingLogLevelFlow = remember {
appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) }
}
val tracingLogLevel by tracingLogLevelFlow.collectAsState(initial = AsyncData.Uninitialized)
LaunchedEffect(Unit) {
FeatureFlags.entries

View file

@ -8,9 +8,9 @@
package io.element.android.features.preferences.impl.root
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -113,7 +113,7 @@ class PreferencesRootNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalContext.current as Activity
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
PreferencesRootView(
state = state,

View file

@ -7,10 +7,9 @@
package io.element.android.features.rageshake.impl.bugreport
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -38,7 +37,7 @@ class BugReportNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalContext.current as? Activity
val activity = LocalActivity.current
BugReportView(
state = state,
modifier = modifier,

View file

@ -8,9 +8,9 @@
package io.element.android.features.roomlist.impl
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -92,7 +92,7 @@ class RoomListNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalContext.current as Activity
val activity = requireNotNull(LocalActivity.current)
RoomListView(
state = state,

View file

@ -9,11 +9,11 @@ package io.element.android.features.securebackup.impl.reset
import android.app.Activity
import android.os.Parcelable
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
@ -160,7 +160,7 @@ class ResetIdentityFlowNode @AssistedInject constructor(
override fun View(modifier: Modifier) {
// Workaround to get the current activity
if (!this::activity.isInitialized) {
activity = LocalContext.current as Activity
activity = requireNotNull(LocalActivity.current)
}
val startResetState by resetIdentityFlowManager.currentHandleFlow.collectAsState()

View file

@ -8,9 +8,9 @@
package io.element.android.features.verifysession.impl.outgoing
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -45,7 +45,7 @@ class VerifySelfSessionNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalContext.current as Activity
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
VerifySelfSessionView(
state = state,

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,

View file

@ -18,8 +18,28 @@ interface AppForegroundStateService {
*/
val isInForeground: StateFlow<Boolean>
/**
* Updates to whether the app is in an active call or not will be emitted here.
*/
val isInCall: StateFlow<Boolean>
/**
* Updates to whether the app is syncing a notification event or not will be emitted here.
*/
val isSyncingNotificationEvent: StateFlow<Boolean>
/**
* Start observing the foreground state.
*/
fun start()
fun startObservingForeground()
/**
* Update the in-call state.
*/
fun updateIsInCallState(isInCall: Boolean)
/**
* Update the active state for the syncing notification event flow.
*/
fun updateIsSyncingNotificationEvent(isSyncingNotificationEvent: Boolean)
}

View file

@ -12,19 +12,27 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class DefaultAppForegroundStateService : AppForegroundStateService {
private val state = MutableStateFlow(false)
override val isInForeground: StateFlow<Boolean> = state
override val isInForeground = MutableStateFlow(false)
override val isInCall = MutableStateFlow(false)
override val isSyncingNotificationEvent = MutableStateFlow(false)
private val appLifecycle: Lifecycle by lazy { ProcessLifecycleOwner.get().lifecycle }
override fun start() {
override fun startObservingForeground() {
appLifecycle.addObserver(lifecycleObserver)
}
private val lifecycleObserver = LifecycleEventObserver { _, _ -> state.value = getCurrentState() }
override fun updateIsInCallState(isInCall: Boolean) {
this.isInCall.value = isInCall
}
override fun updateIsSyncingNotificationEvent(isSyncingNotificationEvent: Boolean) {
this.isSyncingNotificationEvent.value = isSyncingNotificationEvent
}
private val lifecycleObserver = LifecycleEventObserver { _, _ -> isInForeground.value = getCurrentState() }
private fun getCurrentState(): Boolean = appLifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}

View file

@ -48,7 +48,7 @@ class DefaultAppNavigationStateService @Inject constructor(
init {
coroutineScope.launch {
appForegroundStateService.start()
appForegroundStateService.startObservingForeground()
appForegroundStateService.isInForeground.collect { isInForeground ->
state.getAndUpdate { it.copy(isInForeground = isInForeground) }
}

View file

@ -9,19 +9,29 @@ package io.element.android.services.appnavstate.test
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FakeAppForegroundStateService(
initialValue: Boolean = true,
initialForegroundValue: Boolean = true,
initialIsInCallValue: Boolean = false,
initialIsSyncingNotificationEventValue: Boolean = false
) : AppForegroundStateService {
private val state = MutableStateFlow(initialValue)
override val isInForeground: StateFlow<Boolean> = state
override val isInForeground = MutableStateFlow(initialForegroundValue)
override val isInCall = MutableStateFlow(initialIsInCallValue)
override val isSyncingNotificationEvent = MutableStateFlow(initialIsSyncingNotificationEventValue)
override fun start() {
override fun startObservingForeground() {
// No-op
}
fun givenIsInForeground(isInForeground: Boolean) {
state.value = isInForeground
this.isInForeground.value = isInForeground
}
override fun updateIsInCallState(isInCall: Boolean) {
this.isInCall.value = isInCall
}
override fun updateIsSyncingNotificationEvent(isSyncingNotificationEvent: Boolean) {
this.isSyncingNotificationEvent.value = isSyncingNotificationEvent
}
}