First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.appnavstate.api"
}
dependencies {
implementation(libs.coroutines.core)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.startup)
implementation(projects.libraries.matrix.api)
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.services.appnavstate.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
/**
* Holds the active rooms for a given session so they can be reused instead of instantiating new ones.
*/
interface ActiveRoomsHolder {
/**
* Adds a new held room for the given sessionId.
*/
fun addRoom(room: JoinedRoom)
/**
* Returns the last room added for the given [sessionId] or null if no room was added.
*/
fun getActiveRoom(sessionId: SessionId): JoinedRoom?
/**
* Returns an active room associated to the given [sessionId], with the given [roomId], or null if none match.
*/
fun getActiveRoomMatching(sessionId: SessionId, roomId: RoomId): JoinedRoom?
/**
* Removes any room matching the provided [sessionId] and [roomId].
*/
fun removeRoom(sessionId: SessionId, roomId: RoomId)
/**
* Clears all the rooms for the given sessionId.
*/
fun clear(sessionId: SessionId)
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.api
import kotlinx.coroutines.flow.StateFlow
/**
* A service that tracks the foreground state of the app.
*/
interface AppForegroundStateService {
/**
* Any updates to the foreground state of the app will be emitted here.
*/
val isInForeground: StateFlow<Boolean>
/**
* Updates to whether the app is active because an incoming ringing call is happening will be emitted here.
*/
val hasRingingCall: 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 startObservingForeground()
/**
* Update the in-call state.
*/
fun updateIsInCallState(isInCall: Boolean)
/**
* Update the 'has ringing call' state.
*/
fun updateHasRingingCall(hasRingingCall: Boolean)
/**
* Update the active state for the syncing notification event flow.
*/
fun updateIsSyncingNotificationEvent(isSyncingNotificationEvent: Boolean)
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.api
/**
* A wrapper for the current navigation state of the app, along with its foreground/background state.
*/
data class AppNavigationState(
val navigationState: NavigationState,
val isInForeground: Boolean,
)
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
import kotlinx.coroutines.flow.StateFlow
/**
* A service that tracks the navigation and foreground states of the app.
*/
interface AppNavigationStateService {
val appNavigationState: StateFlow<AppNavigationState>
fun onNavigateToSession(owner: String, sessionId: SessionId)
fun onLeavingSession(owner: String)
fun onNavigateToSpace(owner: String, spaceId: SpaceId)
fun onLeavingSpace(owner: String)
fun onNavigateToRoom(owner: String, roomId: RoomId)
fun onLeavingRoom(owner: String)
fun onNavigateToThread(owner: String, threadId: ThreadId)
fun onLeavingThread(owner: String)
}
@@ -0,0 +1,10 @@
/*
* Copyright (c) 2025 Element Creations 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.services.appnavstate.api
const val ROOM_OPENED_FROM_NOTIFICATION = "opened_from_notification"
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
/**
* Can represent the current global app navigation state.
* @param owner mostly a Node identifier associated with the state.
* We are using the owner parameter to check when calling onLeaving methods is still using the same owner than his companion onNavigate.
* Why this is needed : for now we rely on lifecycle methods of the node, which are async.
* If you navigate quickly between nodes, onCreate of the new node is called before onDestroy of the previous node.
* So we assume if we don't get the same owner, we can skip the onLeaving action as we already replaced it.
*/
sealed class NavigationState(open val owner: String) {
data object Root : NavigationState("ROOT")
data class Session(
override val owner: String,
val sessionId: SessionId,
) : NavigationState(owner)
data class Space(
override val owner: String,
// Can be fake value, if no space is selected
val spaceId: SpaceId,
val parentSession: Session,
) : NavigationState(owner)
data class Room(
override val owner: String,
val roomId: RoomId,
val parentSpace: Space,
) : NavigationState(owner)
data class Thread(
override val owner: String,
val threadId: ThreadId,
val parentRoom: Room,
) : NavigationState(owner)
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.api
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
fun NavigationState.currentSessionId(): SessionId? {
return when (this) {
NavigationState.Root -> null
is NavigationState.Session -> sessionId
is NavigationState.Space -> parentSession.sessionId
is NavigationState.Room -> parentSpace.parentSession.sessionId
is NavigationState.Thread -> parentRoom.parentSpace.parentSession.sessionId
}
}
fun NavigationState.currentSpaceId(): SpaceId? {
return when (this) {
NavigationState.Root -> null
is NavigationState.Session -> null
is NavigationState.Space -> spaceId
is NavigationState.Room -> parentSpace.spaceId
is NavigationState.Thread -> parentRoom.parentSpace.spaceId
}
}
fun NavigationState.currentRoomId(): RoomId? {
return when (this) {
NavigationState.Root -> null
is NavigationState.Session -> null
is NavigationState.Space -> null
is NavigationState.Room -> roomId
is NavigationState.Thread -> parentRoom.roomId
}
}
fun NavigationState.currentThreadId(): ThreadId? {
return when (this) {
NavigationState.Root -> null
is NavigationState.Session -> null
is NavigationState.Space -> null
is NavigationState.Room -> null
is NavigationState.Thread -> threadId
}
}
@@ -0,0 +1,36 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-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.
*/
plugins {
id("io.element.android-library")
}
setupDependencyInjection()
android {
namespace = "io.element.android.services.appnavstate.impl"
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)
implementation(libs.coroutines.core)
implementation(libs.androidx.corektx)
implementation(libs.androidx.lifecycle.process)
api(projects.services.appnavstate.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.appnavstate.test)
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.services.appnavstate.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import java.util.concurrent.ConcurrentHashMap
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultActiveRoomsHolder : ActiveRoomsHolder {
private val rooms = ConcurrentHashMap<SessionId, MutableSet<JoinedRoom>>()
override fun addRoom(room: JoinedRoom) {
val roomsForSessionId = rooms.getOrPut(key = room.sessionId, defaultValue = { mutableSetOf() })
if (roomsForSessionId.none { it.roomId == room.roomId }) {
// We don't want to add the same room multiple times
roomsForSessionId.add(room)
}
}
override fun getActiveRoom(sessionId: SessionId): JoinedRoom? {
return rooms[sessionId]?.lastOrNull()
}
override fun getActiveRoomMatching(sessionId: SessionId, roomId: RoomId): JoinedRoom? {
return rooms[sessionId]?.find { it.roomId == roomId }
}
override fun removeRoom(sessionId: SessionId, roomId: RoomId) {
val roomsForSessionId = rooms[sessionId] ?: return
roomsForSessionId.removeIf { it.roomId == roomId }
}
override fun clear(sessionId: SessionId) {
val activeRooms = rooms.remove(sessionId) ?: return
for (room in activeRooms) {
// Destroy the room to reset the live timelines
room.destroy()
}
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.impl
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.flow.MutableStateFlow
class DefaultAppForegroundStateService : AppForegroundStateService {
override val isInForeground = MutableStateFlow(false)
override val isInCall = MutableStateFlow(false)
override val isSyncingNotificationEvent = MutableStateFlow(false)
override val hasRingingCall = MutableStateFlow(false)
private val appLifecycle: Lifecycle by lazy { ProcessLifecycleOwner.get().lifecycle }
override fun startObservingForeground() {
appLifecycle.addObserver(lifecycleObserver)
}
override fun updateIsInCallState(isInCall: Boolean) {
this.isInCall.value = isInCall
}
override fun updateHasRingingCall(hasRingingCall: Boolean) {
this.hasRingingCall.value = hasRingingCall
}
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)
}
@@ -0,0 +1,172 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.launch
import timber.log.Timber
private val loggerTag = LoggerTag("Navigation")
/**
* TODO This will maybe not support properly navigation using permalink.
*/
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultAppNavigationStateService(
private val appForegroundStateService: AppForegroundStateService,
@AppCoroutineScope
coroutineScope: CoroutineScope,
) : AppNavigationStateService {
private val state = MutableStateFlow(
AppNavigationState(
navigationState = NavigationState.Root,
isInForeground = true,
)
)
override val appNavigationState: StateFlow<AppNavigationState> = state
init {
coroutineScope.launch {
appForegroundStateService.startObservingForeground()
appForegroundStateService.isInForeground.collect { isInForeground ->
state.getAndUpdate { it.copy(isInForeground = isInForeground) }
}
}
}
override fun onNavigateToSession(owner: String, sessionId: SessionId) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Navigating to session $sessionId. Current state: $currentValue")
val newValue: NavigationState.Session = when (currentValue) {
is NavigationState.Session,
is NavigationState.Space,
is NavigationState.Room,
is NavigationState.Thread,
is NavigationState.Root -> NavigationState.Session(owner, sessionId)
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onNavigateToSpace(owner: String, spaceId: SpaceId) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Navigating to space $spaceId. Current state: $currentValue")
val newValue: NavigationState.Space = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> NavigationState.Space(owner, spaceId, currentValue)
is NavigationState.Space -> NavigationState.Space(owner, spaceId, currentValue.parentSession)
is NavigationState.Room -> NavigationState.Space(owner, spaceId, currentValue.parentSpace.parentSession)
is NavigationState.Thread -> NavigationState.Space(owner, spaceId, currentValue.parentRoom.parentSpace.parentSession)
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onNavigateToRoom(owner: String, roomId: RoomId) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Navigating to room $roomId. Current state: $currentValue")
val newValue: NavigationState.Room = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> NavigationState.Room(owner, roomId, currentValue)
is NavigationState.Room -> NavigationState.Room(owner, roomId, currentValue.parentSpace)
is NavigationState.Thread -> NavigationState.Room(owner, roomId, currentValue.parentRoom.parentSpace)
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onNavigateToThread(owner: String, threadId: ThreadId) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Navigating to thread $threadId. Current state: $currentValue")
val newValue: NavigationState.Thread = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> return logError("onNavigateToRoom()")
is NavigationState.Room -> NavigationState.Thread(owner, threadId, currentValue)
is NavigationState.Thread -> NavigationState.Thread(owner, threadId, currentValue.parentRoom)
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onLeavingThread(owner: String) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Leaving thread. Current state: $currentValue")
if (!currentValue.assertOwner(owner)) return
val newValue: NavigationState.Room = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> return logError("onNavigateToRoom()")
is NavigationState.Room -> return logError("onNavigateToThread()")
is NavigationState.Thread -> currentValue.parentRoom
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onLeavingRoom(owner: String) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Leaving room. Current state: $currentValue")
if (!currentValue.assertOwner(owner)) return
val newValue: NavigationState.Space = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> return logError("onNavigateToRoom()")
is NavigationState.Room -> currentValue.parentSpace
is NavigationState.Thread -> currentValue.parentRoom.parentSpace
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onLeavingSpace(owner: String) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Leaving space. Current state: $currentValue")
if (!currentValue.assertOwner(owner)) return
val newValue: NavigationState.Session = when (currentValue) {
NavigationState.Root -> return logError("onNavigateToSession()")
is NavigationState.Session -> return logError("onNavigateToSpace()")
is NavigationState.Space -> currentValue.parentSession
is NavigationState.Room -> currentValue.parentSpace.parentSession
is NavigationState.Thread -> currentValue.parentRoom.parentSpace.parentSession
}
state.getAndUpdate { it.copy(navigationState = newValue) }
}
override fun onLeavingSession(owner: String) {
val currentValue = state.value.navigationState
Timber.tag(loggerTag.value).d("Leaving session. Current state: $currentValue")
if (!currentValue.assertOwner(owner)) return
state.getAndUpdate { it.copy(navigationState = NavigationState.Root) }
}
private fun logError(logPrefix: String) {
Timber.tag(loggerTag.value).w("$logPrefix must be call first.")
}
private fun NavigationState.assertOwner(owner: String): Boolean {
if (this.owner != owner) {
Timber.tag(loggerTag.value).d("Can't leave current state as the owner is not the same (current = ${this.owner}, new = $owner)")
return false
}
return true
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.impl.di
import android.content.Context
import androidx.startup.AppInitializer
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.appnavstate.impl.initializer.AppForegroundStateServiceInitializer
@BindingContainer
@ContributesTo(AppScope::class)
object AppNavStateModule {
@Provides
fun provideAppForegroundStateService(
@ApplicationContext context: Context
): AppForegroundStateService =
AppInitializer.getInstance(context).initializeComponent(AppForegroundStateServiceInitializer::class.java)
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.impl.initializer
import android.content.Context
import androidx.lifecycle.ProcessLifecycleInitializer
import androidx.startup.Initializer
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.appnavstate.impl.DefaultAppForegroundStateService
class AppForegroundStateServiceInitializer : Initializer<AppForegroundStateService> {
override fun create(context: Context): AppForegroundStateService {
return DefaultAppForegroundStateService()
}
override fun dependencies(): MutableList<Class<out Initializer<*>>> = mutableListOf(
ProcessLifecycleInitializer::class.java
)
}
@@ -0,0 +1,340 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_SPACE_ID
import io.element.android.libraries.matrix.test.A_SPACE_ID_2
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID_2
import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
import io.element.android.services.appnavstate.test.A_ROOM_OWNER
import io.element.android.services.appnavstate.test.A_SESSION_OWNER
import io.element.android.services.appnavstate.test.A_SPACE_OWNER
import io.element.android.services.appnavstate.test.A_THREAD_OWNER
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultNavigationStateServiceTest {
private val navigationStateRoot = NavigationState.Root
private val navigationStateSession = NavigationState.Session(
owner = A_SESSION_OWNER,
sessionId = A_SESSION_ID
)
private val navigationStateSpace = NavigationState.Space(
owner = A_SPACE_OWNER,
spaceId = A_SPACE_ID,
parentSession = navigationStateSession
)
private val navigationStateRoom = NavigationState.Room(
owner = A_ROOM_OWNER,
roomId = A_ROOM_ID,
parentSpace = navigationStateSpace
)
private val navigationStateThread = NavigationState.Thread(
owner = A_THREAD_OWNER,
threadId = A_THREAD_ID,
parentRoom = navigationStateRoom
)
@Test
fun testNavigation() = runTest {
val service = createStateService()
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread)
// Leaving the states
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
}
@Test
fun testFailure() = runTest {
val service = createStateService()
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.value.navigationState).isEqualTo(NavigationState.Root)
}
@Test
fun testOnNavigateToThread() = runTest {
val service = createStateService()
// From root (no effect)
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect)
service.reset()
service.navigateToSession()
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space (no effect)
service.reset()
service.navigateToSpace()
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From room
service.reset()
service.navigateToRoom()
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread)
// From thread
service.reset()
service.navigateToThread()
// Navigate to another thread
service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID_2)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread.copy(threadId = A_THREAD_ID_2))
}
@Test
fun testOnNavigateToRoom() = runTest {
val service = createStateService()
// From root (no effect)
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect)
service.reset()
service.navigateToSession()
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space
service.reset()
service.navigateToSpace()
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
// From room
service.reset()
service.navigateToRoom()
// Navigate to another room
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID_2)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom.copy(roomId = A_ROOM_ID_2))
// From thread
service.reset()
service.navigateToThread()
service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
}
@Test
fun testOnNavigateToSpace() = runTest {
val service = createStateService()
// From root (no effect)
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session
service.reset()
service.navigateToSession()
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From space
service.reset()
service.navigateToSpace()
// Navigate to another space
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID_2)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace.copy(spaceId = A_SPACE_ID_2))
// From room (no effect)
service.reset()
service.navigateToRoom()
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From thread (no effect)
service.reset()
service.navigateToThread()
service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
}
@Test
fun testOnNavigateToSession() = runTest {
val service = createStateService()
// From root
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From session
service.reset()
service.navigateToSession()
// Navigate to another session
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID_2)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession.copy(sessionId = A_SESSION_ID_2))
// From space
service.reset()
service.navigateToSpace()
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From room
service.reset()
service.navigateToRoom()
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From thread
service.reset()
service.navigateToThread()
service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
}
@Test
fun testOnLeavingThread() = runTest {
val service = createStateService()
// From root (no effect)
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect)
service.reset()
service.navigateToSession()
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space (no effect)
service.reset()
service.navigateToSpace()
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From room (no effect)
service.reset()
service.navigateToRoom()
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
// From thread
service.reset()
service.navigateToThread()
service.onLeavingThread(A_THREAD_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
}
@Test
fun testOnLeavingRoom() = runTest {
val service = createStateService()
// From root (no effect)
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect)
service.reset()
service.navigateToSession()
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space (no effect)
service.reset()
service.navigateToSpace()
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From room
service.reset()
service.navigateToRoom()
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From thread (no effect)
service.reset()
service.navigateToThread()
service.onLeavingRoom(A_ROOM_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread)
}
@Test
fun testOnLeavingSpace() = runTest {
val service = createStateService()
// From root (no effect)
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session (no effect)
service.reset()
service.navigateToSession()
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From space
service.reset()
service.navigateToSpace()
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession)
// From room (no effect)
service.reset()
service.navigateToRoom()
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
// From thread (no effect)
service.reset()
service.navigateToThread()
service.onLeavingSpace(A_SPACE_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread)
}
@Test
fun testOnLeavingSession() = runTest {
val service = createStateService()
// From root
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From session
service.reset()
service.navigateToSession()
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot)
// From space (no effect)
service.reset()
service.navigateToSpace()
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace)
// From room (no effect)
service.reset()
service.navigateToRoom()
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom)
// From thread (no effect)
service.reset()
service.navigateToThread()
service.onLeavingSession(A_SESSION_OWNER)
assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread)
}
private fun AppNavigationStateService.reset() {
navigateToSession()
onLeavingSession(A_SESSION_OWNER)
}
private fun AppNavigationStateService.navigateToSession() {
onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
}
private fun AppNavigationStateService.navigateToSpace() {
navigateToSession()
onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
}
private fun AppNavigationStateService.navigateToRoom() {
navigateToSpace()
onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
}
private fun AppNavigationStateService.navigateToThread() {
navigateToRoom()
onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
}
private fun TestScope.createStateService() = DefaultAppNavigationStateService(
appForegroundStateService = FakeAppForegroundStateService(),
coroutineScope = backgroundScope,
)
}
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.appnavstate.test"
}
dependencies {
api(projects.libraries.matrix.api)
api(projects.services.appnavstate.api)
implementation(libs.coroutines.core)
implementation(libs.androidx.lifecycle.runtime)
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.test
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.services.appnavstate.api.NavigationState
const val A_SESSION_OWNER = "aSessionOwner"
const val A_SPACE_OWNER = "aSpaceOwner"
const val A_ROOM_OWNER = "aRoomOwner"
const val A_THREAD_OWNER = "aThreadOwner"
fun aNavigationState(
sessionId: SessionId? = null,
spaceId: SpaceId? = MAIN_SPACE,
roomId: RoomId? = null,
threadId: ThreadId? = null,
): NavigationState {
if (sessionId == null) {
return NavigationState.Root
}
val session = NavigationState.Session(A_SESSION_OWNER, sessionId)
if (spaceId == null) {
return session
}
val space = NavigationState.Space(A_SPACE_OWNER, spaceId, session)
if (roomId == null) {
return space
}
val room = NavigationState.Room(A_ROOM_OWNER, roomId, space)
if (threadId == null) {
return room
}
return NavigationState.Thread(A_THREAD_OWNER, threadId, room)
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.test
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.flow.MutableStateFlow
class FakeAppForegroundStateService(
initialForegroundValue: Boolean = true,
initialIsInCallValue: Boolean = false,
initialIsSyncingNotificationEventValue: Boolean = false,
initialHasRingingCall: Boolean = false,
) : AppForegroundStateService {
override val isInForeground = MutableStateFlow(initialForegroundValue)
override val isInCall = MutableStateFlow(initialIsInCallValue)
override val isSyncingNotificationEvent = MutableStateFlow(initialIsSyncingNotificationEventValue)
override val hasRingingCall = MutableStateFlow(initialHasRingingCall)
override fun startObservingForeground() {
// No-op
}
fun givenIsInForeground(isInForeground: Boolean) {
this.isInForeground.value = isInForeground
}
override fun updateIsInCallState(isInCall: Boolean) {
this.isInCall.value = isInCall
}
override fun updateIsSyncingNotificationEvent(isSyncingNotificationEvent: Boolean) {
this.isSyncingNotificationEvent.value = isSyncingNotificationEvent
}
override fun updateHasRingingCall(hasRingingCall: Boolean) {
this.hasRingingCall.value = hasRingingCall
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.services.appnavstate.test
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
import kotlinx.coroutines.flow.MutableStateFlow
class FakeAppNavigationStateService(
override val appNavigationState: MutableStateFlow<AppNavigationState> = MutableStateFlow(
AppNavigationState(
navigationState = NavigationState.Root,
isInForeground = true,
)
),
) : AppNavigationStateService {
override fun onNavigateToSession(owner: String, sessionId: SessionId) = Unit
override fun onLeavingSession(owner: String) = Unit
override fun onNavigateToSpace(owner: String, spaceId: SpaceId) = Unit
override fun onLeavingSpace(owner: String) = Unit
override fun onNavigateToRoom(owner: String, roomId: RoomId) = Unit
override fun onLeavingRoom(owner: String) = Unit
override fun onNavigateToThread(owner: String, threadId: ThreadId) = Unit
override fun onLeavingThread(owner: String) = Unit
}