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