Repository: element-hq/element-x-android (language: kotlin)

Issue:
Notifications are displayed for messages sent to the current room
### Steps to reproduce

1. Open a room in the app.
2. From another client, send a message.


### Outcome

#### What did you expect?

Since the room is already displayed, no notification should be displayed.

#### What happened instead?

The notification is displayed.

### Your phone model

OnePlus 6T

### Operating system version

Android 11.0

### Application version and app store

Element X - develop (106d0708462a0f45a0c04102012f8c3cdbe6e1c3)

### Homeserver

_No response_

### Will you send logs?

No

### Are you willing to provide a PR?

Yes


Discussion hints:
nan

Pull request: Fix: make sure we ignore notifications for open rooms
## Changes

To ignore notifications for the currently displayed room, we were previously checking `ProcessLifecycleOwner.get().lifecycle` in `NotifiableMessageEvent`. However, this value seems to always return the `INITIALIZED`, not ever updating.

The new changes add an observer that publishes the current app state to `AppNavigationStateService`.

## Why

Fixes #866.

Test command: ./gradlew :appnav:testDebugUnitTest --tests "io.element.android.appnav.RoomFlowNodeTest" :services:appnavstate:impl:testDebugUnitTest --tests "io.element.android.services.appnavstate.impl.DefaultNavigationStateServiceTest" :tests:testutils:testDebugUnitTest --tests "io.element.android.tests.testutils.RunCancellableTest" :libraries:push:impl:testDebugUnitTest --tests "io.element.android.libraries.push.impl.notifications.NotifiableEventProcessorTest"

