Repository: element-hq/element-x-android
Pull request #4634: Keep call notification ringing while a call is present in the room
Author: login: jmartinesp
Created: 2025-04-24T08:24:07Z | Merged: 2025-05-09T09:38:43Z

<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
 
## Content

This removes the 10s timeout to consider a call notification ringing, and instead uses `RoomInfo.hasRoomCall` to check if the notification should ring or not.

## Motivation and context

Mitigate or fix issues when a call notification that should be considered ringing because the other user was still calling was considered low priority instead.

## Tests

<!-- Explain how you tested your development -->

- From 2 devices with different accounts, start a call in a DM room.
- The receiving one should display the ringing notification/screen.

## Tested devices

- [x] Physical
- [ ] Emulator
- OS version(s): 14

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 24
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [x] Pull request is based on the develop branch
- [x] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes
- [x] You've made a self review of your PR



Diff:
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt
index 1857ef2b9b8..2e56c45395a 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt
@@ -9,16 +9,22 @@ package io.element.android.libraries.push.impl.notifications
 
 import com.squareup.anvil.annotations.ContributesBinding
 import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.MatrixClientProvider
 import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.api.notification.CallNotifyType
 import io.element.android.libraries.matrix.api.notification.NotificationContent
 import io.element.android.libraries.matrix.api.notification.NotificationData
 import io.element.android.libraries.matrix.api.timeline.item.event.EventType
 import io.element.android.libraries.push.impl.R
 import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
 import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
+import io.element.android.services.appnavstate.api.AppForegroundStateService
 import io.element.android.services.toolbox.api.strings.StringProvider
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.withTimeoutOrNull
 import timber.log.Timber
 import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
 
 /**
  * Helper to resolve a valid [NotifiableEvent] from a [NotificationData].
@@ -31,7 +37,7 @@ interface CallNotificationEventResolver {
      * @param forceNotify `true` to force the notification to be non-ringing, `false` to use the default behaviour. Default is `false`.
      * @return a [NotifiableEvent] if the notification data is a call notification, null otherwise
      */