Fix patch:
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index dd198cd5aad..32bfd40629b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -209,6 +209,7 @@ dependencies {
     implementation(libs.androidx.core)
     implementation(libs.androidx.corektx)
     implementation(libs.androidx.lifecycle.runtime)
+    implementation(libs.androidx.lifecycle.process)
     implementation(libs.androidx.activity.compose)
     implementation(libs.androidx.startup)
     implementation(libs.androidx.preference)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c77fba93e11..2917c5199ba 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -32,6 +32,18 @@
         android:theme="@style/Theme.ElementX"
         tools:targetApi="33">
 
+        <provider
+            android:name="androidx.startup.InitializationProvider"
+            android:authorities="${applicationId}.androidx-startup"
+            android:exported="false"
+            tools:node="merge">
+
+            <meta-data
+                android:name='androidx.lifecycle.ProcessLifecycleInitializer'
+                android:value='androidx.startup' />
+
+        </provider>
+
         <activity
             android:name=".MainActivity"
             android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts
index ebd4c8f1058..b639f61b8b0 100644
--- a/libraries/push/impl/build.gradle.kts
+++ b/libraries/push/impl/build.gradle.kts
@@ -31,7 +31,6 @@ dependencies {
     implementation(libs.dagger)
     implementation(libs.androidx.corektx)
     implementation(libs.androidx.datastore.preferences)
-    implementation(libs.androidx.lifecycle.process)
     implementation(libs.androidx.security.crypto)
     implementation(libs.network.retrofit)
     implementation(libs.serialization.json)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt
index cd0274016b9..41d38f3ff50 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt
@@ -32,9 +32,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
 import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
 import io.element.android.libraries.push.api.store.PushDataStore
 import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
-import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
-import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
-import io.element.android.services.appnavstate.api.AppNavigationState
+import io.element.android.services.appnavstate.api.NavigationState
 import io.element.android.services.appnavstate.api.AppNavigationStateService
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.delay
@@ -65,7 +63,6 @@ class DefaultNotificationDrawerManager @Inject constructor(
      * Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
      */
     private val notificationState by lazy { createInitialNotificationState() }
-    private var currentAppNavigationState: AppNavigationState? = null
     private val firstThrottler = FirstThrottler(200)
 
     // TODO EAx add a setting per user for this
@@ -74,26 +71,25 @@ class DefaultNotificationDrawerManager @Inject constructor(
     init {
         // Observe application state
         coroutineScope.launch {
-            appNavigationStateService.appNavigationStateFlow
-                .collect { onAppNavigationStateChange(it) }
+            appNavigationStateService.appNavigationState
+                .collect { onAppNavigationStateChange(it.navigationState) }
         }
     }
 
-    private fun onAppNavigationStateChange(appNavigationState: AppNavigationState) {
-        currentAppNavigationState = appNavigationState
-        when (appNavigationState) {
-            AppNavigationState.Root -> {}
-            is AppNavigationState.Session -> {}
-            is AppNavigationState.Space -> {}
-            is AppNavigationState.Room -> {
+    private fun onAppNavigationStateChange(navigationState: NavigationState) {
+        when (navigationState) {
+            NavigationState.Root -> {}
+            is NavigationState.Session -> {}
+            is NavigationState.Space -> {}
+            is NavigationState.Room -> {
                 // Cleanup notification for current room
-                clearMessagesForRoom(appNavigationState.parentSpace.parentSession.sessionId, appNavigationState.roomId)
+                clearMessagesForRoom(navigationState.parentSpace.parentSession.sessionId, navigationState.roomId)
             }
-            is AppNavigationState.Thread -> {
+            is NavigationState.Thread -> {
                 onEnteringThread(
-                    appNavigationState.parentRoom.parentSpace.parentSession.sessionId,
-                    appNavigationState.parentRoom.roomId,
-                    appNavigationState.threadId
+                    navigationState.parentRoom.parentSpace.parentSession.sessionId,
+                    navigationState.parentRoom.roomId,
+                    navigationState.threadId
                 )
             }
         }
@@ -225,7 +221,7 @@ class DefaultNotificationDrawerManager @Inject constructor(
     private suspend fun refreshNotificationDrawerBg() {
         Timber.v("refreshNotificationDrawerBg()")
         val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
-            notifiableEventProcessor.process(queuedEvents.rawEvents(), currentAppNavigationState, renderedEvents).also {
+            notifiableEventProcessor.process(queuedEvents.rawEvents(), renderedEvents).also {
                 queuedEvents.clearAndAdd(it.onlyKeptEvents())
             }
         }
@@ -275,8 +271,4 @@ class DefaultNotificationDrawerManager @Inject constructor(
             notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents)
         }
     }
-
-    fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean {
-        return resolvedEvent.shouldIgnoreEventInRoom(currentAppNavigationState)
-    }
 }
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt
index 4202ef78d4b..50f1b887835 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt
@@ -23,7 +23,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableEven
 import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
 import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
 import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
-import io.element.android.services.appnavstate.api.AppNavigationState
+import io.element.android.services.appnavstate.api.AppNavigationStateService
 import timber.log.Timber
 import javax.inject.Inject
 
@@ -31,18 +31,19 @@ private typealias ProcessedEvents = List<ProcessedEvent<NotifiableEvent>>
 
 class NotifiableEventProcessor @Inject constructor(
     private val outdatedDetector: OutdatedEventDetector,
+    private val appNavigationStateService: AppNavigationStateService,
 ) {
 
     fun process(
         queuedEvents: List<NotifiableEvent>,
-        appNavigationState: AppNavigationState?,
         renderedEvents: ProcessedEvents,
     ): ProcessedEvents {
+        val appState = appNavigationStateService.appNavigationState.value
         val processedEvents = queuedEvents.map {
             val type = when (it) {
                 is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP
                 is NotifiableMessageEvent -> when {
-                    it.shouldIgnoreEventInRoom(appNavigationState) -> {
+                    it.shouldIgnoreEventInRoom(appState) -> {
                         ProcessedEvent.Type.REMOVE
                             .also { Timber.d("notification message removed due to currently viewing the same room or thread") }
                     }
@@ -55,7 +56,7 @@ class NotifiableEventProcessor @Inject constructor(
                     else -> ProcessedEvent.Type.KEEP
                 }
                 is FallbackNotifiableEvent -> when {
-                    it.shouldIgnoreEventInRoom(appNavigationState) -> {
+                    it.shouldIgnoreEventInRoom(appState) -> {
                         ProcessedEvent.Type.REMOVE
                             .also { Timber.d("notification fallback removed due to currently viewing the same room or thread") }
                     }
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt
index 7730066d313..57a3eb45aa7 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt
@@ -16,8 +16,6 @@
 package io.element.android.libraries.push.impl.notifications.model
 
 import android.net.Uri
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.ProcessLifecycleOwner
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.RoomId
 import io.element.android.libraries.matrix.api.core.SessionId
@@ -69,18 +67,13 @@ data class NotifiableMessageEvent(
 /**
  * Used to check if a notification should be ignored based on the current app and navigation state.
  */
-fun NotifiableEvent.shouldIgnoreEventInRoom(
-    appNavigationState: AppNavigationState?
-): Boolean {
-    val currentSessionId = appNavigationState?.currentSessionId() ?: return false
-    return when (val currentRoomId = appNavigationState.currentRoomId()) {
+fun NotifiableEvent.shouldIgnoreEventInRoom(appNavigationState: AppNavigationState): Boolean {
+    val currentSessionId = appNavigationState.navigationState.currentSessionId() ?: return false
+    return when (val currentRoomId = appNavigationState.navigationState.currentRoomId()) {
         null -> false
-        else -> isAppInForeground
+        else -> appNavigationState.isInForeground
             && sessionId == currentSessionId
             && roomId == currentRoomId
-            && (this as? NotifiableMessageEvent)?.threadId == appNavigationState.currentThreadId()
+            && (this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId()
     }
 }
-
-private val isAppInForeground: Boolean
-    get() = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
diff --git a/services/appnavstate/api/build.gradle.kts b/services/appnavstate/api/build.gradle.kts
index b7ce6161fbb..9ae81e15aad 100644
--- a/services/appnavstate/api/build.gradle.kts
+++ b/services/appnavstate/api/build.gradle.kts
@@ -24,5 +24,8 @@ android {
 
 dependencies {
     implementation(libs.coroutines.core)
+    implementation(libs.androidx.lifecycle.runtime)
+    implementation(libs.androidx.lifecycle.process)
+    implementation(libs.androidx.startup)
     implementation(projects.libraries.matrix.api)
 }
diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt
new file mode 100644
index 00000000000..098769c3708
--- /dev/null
+++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppForegroundStateService.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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>
+
+    /**
+     * Start observing the foreground state.
+     */
+    fun start()
+}
diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt
index 5ead00c976d..0a6ab692d2f 100644
--- a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt
+++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt
@@ -16,43 +16,10 @@
 
 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.
+ * A wrapper for the current navigation state of the app, along with its foreground/background state.
  */
-sealed class AppNavigationState(open val owner: String) {
-    object Root : AppNavigationState("ROOT")
-
-    data class Session(
-        override val owner: String,
-        val sessionId: SessionId,
-    ) : AppNavigationState(owner)
-
-    data class Space(
-        override val owner: String,
-        // Can be fake value, if no space is selected
-        val spaceId: SpaceId,
-        val parentSession: Session,
-    ) : AppNavigationState(owner)
-
-    data class Room(
-        override val owner: String,
-        val roomId: RoomId,
-        val parentSpace: Space,
-    ) : AppNavigationState(owner)
-
-    data class Thread(
-        override val owner: String,
-        val threadId: ThreadId,
-        val parentRoom: Room,
-    ) : AppNavigationState(owner)
-}
+data class AppNavigationState(
+    val navigationState: NavigationState,
+    val isInForeground: Boolean,
+)
diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt
deleted file mode 100644
index 00fe638a478..00000000000
--- a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateExtension.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Copyright (c) 2023 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-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 AppNavigationState.currentSessionId(): SessionId? {
-    return when (this) {
-        AppNavigationState.Root -> null
-        is AppNavigationState.Session -> sessionId
-        is AppNavigationState.Space -> parentSession.sessionId
-        is AppNavigationState.Room -> parentSpace.parentSession.sessionId
-        is AppNavigationState.Thread -> parentRoom.parentSpace.parentSession.sessionId
-    }
-}
-
-fun AppNavigationState.currentSpaceId(): SpaceId? {
-    return when (this) {
-        AppNavigationState.Root -> null
-        is AppNavigationState.Session -> null
-        is AppNavigationState.Space -> spaceId
-        is AppNavigationState.Room -> parentSpace.spaceId
-        is AppNavigationState.Thread -> parentRoom.parentSpace.spaceId
-    }
-}
-
-fun AppNavigationState.currentRoomId(): RoomId? {
-    return when (this) {
-        AppNavigationState.Root -> null
-        is AppNavigationState.Session -> null
-        is AppNavigationState.Space -> null
-        is AppNavigationState.Room -> roomId
-        is AppNavigationState.Thread -> parentRoom.roomId
-    }
-}
-
-fun AppNavigationState.currentThreadId(): ThreadId? {
-    return when (this) {
-        AppNavigationState.Root -> null
-        is AppNavigationState.Session -> null
-        is AppNavigationState.Space -> null
-        is AppNavigationState.Room -> null
-        is AppNavigationState.Thread -> threadId
-    }
-}
diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateService.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateService.kt
index 4bb40b7b757..50e6b3434e6 100644
--- a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateService.kt
+++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationStateService.kt
@@ -22,8 +22,11 @@ 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 appNavigationStateFlow: StateFlow<AppNavigationState>
+    val appNavigationState: StateFlow<AppNavigationState>
 
     fun onNavigateToSession(owner: String, sessionId: SessionId)
     fun onLeavingSession(owner: String)
@@ -37,3 +40,4 @@ interface AppNavigationStateService {
     fun onNavigateToThread(owner: String, threadId: ThreadId)
     fun onLeavingThread(owner: String)
 }
+
diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationState.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationState.kt
new file mode 100644
index 00000000000..12cd07f05e2
--- /dev/null
+++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationState.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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) {
+    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)
+}
diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationStateExtension.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationStateExtension.kt
new file mode 100644
index 00000000000..b399934cac6
--- /dev/null
+++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/NavigationStateExtension.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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
+    }
+}
diff --git a/services/appnavstate/impl/build.gradle.kts b/services/appnavstate/impl/build.gradle.kts
index 4cd39a4c426..4c6973b8da0 100644
--- a/services/appnavstate/impl/build.gradle.kts
+++ b/services/appnavstate/impl/build.gradle.kts
@@ -38,6 +38,7 @@ dependencies {
 
     implementation(libs.coroutines.core)
     implementation(libs.androidx.corektx)
+    implementation(libs.androidx.lifecycle.process)
 
     api(projects.services.appnavstate.api)
 
@@ -45,5 +46,6 @@ dependencies {
     testImplementation(libs.coroutines.test)
     testImplementation(libs.test.truth)
     testImplementation(projects.libraries.matrix.test)
+    testImplementation(projects.tests.testutils)
     testImplementation(projects.services.appnavstate.test)
 }
diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt
new file mode 100644
index 00000000000..27c3f12a6a5
--- /dev/null
+++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppForegroundStateService.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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
+import kotlinx.coroutines.flow.StateFlow
+
+class DefaultAppForegroundStateService : AppForegroundStateService {
+
+    private val state = MutableStateFlow(false)
+    override val isInForeground: StateFlow<Boolean> = state
+
+    private val appLifecycle: Lifecycle by lazy { ProcessLifecycleOwner.get().lifecycle }
+
+    override fun start() {
+        appLifecycle.addObserver(lifecycleObserver)
+    }
+
+    private val lifecycleObserver = LifecycleEventObserver { _, _ -> state.value = getCurrentState() }
+
+    private fun getCurrentState(): Boolean = appLifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
+}
diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateService.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateService.kt
index bf20a04b113..9360ce93ec8 100644
--- a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateService.kt
+++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateService.kt
@@ -24,10 +24,15 @@ 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.AppForegroundStateService
+import io.element.android.services.appnavstate.api.NavigationState
 import io.element.android.services.appnavstate.api.AppNavigationStateService
+import io.element.android.services.appnavstate.api.AppNavigationState
+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
 import javax.inject.Inject
 
@@ -38,113 +43,131 @@ private val loggerTag = LoggerTag("Navigation")
  */
 @ContributesBinding(AppScope::class)
 @SingleIn(AppScope::class)
-class DefaultAppNavigationStateService @Inject constructor() : AppNavigationStateService {
+class DefaultAppNavigationStateService @Inject constructor(
+    private val appForegroundStateService: AppForegroundStateService,
+    private val coroutineScope: CoroutineScope,
+) : AppNavigationStateService {
+
+    private val state = MutableStateFlow(
+        AppNavigationState(
+                navigationState = NavigationState.Root,
+                isInForeground = true,
+            )
+    )
+    override val appNavigationState: StateFlow<AppNavigationState> = state
 
-    private val currentAppNavigationState: MutableStateFlow<AppNavigationState> = MutableStateFlow(AppNavigationState.Root)
-    override val appNavigationStateFlow: StateFlow<AppNavigationState> = currentAppNavigationState
+    init {
+        coroutineScope.launch {
+            appForegroundStateService.start()
+
+            appForegroundStateService.isInForeground.collect { isInForeground ->
+                state.getAndUpdate { it.copy(isInForeground = isInForeground) }
+            }
+        }
+    }
 
     override fun onNavigateToSession(owner: String, sessionId: SessionId) {
-        val currentValue = currentAppNavigationState.value
+        val currentValue = state.value.navigationState
         Timber.tag(loggerTag.value).d("Navigating to session $sessionId. Current state: $currentValue")
-        val newValue: AppNavigationState.Session = when (currentValue) {
-            is AppNavigationState.Session,
-            is AppNavigationState.Space,
-            is AppNavigationState.Room,
-            is AppNavigationState.Thread,
-            is AppNavigationState.Root -> AppNavigationState.Session(owner, sessionId)
+        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)
         }
-        currentAppNavigationState.value = newValue
+        state.getAndUpdate { it.copy(navigationState = newValue) }
     }
 
     override fun onNavigateToSpace(owner: String, spaceId: SpaceId) {
-        val currentValue = currentAppNavigationState.value
+        val currentValue = state.value.navigationState
         Timber.tag(loggerTag.value).d("Navigating to space $spaceId. Current state: $currentValue")
-        val newValue: AppNavigationState.Space = when (currentValue) {
-            AppNavigationState.Root -> error("onNavigateToSession() must be called first")
-            is AppNavigationState.Session -> AppNavigationState.Space(owner, spaceId, currentValue)
-            is AppNavigationState.Space -> AppNavigationState.Space(owner, spaceId, currentValue.parentSession)
-            is AppNavigationState.Room -> AppNavigationState.Space(owner, spaceId, currentValue.parentSpace.parentSession)
-            is AppNavigationState.Thread -> AppNavigationState.Space(owner, spaceId, currentValue.parentRoom.parentSpace.parentSession)
+        val newValue: NavigationState.Space = when (currentValue) {
+            NavigationState.Root -> error("onNavigateToSession() must be called first")
+            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)
         }
-        currentAppNavigationState.value = newValue
+        state.getAndUpdate { it.copy(navigationState = newValue) }
     }
 
     override fun onNavigateToRoom(owner: String, roomId: RoomId) {
-        val currentValue = currentAppNavigationState.value
+        val currentValue = state.value.navigationState
         Timber.tag(loggerTag.value).d("Navigating to room $roomId. Current state: $currentValue")
-        val newValue: AppNavigationState.Room = when (currentValue) {
-            AppNavigationState.Root -> error("onNavigateToSession() must be called first")
-            is AppNavigationState.Session -> error("onNavigateToSpace() must be called first")
-            is AppNavigationState.Space -> AppNavigationState.Room(owner, roomId, currentValue)
-            is AppNavigationState.Room -> AppNavigationState.Room(owner, roomId, currentValue.parentSpace)
-            is AppNavigationState.Thread -> AppNavigationState.Room(owner, roomId, currentValue.parentRoom.parentSpace)
+        val newValue: NavigationState.Room = when (currentValue) {
+            NavigationState.Root -> error("onNavigateToSession() must be called first")
+            is NavigationState.Session -> error("onNavigateToSpace() must be called first")
+            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)
         }
-        currentAppNavigationState.value = newValue
+        state.getAndUpdate { it.copy(navigationState = newValue) }
     }
 
     override fun onNavigateToThread(owner: String, threadId: ThreadId) {
-        val currentValue = currentAppNavigationState.value
+        val currentValue = state.value.navigationState
         Timber.tag(loggerTag.value).d("Navigating to thread $threadId. Current state: $currentValue")
-        val newValue: AppNavigationState.Thread = when (currentValue) {
-            AppNavigationState.Root -> error("onNavigateToSession() must be called first")
-            is AppNavigationState.Session -> error("onNavigateToSpace() must be called first")
-            is AppNavigationState.Space -> error("onNavigateToRoom() must be called first")
-            is AppNavigationState.Room -> AppNavigationState.Thread(owner, threadId, currentValue)
-            is AppNavigationState.Thread -> AppNavigationState.Thread(owner, threadId, currentValue.parentRoom)
+        val newValue: NavigationState.Thread = when (currentValue) {
+            NavigationState.Root -> error("onNavigateToSession() must be called first")
+            is NavigationState.Session -> error("onNavigateToSpace() must be called first")
+            is NavigationState.Space -> error("onNavigateToRoom() must be called first")
+            is NavigationState.Room -> NavigationState.Thread(owner, threadId, currentValue)
+            is NavigationState.Thread -> NavigationState.Thread(owner, threadId, currentValue.parentRoom)
         }
-        currentAppNavigationState.value = newValue
+        state.getAndUpdate { it.copy(navigationState = newValue) }
     }
 
     override fun onLeavingThread(owner: String) {
-        val currentValue = currentAppNavigationState.value
+        val currentValue = state.value.navigationState
         Timber.tag(loggerTag.value).d("Leaving thread. Current state: $currentValue")
         if (!currentValue.assertOwner(owner)) return
-        val newValue: AppNavigationState.Room = when (currentValue) {
-            AppNavigationState.Root -> error("onNavigateToSession() must be called first")
-            is AppNavigationState.Session -> error("onNavigateToSpace() must be called first")
-            is AppNavigationState.Space -> error("onNavigateToRoom() must be called first")
-            is AppNavigationState.Room -> error("onNavigateToThread() must be called first")
-            is AppNavigationState.Thread -> currentValue.parentRoom
+        val newValue: NavigationState.Room = when (currentValue) {
+            NavigationState.Root -> error("onNavigateToSession() must be called first")
+            is NavigationState.Session -> error("onNavigateToSpace() must be called first")
+            is NavigationState.Space -> error("onNavigateToRoom() must be called first")
+            is NavigationState.Room -> error("onNavigateToThread() must be called first")
+            is NavigationState.Thread -> currentValue.parentRoom
         }
-        currentAppNavigationState.value = newValue
+        state.getAndUpdate { it.copy(navigationState = newValue) }
     }
 
     override fun onLeavingRoom(owner: String) {
-        val currentValue = currentAppNavigationState.value
+        val currentValue = state.value.navigationState
         Timber.tag(loggerTag.value).d("Leaving room. Current state: $currentValue")
         if (!currentValue.assertOwner(owner)) return
-        val newValue: AppNavigationState.Space = when (currentValue) {
-            AppNavigationState.Root -> error("onNavigateToSession() must be called first")
-            is AppNavigationState.Session -> error("onNavigateToSpace() must be called first")
-            is AppNavigationState.Space -> error("onNavigateToRoom() must be called first")
-            is AppNavigationState.Room -> currentValue.parentSpace
-            is AppNavigationState.Thread -> currentValue.parentRoom.parentSpace
+        val newValue: NavigationState.Space = when (currentValue) {
+            NavigationState.Root -> error("onNavigateToSession() must be called first")
+            is NavigationState.Session -> error("onNavigateToSpace() must be called first")
+            is NavigationState.Space -> error("onNavigateToRoom() must be called first")
+            is NavigationState.Room -> currentValue.parentSpace
+            is NavigationState.Thread -> currentValue.parentRoom.parentSpace
         }
-        currentAppNavigationState.value = newValue
+        state.getAndUpdate { it.copy(navigationState = newValue) }
     }
 
     override fun onLeavingSpace(owner: String) {
-        val currentValue = currentAppNavigationState.value
+        val currentValue = state.value.navigationState
         Timber.tag(loggerTag.value).d("Leaving space. Current state: $currentValue")
         if (!currentValue.assertOwner(owner)) return
-        val newValue: AppNavigationState.Session = when (currentValue) {
-            AppNavigationState.Root -> error("onNavigateToSession() must be called first")
-            is AppNavigationState.Session -> error("onNavigateToSpace() must be called first")
-            is AppNavigationState.Space -> currentValue.parentSession
-            is AppNavigationState.Room -> currentValue.parentSpace.parentSession
-            is AppNavigationState.Thread -> currentValue.parentRoom.parentSpace.parentSession
+        val newValue: NavigationState.Session = when (currentValue) {
+            NavigationState.Root -> error("onNavigateToSession() must be called first")
+            is NavigationState.Session -> error("onNavigateToSpace() must be called first")
+            is NavigationState.Space -> currentValue.parentSession
+            is NavigationState.Room -> currentValue.parentSpace.parentSession
+            is NavigationState.Thread -> currentValue.parentRoom.parentSpace.parentSession
         }
-        currentAppNavigationState.value = newValue
+        state.getAndUpdate { it.copy(navigationState = newValue) }
     }
 
     override fun onLeavingSession(owner: String) {
-        val currentValue = currentAppNavigationState.value
+        val currentValue = state.value.navigationState
         Timber.tag(loggerTag.value).d("Leaving session. Current state: $currentValue")
         if (!currentValue.assertOwner(owner)) return
-        currentAppNavigationState.value = AppNavigationState.Root
+        state.getAndUpdate { it.copy(navigationState = NavigationState.Root) }
     }
 
-    private fun AppNavigationState.assertOwner(owner: String): Boolean {
+    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
diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/di/AppNavStateModule.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/di/AppNavStateModule.kt
new file mode 100644
index 00000000000..4537c9f9028
--- /dev/null
+++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/di/AppNavStateModule.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.services.appnavstate.impl.di
+
+import android.content.Context
+import androidx.startup.AppInitializer
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Module
+import dagger.Provides
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.services.appnavstate.api.AppForegroundStateService
+import io.element.android.services.appnavstate.impl.initializer.AppForegroundStateServiceInitializer
+
+@Module
+@ContributesTo(AppScope::class)
+object AppNavStateModule {
+
+    @Provides
+    fun provideAppForegroundStateService(
+        @ApplicationContext context: Context
+    ): AppForegroundStateService =
+        AppInitializer.getInstance(context).initializeComponent(AppForegroundStateServiceInitializer::class.java)
+
+}
diff --git a/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/initializer/AppForegroundStateServiceInitializer.kt b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/initializer/AppForegroundStateServiceInitializer.kt
new file mode 100644
index 00000000000..cfd382a57b7
--- /dev/null
+++ b/services/appnavstate/impl/src/main/kotlin/io/element/android/services/appnavstate/impl/initializer/AppForegroundStateServiceInitializer.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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
+    )
+}


Test patch:
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt
index 0f5dcd84f6f..48efd778084 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt
@@ -32,7 +32,7 @@ import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
 import io.element.android.libraries.architecture.childNode
 import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
 import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
-import io.element.android.services.appnavstate.test.NoopAppNavigationStateService
+import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
 import org.junit.Rule
 import org.junit.Test
 
@@ -82,7 +82,7 @@ class RoomFlowNodeTest {
         plugins = plugins,
         messagesEntryPoint = messagesEntryPoint,
         roomDetailsEntryPoint = roomDetailsEntryPoint,
-        appNavigationStateService = NoopAppNavigationStateService(),
+        appNavigationStateService = FakeAppNavigationStateService(),
         roomMembershipObserver = RoomMembershipObserver()
     )
 
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt
index a1398ef4290..28b001ca281 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt
@@ -30,17 +30,20 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiable
 import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
 import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
 import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
-import io.element.android.services.appnavstate.test.anAppNavigationState
+import io.element.android.services.appnavstate.api.NavigationState
+import io.element.android.services.appnavstate.api.AppNavigationState
+import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
+import io.element.android.services.appnavstate.test.aNavigationState
+import kotlinx.coroutines.flow.MutableStateFlow
 import org.junit.Test
 
-private val NOT_VIEWING_A_ROOM = anAppNavigationState()
-private val VIEWING_A_ROOM = anAppNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID)
-private val VIEWING_A_THREAD = anAppNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID)
+private val NOT_VIEWING_A_ROOM = aNavigationState()
+private val VIEWING_A_ROOM = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID)
+private val VIEWING_A_THREAD = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID)
 
 class NotifiableEventProcessorTest {
 
     private val outdatedDetector = FakeOutdatedEventDetector()
-    private val eventProcessor = NotifiableEventProcessor(outdatedDetector.instance)
 
     @Test
     fun `given simple events when processing then keep simple events`() {
@@ -48,8 +51,9 @@ class NotifiableEventProcessorTest {
             aSimpleNotifiableEvent(eventId = AN_EVENT_ID),
             aSimpleNotifiableEvent(eventId = AN_EVENT_ID_2)
         )
+        val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
 
-        val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, renderedEvents = emptyList())
 
         assertThat(result).isEqualTo(
             listOfProcessedEvents(
@@ -62,8 +66,9 @@ class NotifiableEventProcessorTest {
     @Test
     fun `given redacted simple event when processing then remove redaction event`() {
         val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = EventType.REDACTION))
+        val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
 
-        val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, renderedEvents = emptyList())
 
         assertThat(result).isEqualTo(
             listOfProcessedEvents(
@@ -78,8 +83,9 @@ class NotifiableEventProcessorTest {
             anInviteNotifiableEvent(roomId = A_ROOM_ID),
             anInviteNotifiableEvent(roomId = A_ROOM_ID_2)
         )
+        val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
 
-        val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, renderedEvents = emptyList())
 
         assertThat(result).isEqualTo(
             listOfProcessedEvents(
@@ -94,7 +100,9 @@ class NotifiableEventProcessorTest {
         val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID))
         outdatedDetector.givenEventIsOutOfDate(events[0])
 
-        val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+        val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
+
+        val result = eventProcessor.process(events, renderedEvents = emptyList())
 
         assertThat(result).isEqualTo(
             listOfProcessedEvents(
@@ -107,8 +115,9 @@ class NotifiableEventProcessorTest {
     fun `given in date message event when processing then keep message event`() {
         val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID))
         outdatedDetector.givenEventIsInDate(events[0])
+        val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
 
-        val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, renderedEvents = emptyList())
 
         assertThat(result).isEqualTo(
             listOfProcessedEvents(
@@ -121,8 +130,9 @@ class NotifiableEventProcessorTest {
     fun `given viewing the same room main timeline when processing main timeline message event then removes message`() {
         val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null))
         events.forEach { outdatedDetector.givenEventIsOutOfDate(it) }
+        val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM)
 
-        val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, renderedEvents = emptyList())
 
         assertThat(result).isEqualTo(
             listOfProcessedEvents(
@@ -135,8 +145,9 @@ class NotifiableEventProcessorTest {
     fun `given viewing the same thread timeline when processing thread message event then removes message`() {
         val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID))
         events.forEach { outdatedDetector.givenEventIsOutOfDate(it) }
+        val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD)
 
-        val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, renderedEvents = emptyList())
 
         assertThat(result).isEqualTo(
             listOfProcessedEvents(
@@ -149,8 +160,9 @@ class NotifiableEventProcessorTest {
     fun `given viewing main timeline of the same room when processing thread timeline message event then keep message`() {
         val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID))
         outdatedDetector.givenEventIsInDate(events[0])
+        val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM)
 
-        val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, renderedEvents = emptyList())
 
         assertThat(result).isEqualTo(
             listOfProcessedEvents(
@@ -163,8 +175,9 @@ class NotifiableEventProcessorTest {
     fun `given viewing thread timeline of the same room when processing main timeline message event then keep message`() {
         val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID))
         outdatedDetector.givenEventIsInDate(events[0])
+        val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD)
 
-        val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, renderedEvents = emptyList())
 
         assertThat(result).isEqualTo(
             listOfProcessedEvents(
@@ -180,8 +193,9 @@ class NotifiableEventProcessorTest {
             ProcessedEvent(ProcessedEvent.Type.KEEP, events[0]),
             ProcessedEvent(ProcessedEvent.Type.KEEP, anInviteNotifiableEvent(eventId = AN_EVENT_ID_2))
         )
+        val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM)
 
-        val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = renderedEvents)
+        val result = eventProcessor.process(events, renderedEvents = renderedEvents)
 
         assertThat(result).isEqualTo(
             listOfProcessedEvents(
@@ -194,4 +208,14 @@ class NotifiableEventProcessorTest {
     private fun listOfProcessedEvents(vararg event: Pair<ProcessedEvent.Type, NotifiableEvent>) = event.map {
         ProcessedEvent(it.first, it.second)
     }
+
+    private fun createProcessor(
+        isInForeground: Boolean = false,
+        navigationState: NavigationState
+    ): NotifiableEventProcessor {
+        return NotifiableEventProcessor(
+            outdatedDetector.instance,
+            FakeAppNavigationStateService(MutableStateFlow(AppNavigationState(navigationState, isInForeground))),
+        )
+    }
 }
diff --git a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt
similarity index 70%
rename from services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt
rename to services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt
index d6000dc1d8e..dd0e576c79d 100644
--- a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt
+++ b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt
@@ -21,35 +21,36 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
 import io.element.android.libraries.matrix.test.A_SESSION_ID
 import io.element.android.libraries.matrix.test.A_SPACE_ID
 import io.element.android.libraries.matrix.test.A_THREAD_ID
-import io.element.android.services.appnavstate.api.AppNavigationState
+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.tests.testutils.runCancellableScopeTest
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runTest
 import org.junit.Assert.assertThrows
 import org.junit.Test
 
-class DefaultAppNavigationStateServiceTest {
+class DefaultNavigationStateServiceTest {
 
     @Test
-    fun testNavigation() = runTest {
-        val service = DefaultAppNavigationStateService()
+    fun testNavigation() = runCancellableScopeTest { scope ->
+        val service = createStateService(scope)
         service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID)
         service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID)
         service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID)
         service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID)
-        assertThat(service.appNavigationStateFlow.first()).isEqualTo(
-            AppNavigationState.Thread(
+        assertThat(service.appNavigationState.first().navigationState).isEqualTo(
+            NavigationState.Thread(
                 A_THREAD_OWNER, A_THREAD_ID,
-                AppNavigationState.Room(
+                NavigationState.Room(
                     A_ROOM_OWNER,
                     A_ROOM_ID,
-                    AppNavigationState.Space(
+                    NavigationState.Space(
                         A_SPACE_OWNER,
                         A_SPACE_ID,
-                        AppNavigationState.Session(
+                        NavigationState.Session(
                             A_SESSION_OWNER,
                             A_SESSION_ID
                         )
@@ -60,8 +61,13 @@ class DefaultAppNavigationStateServiceTest {
     }
 
     @Test
-    fun testFailure() = runTest {
-        val service = DefaultAppNavigationStateService()
+    fun testFailure() = runCancellableScopeTest { scope ->
+        val service = createStateService(scope)
+
         assertThrows(IllegalStateException::class.java) { service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) }
     }
+
+    private fun createStateService(
+        coroutineScope: CoroutineScope
+    ) = DefaultAppNavigationStateService(FakeAppForegroundStateService(), coroutineScope)
 }
diff --git a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/FakeAppForegroundStateService.kt b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/FakeAppForegroundStateService.kt
new file mode 100644
index 00000000000..e243523bd0d
--- /dev/null
+++ b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/FakeAppForegroundStateService.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.services.appnavstate.impl
+
+import io.element.android.services.appnavstate.api.AppForegroundStateService
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+class FakeAppForegroundStateService(
+    initialValue: Boolean = true,
+) : AppForegroundStateService {
+
+    private val state = MutableStateFlow(initialValue)
+    override val isInForeground: StateFlow<Boolean> = state
+
+    override fun start() {
+        // No-op
+    }
+
+    fun givenIsInForeground(isInForeground: Boolean) {
+        state.value = isInForeground
+    }
+}
diff --git a/services/appnavstate/test/build.gradle.kts b/services/appnavstate/test/build.gradle.kts
index 93e92943040..656777dac1e 100644
--- a/services/appnavstate/test/build.gradle.kts
+++ b/services/appnavstate/test/build.gradle.kts
@@ -26,4 +26,5 @@ dependencies {
     api(projects.libraries.matrix.api)
     api(projects.services.appnavstate.api)
     implementation(libs.coroutines.core)
+    implementation(libs.androidx.lifecycle.runtime)
 }
diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt
index aa0b3512205..63c3d4e9676 100644
--- a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt
+++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt
@@ -21,33 +21,33 @@ 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.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 anAppNavigationState(
+fun aNavigationState(
     sessionId: SessionId? = null,
     spaceId: SpaceId? = MAIN_SPACE,
     roomId: RoomId? = null,
     threadId: ThreadId? = null,
-): AppNavigationState {
+): NavigationState {
     if (sessionId == null) {
-        return AppNavigationState.Root
+        return NavigationState.Root
     }
-    val session = AppNavigationState.Session(A_SESSION_OWNER, sessionId)
+    val session = NavigationState.Session(A_SESSION_OWNER, sessionId)
     if (spaceId == null) {
         return session
     }
-    val space = AppNavigationState.Space(A_SPACE_OWNER, spaceId, session)
+    val space = NavigationState.Space(A_SPACE_OWNER, spaceId, session)
     if (roomId == null) {
         return space
     }
-    val room = AppNavigationState.Room(A_ROOM_OWNER, roomId, space)
+    val room = NavigationState.Room(A_ROOM_OWNER, roomId, space)
     if (threadId == null) {
         return room
     }
-    return AppNavigationState.Thread(A_THREAD_OWNER, threadId, room)
+    return NavigationState.Thread(A_THREAD_OWNER, threadId, room)
 }
diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/NoopAppNavigationStateService.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt
similarity index 78%
rename from services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/NoopAppNavigationStateService.kt
rename to services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt
index c31d74ec18f..a09e2a9c5e0 100644
--- a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/NoopAppNavigationStateService.kt
+++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt
@@ -20,16 +20,22 @@ 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.NavigationState
 import io.element.android.services.appnavstate.api.AppNavigationStateService
+import io.element.android.services.appnavstate.api.AppNavigationState
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 
-class NoopAppNavigationStateService : AppNavigationStateService {
+class FakeAppNavigationStateService(
+    private val fakeAppNavigationState: MutableStateFlow<AppNavigationState> = MutableStateFlow(
+        AppNavigationState(
+            navigationState = NavigationState.Root,
+            isInForeground = true,
+        )
+    ),
+) : AppNavigationStateService {
 
-    private val currentAppNavigationState: MutableStateFlow<AppNavigationState> =
-        MutableStateFlow(AppNavigationState.Root)
-    override val appNavigationStateFlow: StateFlow<AppNavigationState> = currentAppNavigationState
+    override val appNavigationState: StateFlow<AppNavigationState> = fakeAppNavigationState
 
     override fun onNavigateToSession(owner: String, sessionId: SessionId) = Unit
     override fun onLeavingSession(owner: String) = Unit
diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RunCancellableTest.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RunCancellableTest.kt
new file mode 100644
index 00000000000..aea33b67985
--- /dev/null
+++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RunCancellableTest.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2023 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.tests.testutils
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.test.runTest
+
+/**
+ * Run a test with a [CoroutineScope] that will be cancelled automatically and avoiding failing the test.
+ */
+fun runCancellableScopeTest(block: suspend (CoroutineScope) -> Unit) = runTest {
+    val scope = CoroutineScope(coroutineContext + SupervisorJob())
+    block(scope)
+    scope.cancel()
+}