-    fun resolveEvent(
+    suspend fun resolveEvent(
         sessionId: SessionId,
         notificationData: NotificationData,
         forceNotify: Boolean = false,
@@ -41,8 +47,10 @@ interface CallNotificationEventResolver {
 @ContributesBinding(AppScope::class)
 class DefaultCallNotificationEventResolver @Inject constructor(
     private val stringProvider: StringProvider,
+    private val appForegroundStateService: AppForegroundStateService,
+    private val clientProvider: MatrixClientProvider,
 ) : CallNotificationEventResolver {
-    override fun resolveEvent(
+    override suspend fun resolveEvent(
         sessionId: SessionId,
         notificationData: NotificationData,
         forceNotify: Boolean
@@ -50,8 +58,32 @@ class DefaultCallNotificationEventResolver @Inject constructor(
         val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
             ?: throw ResolvingException("content is not a call notify")
 
+        val previousRingingCallStatus = appForegroundStateService.hasRingingCall.value
+        // We need the sync service working to get the updated room info
+        val isRoomCallActive = runCatching {
+            if (content.type == CallNotifyType.RING) {
+                appForegroundStateService.updateHasRingingCall(true)
+
+                val client = clientProvider.getOrRestore(sessionId).getOrNull() ?: throw ResolvingException("Session $sessionId not found")
+                val room = client.getRoom(notificationData.roomId) ?: throw ResolvingException("Room ${notificationData.roomId} not found")
+                // Give a few seconds for the room info flow to catch up with the sync, if needed - this is usually instant
+                val isActive = withTimeoutOrNull(3.seconds) { room.roomInfoFlow.firstOrNull { it.hasRoomCall } }?.hasRoomCall ?: false
+
+                // We no longer need the sync service to be active because of a call notification.
+                appForegroundStateService.updateHasRingingCall(previousRingingCallStatus)
+
+                isActive
+            } else {
+                // If the call notification is not of ringing type, we don't need to check if the call is active
+                false
+            }
+        }.onFailure {
+            // Make sure to reset the hasRingingCall state in case of failure
+            appForegroundStateService.updateHasRingingCall(previousRingingCallStatus)
+        }.getOrDefault(false)
+
         notificationData.run {
-            if (NotifiableRingingCallEvent.shouldRing(content.type, timestamp) && !forceNotify) {
+            if (content.type == CallNotifyType.RING && isRoomCallActive && !forceNotify) {
                 NotifiableRingingCallEvent(
                     sessionId = sessionId,
                     roomId = roomId,
@@ -70,9 +102,7 @@ class DefaultCallNotificationEventResolver @Inject constructor(
                     senderAvatarUrl = senderAvatarUrl,
                 )
             } else {
-                val now = System.currentTimeMillis()
-                val elapsed = now - timestamp
-                Timber.d("Event $eventId is call notify but should not ring: $timestamp vs $now ($elapsed ms elapsed), notify: ${content.type}")
+                Timber.d("Event $eventId is call notify but should not ring: $isRoomCallActive, notify: ${content.type}")
                 // Create a simple message notification event
                 buildNotifiableMessageEvent(
                     sessionId = sessionId,
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableRingingCallEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableRingingCallEvent.kt
index bf2de5c32d3..07e85ab2bfc 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableRingingCallEvent.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableRingingCallEvent.kt
@@ -12,8 +12,6 @@ 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.UserId
 import io.element.android.libraries.matrix.api.notification.CallNotifyType
-import java.time.Instant
-import kotlin.time.Duration.Companion.seconds
 
 data class NotifiableRingingCallEvent(
     override val sessionId: SessionId,
@@ -31,13 +29,4 @@ data class NotifiableRingingCallEvent(
     val roomAvatarUrl: String? = null,
     val callNotifyType: CallNotifyType,
     val timestamp: Long,
-) : NotifiableEvent {
-    companion object {
-        fun shouldRing(callNotifyType: CallNotifyType, timestamp: Long): Boolean {
-            val timeout = 10.seconds.inWholeMilliseconds
-            val elapsed = Instant.now().toEpochMilli() - timestamp
-            // Only ring if the type is RING and the elapsed time is less than the timeout
-            return callNotifyType == CallNotifyType.RING && elapsed < timeout
-        }
-    }
-}
+) : NotifiableEvent
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultCallNotificationEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultCallNotificationEventResolverTest.kt
new file mode 100644
index 00000000000..c9ebbb72b32
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultCallNotificationEventResolverTest.kt
@@ -0,0 +1,171 @@
+/*
+ * 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.libraries.push.impl.notifications
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.notification.CallNotifyType
+import io.element.android.libraries.matrix.api.notification.NotificationContent
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_ROOM_NAME
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.matrix.test.A_USER_ID_2
+import io.element.android.libraries.matrix.test.A_USER_NAME_2
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
+import io.element.android.libraries.matrix.test.notification.aNotificationData
+import io.element.android.libraries.matrix.test.room.FakeBaseRoom
+import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
+import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
+import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
+import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
+import io.element.android.services.toolbox.test.strings.FakeStringProvider
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultCallNotificationEventResolverTest {
+    @Test
+    fun `resolve CallNotify - RING when call is still ongoing`() = runTest {
+        val room = FakeJoinedRoom(
+            baseRoom = FakeBaseRoom(
+                sessionId = A_SESSION_ID,
+                roomId = A_ROOM_ID,
+                // The call is still ongoing
+                initialRoomInfo = aRoomInfo(hasRoomCall = true),
+            )
+        )
+        val client = FakeMatrixClient().apply {
+            givenGetRoomResult(A_ROOM_ID, room)
+        }
+
+        val resolver = createDefaultNotifiableEventResolver(
+            clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
+        )
+        val expectedResult = NotifiableRingingCallEvent(
+            sessionId = A_SESSION_ID,
+            roomId = A_ROOM_ID,
+            eventId = AN_EVENT_ID,
+            senderId = A_USER_ID_2,
+            roomName = A_ROOM_NAME,
+            editedEventId = null,
+            description = "📹 Incoming call",
+            timestamp = 567L,
+            canBeReplaced = true,
+            isRedacted = false,
+            isUpdated = false,
+            senderDisambiguatedDisplayName = A_USER_NAME_2,
+            senderAvatarUrl = null,
+            callNotifyType = CallNotifyType.RING,
+        )
+
+        val notificationData = aNotificationData(
+            content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.RING)
+        )
+        val result = resolver.resolveEvent(A_SESSION_ID, notificationData)
+        assertThat(result.getOrNull()).isEqualTo(expectedResult)
+    }
+
+    @Test
+    fun `resolve CallNotify - NOTIFY`() = runTest {
+        val room = FakeJoinedRoom(
+            baseRoom = FakeBaseRoom(
+                sessionId = A_SESSION_ID,
+                roomId = A_ROOM_ID,
+                // The call already ended
+                initialRoomInfo = aRoomInfo(hasRoomCall = true),
+            )
+        )
+        val client = FakeMatrixClient().apply {
+            givenGetRoomResult(A_ROOM_ID, room)
+        }
+
+        val resolver = createDefaultNotifiableEventResolver(
+            clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
+        )
+        val expectedResult = NotifiableMessageEvent(
+            sessionId = A_SESSION_ID,
+            roomId = A_ROOM_ID,
+            eventId = AN_EVENT_ID,
+            senderId = A_USER_ID_2,
+            roomName = A_ROOM_NAME,
+            editedEventId = null,
+            body = "📹 Incoming call",
+            timestamp = 567L,
+            canBeReplaced = false,
+            isRedacted = false,
+            isUpdated = false,
+            senderDisambiguatedDisplayName = A_USER_NAME_2,
+            noisy = true,
+            imageUriString = null,
+            imageMimeType = null,
+            threadId = null,
+            type = "m.call.notify",
+        )
+
+        val notificationData = aNotificationData(
+            content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.NOTIFY)
+        )
+        val result = resolver.resolveEvent(A_SESSION_ID, notificationData)
+        assertThat(result.getOrNull()).isEqualTo(expectedResult)
+    }
+
+    @Test
+    fun `resolve CallNotify - RING but timed out displays the same as NOTIFY`() = runTest {
+        val room = FakeJoinedRoom(
+            baseRoom = FakeBaseRoom(
+                sessionId = A_SESSION_ID,
+                roomId = A_ROOM_ID,
+                // The call already ended
+                initialRoomInfo = aRoomInfo(hasRoomCall = false),
+            )
+        )
+        val client = FakeMatrixClient().apply {
+            givenGetRoomResult(A_ROOM_ID, room)
+        }
+
+        val resolver = createDefaultNotifiableEventResolver(
+            clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
+        )
+        val expectedResult = NotifiableMessageEvent(
+            sessionId = A_SESSION_ID,
+            roomId = A_ROOM_ID,
+            eventId = AN_EVENT_ID,
+            senderId = A_USER_ID_2,
+            roomName = A_ROOM_NAME,
+            editedEventId = null,
+            body = "📹 Incoming call",
+            timestamp = 567L,
+            canBeReplaced = false,
+            isRedacted = false,
+            isUpdated = false,
+            senderDisambiguatedDisplayName = A_USER_NAME_2,
+            noisy = true,
+            imageUriString = null,
+            imageMimeType = null,
+            threadId = null,
+            type = "m.call.notify",
+        )
+
+        val notificationData = aNotificationData(
+            content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.RING)
+        )
+        val result = resolver.resolveEvent(A_SESSION_ID, notificationData)
+        assertThat(result.getOrNull()).isEqualTo(expectedResult)
+    }
+
+    private fun createDefaultNotifiableEventResolver(
+        stringProvider: FakeStringProvider = FakeStringProvider(defaultResult = "\uD83D\uDCF9 Incoming call"),
+        appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
+        clientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
+    ) = DefaultCallNotificationEventResolver(
+        stringProvider = stringProvider,
+        appForegroundStateService = appForegroundStateService,
+        clientProvider = clientProvider,
+    )
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt
index 479075694ec..cf9244166d6 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt
@@ -49,10 +49,9 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiable
 import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
 import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
 import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
-import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
 import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
+import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
 import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
-import io.element.android.services.toolbox.impl.systemclock.DefaultSystemClock
 import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
 import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
 import kotlinx.coroutines.test.runTest
@@ -606,80 +605,8 @@ class DefaultNotifiableEventResolverTest {
     }
 
     @Test
-    fun `resolve CallNotify - ringing`() = runTest {
-        val timestamp = DefaultSystemClock().epochMillis()
-        val sut = createDefaultNotifiableEventResolver(
-            notificationResult = Result.success(
-                aNotificationData(
-                    content = NotificationContent.MessageLike.CallNotify(
-                        A_USER_ID_2,
-                        CallNotifyType.RING
-                    ),
-                    timestamp = timestamp,
-                )
-            )
-        )
-        val expectedResult = ResolvedPushEvent.Event(
-            NotifiableRingingCallEvent(
-                sessionId = A_SESSION_ID,
-                roomId = A_ROOM_ID,
-                eventId = AN_EVENT_ID,
-                senderId = A_USER_ID_2,
-                roomName = A_ROOM_NAME,
-                editedEventId = null,
-                description = "📹 Incoming call",
-                timestamp = timestamp,
-                canBeReplaced = true,
-                isRedacted = false,
-                isUpdated = false,
-                senderDisambiguatedDisplayName = A_USER_NAME_2,
-                senderAvatarUrl = null,
-                callNotifyType = CallNotifyType.RING,
-            )
-        )
-        val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        assertThat(result.getOrNull()).isEqualTo(expectedResult)
-    }
-
-    @Test
-    fun `resolve CallNotify - ring but timed out displays the same as notify`() = runTest {
-        val sut = createDefaultNotifiableEventResolver(
-            notificationResult = Result.success(
-                aNotificationData(
-                    content = NotificationContent.MessageLike.CallNotify(
-                        A_USER_ID_2,
-                        CallNotifyType.RING
-                    ),
-                    timestamp = 0L,
-                )
-            )
-        )
-        val expectedResult = ResolvedPushEvent.Event(
-            NotifiableMessageEvent(
-                sessionId = A_SESSION_ID,
-                eventId = AN_EVENT_ID,
-                editedEventId = null,
-                noisy = true,
-                timestamp = 0L,
-                senderDisambiguatedDisplayName = A_USER_NAME_2,
-                senderId = A_USER_ID_2,
-                body = "📹 Incoming call",
-                roomId = A_ROOM_ID,
-                threadId = null,
-                roomName = A_ROOM_NAME,
-                canBeReplaced = false,
-                isRedacted = false,
-                imageUriString = null,
-                imageMimeType = null,
-                type = EventType.CALL_NOTIFY,
-            )
-        )
-        val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        assertThat(result.getOrNull()).isEqualTo(expectedResult)
-    }
-
-    @Test
-    fun `resolve CallNotify - notify`() = runTest {
+    fun `resolve CallNotify - goes through CallNotificationEventResolver`() = runTest {
+        val callNotificationEventResolver = FakeCallNotificationEventResolver()
         val sut = createDefaultNotifiableEventResolver(
             notificationResult = Result.success(
                 aNotificationData(
@@ -688,7 +615,8 @@ class DefaultNotifiableEventResolverTest {
                         CallNotifyType.NOTIFY
                     ),
                 )
-            )
+            ),
+            callNotificationEventResolver = callNotificationEventResolver,
         )
         val expectedResult = ResolvedPushEvent.Event(
             NotifiableMessageEvent(
@@ -710,6 +638,7 @@ class DefaultNotifiableEventResolverTest {
                 type = EventType.CALL_NOTIFY,
             )
         )
+        callNotificationEventResolver.resolveEventLambda = { _, _, _ -> Result.success(expectedResult.notifiableEvent) }
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
         assertThat(result.getOrNull()).isEqualTo(expectedResult)
     }
@@ -804,6 +733,7 @@ class DefaultNotifiableEventResolverTest {
         notificationService: FakeNotificationService? = FakeNotificationService(),
         notificationResult: Result<NotificationData?> = Result.success(null),
         appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
+        callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(),
     ): DefaultNotifiableEventResolver {
         val context = RuntimeEnvironment.getApplication() as Context
         notificationService?.givenGetNotificationResult(notificationResult)
@@ -824,9 +754,7 @@ class DefaultNotifiableEventResolverTest {
             notificationMediaRepoFactory = notificationMediaRepoFactory,
             context = context,
             permalinkParser = FakePermalinkParser(),
-            callNotificationEventResolver = DefaultCallNotificationEventResolver(
-                stringProvider = AndroidStringProvider(context.resources)
-            ),
+            callNotificationEventResolver = callNotificationEventResolver,
             appPreferencesStore = appPreferencesStore,
         )
     }
diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt
index 4ec395f1732..947d57ee7e0 100644
--- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt
+++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt
@@ -18,7 +18,7 @@ class FakeCallNotificationEventResolver(
         lambdaError()
     },
 ) : CallNotificationEventResolver {
-    override fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): Result<NotifiableEvent> {
+    override suspend fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): Result<NotifiableEvent> {
         return resolveEventLambda(sessionId, notificationData, forceNotify)
     }
 }


Reviews:
user: login: bmarty
body: Some remarks. Also, sorry but  I do not understand this phrase:

> Mitigate or fix issues when a call notification that should be considered ringing because the other user was still calling was considered low priority instead.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T09:52:21Z
user: login: jmartinesp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T09:22:08Z
user: login: bmarty
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T09:36:08Z
user: login: bmarty
body: Thanks for the update!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-09T09:36:28Z

Review comments:
diff_hunk: @@ -41,17 +44,27 @@ interface CallNotificationEventResolver {
 @ContributesBinding(AppScope::class)
 class DefaultCallNotificationEventResolver @Inject constructor(
     private val stringProvider: StringProvider,
+    private val appForegroundStateService: AppForegroundStateService,
+    private val clientProvider: MatrixClientProvider,
 ) : CallNotificationEventResolver {
-    override fun resolveEvent(
+    override suspend fun resolveEvent(
         sessionId: SessionId,
         notificationData: NotificationData,
         forceNotify: Boolean
     ): Result<NotifiableEvent> = runCatching {
         val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
             ?: throw ResolvingException("content is not a call notify")
 
+        appForegroundStateService.updateHasRingingCall(true)
+
+        val client = clientProvider.getOrNull(sessionId) ?: error("Session $sessionId not found")
path: libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt
user: login: bmarty
body: Don't you want to use `getOrRestore` instead of `getOrNull` here?

Also maybe use `ResolvingException` instead of `error()`?
created_at: 2025-05-07T09:46:09Z
author_association: MEMBER
diff_hunk: @@ -41,17 +44,27 @@ interface CallNotificationEventResolver {
 @ContributesBinding(AppScope::class)
 class DefaultCallNotificationEventResolver @Inject constructor(
     private val stringProvider: StringProvider,
+    private val appForegroundStateService: AppForegroundStateService,
+    private val clientProvider: MatrixClientProvider,
 ) : CallNotificationEventResolver {
-    override fun resolveEvent(
+    override suspend fun resolveEvent(
         sessionId: SessionId,
         notificationData: NotificationData,
         forceNotify: Boolean
     ): Result<NotifiableEvent> = runCatching {
         val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
             ?: throw ResolvingException("content is not a call notify")
 
+        appForegroundStateService.updateHasRingingCall(true)
+
+        val client = clientProvider.getOrNull(sessionId) ?: error("Session $sessionId not found")
+        val room = client.getRoom(notificationData.roomId) ?: error("Room ${notificationData.roomId} not found")
path: libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt
user: login: bmarty
body: Use `ResolvingException` instead of `error()`?
created_at: 2025-05-07T09:47:35Z
author_association: MEMBER
diff_hunk: @@ -41,17 +44,27 @@ interface CallNotificationEventResolver {
 @ContributesBinding(AppScope::class)
 class DefaultCallNotificationEventResolver @Inject constructor(
     private val stringProvider: StringProvider,
+    private val appForegroundStateService: AppForegroundStateService,
+    private val clientProvider: MatrixClientProvider,
 ) : CallNotificationEventResolver {
-    override fun resolveEvent(
+    override suspend fun resolveEvent(
         sessionId: SessionId,
         notificationData: NotificationData,
         forceNotify: Boolean
     ): Result<NotifiableEvent> = runCatching {
         val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
             ?: throw ResolvingException("content is not a call notify")
 
+        appForegroundStateService.updateHasRingingCall(true)
path: libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt
user: login: bmarty
body: I do not get why there are 2 calls to `updateHasRingingCall` in this function. This is handled by `DefaultActiveCallManager` no?

Anyway there is a problem I think since this is set to `true` here and not to `false` in case of error in the following statements.
created_at: 2025-05-07T09:51:18Z
author_association: MEMBER
diff_hunk: @@ -41,17 +44,27 @@ interface CallNotificationEventResolver {
 @ContributesBinding(AppScope::class)
 class DefaultCallNotificationEventResolver @Inject constructor(
     private val stringProvider: StringProvider,
+    private val appForegroundStateService: AppForegroundStateService,
+    private val clientProvider: MatrixClientProvider,
 ) : CallNotificationEventResolver {
-    override fun resolveEvent(
+    override suspend fun resolveEvent(
         sessionId: SessionId,
         notificationData: NotificationData,
         forceNotify: Boolean
     ): Result<NotifiableEvent> = runCatching {
         val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
             ?: throw ResolvingException("content is not a call notify")
 
+        appForegroundStateService.updateHasRingingCall(true)
+
+        val client = clientProvider.getOrNull(sessionId) ?: error("Session $sessionId not found")
+        val room = client.getRoom(notificationData.roomId) ?: error("Room ${notificationData.roomId} not found")
+        val isRoomCallActive = room.roomInfoFlow.value.hasRoomCall
+
+        appForegroundStateService.updateHasRingingCall(false)
path: libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt
user: login: bmarty
body: See my comment above. This line is suspicious to me.
created_at: 2025-05-07T09:51:40Z
author_association: MEMBER
diff_hunk: @@ -41,17 +44,27 @@ interface CallNotificationEventResolver {
 @ContributesBinding(AppScope::class)
 class DefaultCallNotificationEventResolver @Inject constructor(
     private val stringProvider: StringProvider,
+    private val appForegroundStateService: AppForegroundStateService,
+    private val clientProvider: MatrixClientProvider,
 ) : CallNotificationEventResolver {
-    override fun resolveEvent(
+    override suspend fun resolveEvent(
         sessionId: SessionId,
         notificationData: NotificationData,
         forceNotify: Boolean
     ): Result<NotifiableEvent> = runCatching {
         val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
             ?: throw ResolvingException("content is not a call notify")
 
+        appForegroundStateService.updateHasRingingCall(true)
path: libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt
user: login: jmartinesp
body: This code is called before `DefaultActiveCallManager` handles anything, and if we want to check the updated room info we ned the sync service to be running. I can change it to `appForegroundStateService.updateIsSyncingNotificationEvent` if you think it fits better here, since it'll have the same result. I've added some comments on a new commit to explain why this is needed.

> Anyway there is a problem I think since this is set to true here and not to false in case of error in the following statements.

Fixed too in https://github.com/element-hq/element-x-android/pull/4634/commits/25fabee57653027adc83a3095337622d7f946bce
created_at: 2025-05-08T09:22:08Z
author_association: MEMBER
diff_hunk: @@ -41,17 +44,27 @@ interface CallNotificationEventResolver {
 @ContributesBinding(AppScope::class)
 class DefaultCallNotificationEventResolver @Inject constructor(
     private val stringProvider: StringProvider,
+    private val appForegroundStateService: AppForegroundStateService,
+    private val clientProvider: MatrixClientProvider,
 ) : CallNotificationEventResolver {
-    override fun resolveEvent(
+    override suspend fun resolveEvent(
         sessionId: SessionId,
         notificationData: NotificationData,
         forceNotify: Boolean
     ): Result<NotifiableEvent> = runCatching {
         val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
             ?: throw ResolvingException("content is not a call notify")
 
+        appForegroundStateService.updateHasRingingCall(true)
path: libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt
user: login: bmarty
body: > I can change it to `appForegroundStateService.updateIsSyncingNotificationEvent` if you think it fits better here, since it'll have the same result

I think that this is fine like that. But I am pretty sure that a second incoming call during a first incoming call will lead to issues. We may cross this bridge later.
created_at: 2025-05-09T09:36:08Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-24T08:28:21Z
body: <!-- GITHUB_COMMENT_QR_CODE -->
:iphone: Scan the QR code below to install the build (arm64 only) for this PR.
![QR code](https://www.diawi.com/qrcode/link/BZtBKZ)
If you can't scan the QR code you can install the build via this link: https://i.diawi.com/BZtBKZ
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-04-24T08:36:14Z
body: ## [Codecov](https://app.codecov.io/gh/element-hq/element-x-android/pull/4634?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=element-hq) Report
Attention: Patch coverage is `66.66667%` with `6 lines` in your changes missing coverage. Please review.
> Project coverage is 80.11%. Comparing base [(`5fb0fc0`)](https://app.codecov.io/gh/element-hq/element-x-android/commit/5fb0fc0d501956b3f69376cad9ccc1616b824b5a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=element-hq) to head [(`25fabee`)](https://app.codecov.io/gh/element-hq/element-x-android/commit/25fabee57653027adc83a3095337622d7f946bce?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=element-hq).
> Report is 9 commits behind head on develop.

| [Files with missing lines](https://app.codecov.io/gh/element-hq/element-x-android/pull/4634?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=element-hq) | Patch % | Lines |
|---|---|---|
| [...mpl/notifications/CallNotificationEventResolver.kt](https://app.codecov.io/gh/element-hq/element-x-android/pull/4634?src=pr&el=tree&filepath=libraries%2Fpush%2Fimpl%2Fsrc%2Fmain%2Fkotlin%2Fio%2Felement%2Fandroid%2Flibraries%2Fpush%2Fimpl%2Fnotifications%2FCallNotificationEventResolver.kt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=element-hq#diff-bGlicmFyaWVzL3B1c2gvaW1wbC9zcmMvbWFpbi9rb3RsaW4vaW8vZWxlbWVudC9hbmRyb2lkL2xpYnJhcmllcy9wdXNoL2ltcGwvbm90aWZpY2F0aW9ucy9DYWxsTm90aWZpY2F0aW9uRXZlbnRSZXNvbHZlci5rdA==) | 66.66% | [1 Missing and 5 partials :warning: ](https://app.codecov.io/gh/element-hq/element-x-android/pull/4634?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=element-hq) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           develop    #4634      +/-   ##
===========================================
- Coverage    80.11%   80.11%   -0.01%     
===========================================
  Files         2127     2127              
  Lines        56292    56303      +11     
  Branches      7018     7023       +5     
===========================================
+ Hits         45101    45105       +4     
- Misses        8779     8782       +3     
- Partials      2412     2416       +4     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/element-hq/element-x-android/pull/4634?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=element-hq).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=element-hq).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-05-08T09:27:12Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=element-x-android&pullRequest=4634) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=element-x-android&pullRequest=4634&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=element-x-android&pullRequest=4634&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=element-x-android&pullRequest=4634&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=element-x-android&pullRequest=4634&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=element-x-android&pullRequest=4634&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=element-x-android&pullRequest=4634)


author_association: NONE