From c05257431b509a397b896fccfaef808e1929a744 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 9 Feb 2026 14:23:06 +0100 Subject: [PATCH 01/40] Sort audio device by device type before sending the list to Element Call. --- .../call/impl/utils/WebViewAudioManager.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt index d4811d65d5..e27084f707 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt @@ -82,6 +82,13 @@ class WebViewAudioManager( AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, ) + private val audioDeviceComparator = Comparator { a, b -> + // If the device type is not in the wantedDeviceTypes list, we give it a high index, (i.e. low priority) + val indexOfA = wantedDeviceTypes.indexOf(a.type).let { if (it == -1) Int.MAX_VALUE else it } + val indexOfB = wantedDeviceTypes.indexOf(b.type).let { if (it == -1) Int.MAX_VALUE else it } + indexOfA.compareTo(indexOfB) + } + private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager /** @@ -134,7 +141,7 @@ class WebViewAudioManager( if (validNewDevices.isEmpty()) return // We need to calculate the available devices ourselves, since calling `listAudioDevices` will return an outdated list - val audioDevices = (listAudioDevices() + validNewDevices).distinctBy { it.id } + val audioDevices = (listAudioDevices() + validNewDevices).distinctBy { it.id }.sortedWith(audioDeviceComparator) setAvailableAudioDevices(audioDevices.map(SerializableAudioDevice::fromAudioDeviceInfo)) // This should automatically switch to a new device if it has a higher priority than the current one selectDefaultAudioDevice(audioDevices) @@ -294,7 +301,7 @@ class WebViewAudioManager( } /** - * Returns the list of available audio devices. + * Returns the list of available audio devices, sorted by likelihood of it being used for communication. * * On Android 11 ([Build.VERSION_CODES.R]) and lower, it returns the list of output devices as a fallback. */ @@ -304,7 +311,7 @@ class WebViewAudioManager( } else { val rawAudioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) rawAudioDevices.filter { it.type in wantedDeviceTypes && it.isSink } - } + }.sortedWith(audioDeviceComparator) } /** @@ -323,19 +330,12 @@ class WebViewAudioManager( } /** - * Selects the default audio device based on the available devices. + * Selects the default audio device based on the sorted available devices. * * @param availableDevices The list of available audio devices to select from. If not provided, it will use the current list of audio devices. */ private fun selectDefaultAudioDevice(availableDevices: List = listAudioDevices()) { - val selectedDevice = availableDevices - .minByOrNull { - wantedDeviceTypes.indexOf(it.type).let { index -> - // If the device type is not in the wantedDeviceTypes list, we give it a low priority - if (index == -1) Int.MAX_VALUE else index - } - } - + val selectedDevice = availableDevices.firstOrNull() expectedNewCommunicationDeviceId = selectedDevice?.id audioManager.selectAudioDevice(selectedDevice) From 22e9e8d9bd3f8a77fc7bb77a916be96c00ff066a Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 10 Feb 2026 16:33:10 +0100 Subject: [PATCH 02/40] Enable space feature flags by default --- .../android/libraries/featureflag/api/FeatureFlags.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 46f39b2e99..3b47726b80 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -74,21 +74,21 @@ enum class FeatureFlags( key = "feature.createSpaces", title = "Create spaces", description = "Allow creating spaces.", - defaultValue = { false }, + defaultValue = { true }, isFinished = false, ), SpaceSettings( key = "feature.spaceSettings", title = "Space settings", description = "Allow managing space settings such as details, permissions and privacy.", - defaultValue = { false }, + defaultValue = { true }, isFinished = false, ), RoomListSpaceFilters( key = "feature.roomListSpaceFilters", title = "Room list space filters", description = "Allow filtering the room list by space.", - defaultValue = { false }, + defaultValue = { true }, isFinished = false, ), PrintLogsToLogcat( From 5c0f6e5d2c7d4bd231d50f19fbab8d6c0d07921d Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 10 Feb 2026 10:42:26 -0600 Subject: [PATCH 03/40] Document "Developer options" and remove outdated instructions (#6162) * Document "Developer options" * Remove old docs from `element-android` It looks like these were just copied across from https://github.com/element-hq/element-android/blob/develop/docs/_developer_onboarding.md and no longer apply. --- docs/_developer_onboarding.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md index 6fb5229eeb..7770c016fc 100644 --- a/docs/_developer_onboarding.md +++ b/docs/_developer_onboarding.md @@ -24,6 +24,7 @@ * [Logging](#logging) * [Translations](#translations) * [Rageshake](#rageshake) + * [Developer options](#developer-options) * [Tips](#tips) * [Happy coding!](#happy-coding) @@ -409,14 +410,31 @@ The data will be sent to an internal server, which is not publicly accessible. A Rageshake can be very useful to get logs from a release version of the application. + +#### Developer options + +> [!WARNING] +> Developer options can result in unexpected application behavior or destructive +> actions. Use with caution and only if you are instructed by someone at Element or are +> already familiar. + +These options provide advanced controls for testing and debugging. They are visible by +default in debug and nightly builds but are hidden in release versions. + +**Enabling in release builds:** Navigate to application settings and tap the version +number at the bottom 7 times. After tapping, a new "Developer options" entry will appear +at the bottom of the list. + +The developer options include feature flags, notification/push history, Element call +customization, Rust SDK log levels, per-feature tracing toggles, Showkase to debug UI +components, rageshake controls, app crash controls, cache details/controls, persistent +storage maintenance tasks. + +Keywords: Developer settings, developer mode + + ### Tips -- Element Android has a `developer mode` in the `Settings/Advanced settings`. Other useful options are available here; (TODO Not supported yet!) -- Show hidden Events can also help to debug feature. When developer mode is enabled, it is possible to view the source (= the Json content) of any Events; (TODO - Not supported yet!) -- Type `/devtools` in a Room composer to access a developer menu. There are some other entry points. Developer mode has to be enabled; (TODO Not supported yet!) -- Hidden debug menu: when developer mode is enabled and on debug build, there are some extra screens that can be accessible using the green wheel. In those - screens, it will be possible to toggle some feature flags; (TODO Not supported yet!) - Using logcat, filtering with `Compositions` can help you to understand what screen are currently displayed on your device. Searching for string displayed on the screen can also help to find the running code in the codebase. - When this is possible, prefer using `sealed interface` instead of `sealed class`; From 5d45b1480859ce7d8df1631c5ac2b752dbbb01e9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:51:10 +0000 Subject: [PATCH 04/40] fix(deps): update dependency org.jetbrains.kotlinx:kover-gradle-plugin to v0.9.7 (#6173) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0fa4a43e8d..47c2221772 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -62,7 +62,7 @@ detekt = "1.23.8" # See https://github.com/pinterest/ktlint/releases/ ktlint = "1.8.0" androidx-test-ext-junit = "1.3.0" -kover = "0.9.6" +kover = "0.9.7" [libraries] # Project From 1600d68f4bcb0092d13494d21cc13ddc4e03c777 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 10 Feb 2026 21:06:36 +0100 Subject: [PATCH 05/40] Fix tests after ff enabled --- .../features/createroom/impl/ConfigureRoomPresenterTest.kt | 6 +++++- .../impl/root/SecurityAndPrivacyPresenterTest.kt | 5 ++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt index 1016fea46c..fcedcb2367 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt @@ -219,6 +219,7 @@ class ConfigureRoomPresenterTest { fun `present - when creating a room in a space if the room doesn't receive the power levels value it can't be added to the space`() = runTest { val addChildToSpaceResult = lambdaRecorder> { _, _ -> Result.success(Unit) } val spaceService = FakeSpaceService( + editableSpacesResult = { Result.success(emptyList()) }, addChildToSpaceResult = addChildToSpaceResult, ) val roomInfoFlow = MutableStateFlow>(Optional.empty()) @@ -261,6 +262,7 @@ class ConfigureRoomPresenterTest { fun `present - creating a room and adding it into a parent space works when all the data is available`() = runTest { val addChildToSpaceResult = lambdaRecorder> { _, _ -> Result.success(Unit) } val spaceService = FakeSpaceService( + editableSpacesResult = { Result.success(emptyList()) }, addChildToSpaceResult = addChildToSpaceResult, ) val roomInfoFlow = MutableStateFlow>(Optional.empty()) @@ -522,7 +524,9 @@ class ConfigureRoomPresenterTest { private fun createMatrixClient( isAliasAvailable: Boolean = true, - spaceService: FakeSpaceService = FakeSpaceService(), + spaceService: FakeSpaceService = FakeSpaceService( + editableSpacesResult = { Result.success(emptyList()) } + ), ) = FakeMatrixClient( userIdServerNameLambda = { "matrix.org" }, resolveRoomAliasResult = { diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt index f431a2c761..d2844c79f0 100644 --- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt @@ -809,14 +809,13 @@ class SecurityAndPrivacyPresenterTest { ) ) ) - // No spaces available, so isSpaceMemberSelectable should be false + // Room has SpaceMember access with existing space ID, so isSpaceMemberSelectable is true val presenter = createSecurityAndPrivacyPresenter(room = room) presenter.test { skipItems(1) with(awaitItem()) { assertThat(savedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java) - assertThat(isSpaceMemberSelectable).isFalse() - // showSpaceMemberOption should still be true because savedSettings has SpaceMember + assertThat(isSpaceMemberSelectable).isTrue() assertThat(showSpaceMemberOption).isTrue() } cancelAndIgnoreRemainingEvents() From 29f716e9050039db656c2e4e931c8f3a2fd207f0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:26:37 +0100 Subject: [PATCH 06/40] fix(deps): update haze to v1.7.2 (#6175) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47c2221772..109a923109 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ appyx = "1.7.1" sqldelight = "2.2.1" wysiwyg = "2.41.1" telephoto = "0.18.0" -haze = "1.7.1" +haze = "1.7.2" # Dependency analysis dependencyAnalysis = "3.5.1" From 6b2819477c92f5e5a60a1bdfe0c44b7357fc228d Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 11 Feb 2026 12:45:54 +0100 Subject: [PATCH 07/40] Update SpaceFilterButton selected state color --- .../element/android/features/home/impl/components/HomeTopBar.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt index f8786c544c..69547db58a 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt @@ -263,7 +263,7 @@ private fun SpaceFilterButton( onClick = ::onClick, colors = if (isSelected) { IconButtonDefaults.iconButtonColors( - containerColor = ElementTheme.colors.bgAccentRest, + containerColor = ElementTheme.colors.bgActionPrimaryRest, contentColor = ElementTheme.colors.iconOnSolidPrimary, ) } else { From 410790635c0c2079da1c6cdb63f3f205b82511f1 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Wed, 11 Feb 2026 12:01:58 +0000 Subject: [PATCH 08/40] Update screenshots --- ...mpl.components_HomeTopBarSpaceFiltersSelected_Day_0_en.png | 4 ++-- ...l.components_HomeTopBarSpaceFiltersSelected_Night_0_en.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaceFiltersSelected_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaceFiltersSelected_Day_0_en.png index 7963c6c5a5..0d36ac3681 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaceFiltersSelected_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaceFiltersSelected_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6356cf3f4d2ec169852024b6a939a3b53bc117aa6d27179783252bc19038b0b4 -size 23101 +oid sha256:f0caea3f8eb17dbebdc738a60f2f734ec26c1f94f511eab50669a1c2ec0370a1 +size 23094 diff --git a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaceFiltersSelected_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaceFiltersSelected_Night_0_en.png index afb57ce364..e91898f2a3 100644 --- a/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaceFiltersSelected_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.home.impl.components_HomeTopBarSpaceFiltersSelected_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:86e1bd1197fb4affc1b14b7a2f7f24a1c89ffaaf8bdb17312c0e4059175bb878 -size 21224 +oid sha256:0e41fe1823898014736c2b68a3d60f055d483626deae2407ad0145703a6fc1f6 +size 21317 From 71fef289d1f0992af7b7d4797858bbcffdf33818 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 11 Feb 2026 15:36:12 +0100 Subject: [PATCH 09/40] Cleanup: remove unused `summaryLine` field. --- .../notifications/NotificationDataFactory.kt | 52 ------------------- .../DefaultSummaryGroupMessageCreatorTest.kt | 1 - .../NotificationDataFactoryTest.kt | 4 -- .../notifications/NotificationRendererTest.kt | 2 +- 4 files changed, 1 insertion(+), 58 deletions(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt index 5489fa0894..6a84f8df8f 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt @@ -9,17 +9,12 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification -import android.graphics.Typeface -import android.text.style.StyleSpan -import androidx.core.text.buildSpannedString -import androidx.core.text.inSpans import coil3.ImageLoader import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding 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.ThreadId -import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent @@ -98,7 +93,6 @@ class DefaultNotificationDataFactory( notification = notification, roomId = roomId, threadId = threadId, - summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDm), messageCount = events.size, latestTimestamp = events.maxOf { it.timestamp }, shouldBing = events.any { it.noisy } @@ -123,7 +117,6 @@ class DefaultNotificationDataFactory( OneShotNotification( tag = event.roomId.value, notification = notificationCreator.createRoomInvitationNotification(notificationAccountParams, event), - summaryLine = event.description, isNoisy = event.noisy, timestamp = event.timestamp ) @@ -140,7 +133,6 @@ class DefaultNotificationDataFactory( OneShotNotification( tag = event.eventId.value, notification = notificationCreator.createSimpleEventNotification(notificationAccountParams, event), - summaryLine = event.description, isNoisy = event.noisy, timestamp = event.timestamp ) @@ -157,7 +149,6 @@ class DefaultNotificationDataFactory( OneShotNotification( tag = event.eventId.value, notification = notificationCreator.createFallbackNotification(notificationAccountParams, event), - summaryLine = event.description.orEmpty(), isNoisy = false, timestamp = event.timestamp ) @@ -184,53 +175,12 @@ class DefaultNotificationDataFactory( ) } } - - private fun createRoomMessagesGroupSummaryLine(events: List, roomName: String, roomIsDm: Boolean): CharSequence { - return when (events.size) { - 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDm) - else -> { - stringProvider.getQuantityString( - R.plurals.notification_compat_summary_line_for_room, - events.size, - roomName, - events.size - ) - } - } - } - - private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDm: Boolean): CharSequence { - return if (roomIsDm) { - buildSpannedString { - event.senderDisambiguatedDisplayName?.let { - inSpans(StyleSpan(Typeface.BOLD)) { - append(it) - append(": ") - } - } - append(event.description) - } - } else { - buildSpannedString { - inSpans(StyleSpan(Typeface.BOLD)) { - append(roomName) - append(": ") - event.senderDisambiguatedDisplayName?.let { - append(it) - append(" ") - } - } - append(event.description) - } - } - } } data class RoomNotification( val notification: Notification, val roomId: RoomId, val threadId: ThreadId?, - val summaryLine: CharSequence, val messageCount: Int, val latestTimestamp: Long, val shouldBing: Boolean, @@ -239,7 +189,6 @@ data class RoomNotification( return notification == other.notification && roomId == other.roomId && threadId == other.threadId && - summaryLine.toString() == other.summaryLine.toString() && messageCount == other.messageCount && latestTimestamp == other.latestTimestamp && shouldBing == other.shouldBing @@ -249,7 +198,6 @@ data class RoomNotification( data class OneShotNotification( val notification: Notification, val tag: String, - val summaryLine: CharSequence, val isNoisy: Boolean, val timestamp: Long, ) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt index ba17fd0683..01537fe09a 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt @@ -39,7 +39,6 @@ class DefaultSummaryGroupMessageCreatorTest { RoomNotification( notification = Notification(), roomId = A_ROOM_ID, - summaryLine = "", messageCount = 1, latestTimestamp = A_FAKE_TIMESTAMP + 10, shouldBing = true, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt index 7d9d1e6550..2cd066ea71 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt @@ -64,7 +64,6 @@ class NotificationDataFactoryTest { OneShotNotification( notification = expectedNotification, tag = A_ROOM_ID.value, - summaryLine = AN_INVITATION_EVENT.description, isNoisy = AN_INVITATION_EVENT.noisy, timestamp = AN_INVITATION_EVENT.timestamp ) @@ -83,7 +82,6 @@ class NotificationDataFactoryTest { OneShotNotification( notification = expectedNotification, tag = AN_EVENT_ID.value, - summaryLine = A_SIMPLE_EVENT.description, isNoisy = A_SIMPLE_EVENT.noisy, timestamp = AN_INVITATION_EVENT.timestamp ) @@ -105,7 +103,6 @@ class NotificationDataFactoryTest { existingNotification = null, ), roomId = A_ROOM_ID, - summaryLine = "A room name: Bob Hello world!", messageCount = events.size, latestTimestamp = events.maxOf { it.timestamp }, shouldBing = events.any { it.noisy }, @@ -161,7 +158,6 @@ class NotificationDataFactoryTest { existingNotification = null, ), roomId = A_ROOM_ID, - summaryLine = "A room name: Bob Hello world!", messageCount = withRedactedRemoved.size, latestTimestamp = withRedactedRemoved.maxOf { it.timestamp }, shouldBing = withRedactedRemoved.any { it.noisy }, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt index 51d491f444..957d199138 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt @@ -43,7 +43,7 @@ private const val USE_COMPLETE_NOTIFICATION_FORMAT = true private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(A_NOTIFICATION) private val ONE_SHOT_NOTIFICATION = - OneShotNotification(notification = A_NOTIFICATION, tag = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) + OneShotNotification(notification = A_NOTIFICATION, tag = "ignored", isNoisy = false, timestamp = -1) @RunWith(RobolectricTestRunner::class) class NotificationRendererTest { From 4234600d06b578b8b32f0e4de22a485b998c7b01 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 11 Feb 2026 16:43:03 +0100 Subject: [PATCH 10/40] Notification: implement a counter in the fallback notification. --- .../ActiveNotificationsProvider.kt | 6 ++ .../FallbackNotificationFactory.kt | 5 +- .../notifications/NotificationDataFactory.kt | 36 +++++------ .../notifications/NotificationRenderer.kt | 12 ++-- .../SummaryGroupMessageCreator.kt | 2 - .../factories/NotificationCreator.kt | 59 +++++++++++-------- .../impl/src/main/res/values/localazy.xml | 4 ++ .../DefaultActiveNotificationsProviderTest.kt | 13 ++++ .../DefaultNotifiableEventResolverTest.kt | 4 +- .../DefaultNotificationDrawerManagerTest.kt | 2 - .../DefaultSummaryGroupMessageCreatorTest.kt | 1 - .../NotificationDataFactoryTest.kt | 2 - .../notifications/NotificationRendererTest.kt | 2 - .../DefaultNotificationCreatorTest.kt | 27 +++++---- .../fake/FakeActiveNotificationsProvider.kt | 5 ++ .../fake/FakeNotificationCreator.kt | 14 +++-- .../fake/FakeNotificationDataFactory.kt | 17 +++--- .../fake/FakeSummaryGroupMessageCreator.kt | 10 ++-- .../push/impl/push/DefaultPushHandlerTest.kt | 2 - 19 files changed, 126 insertions(+), 97 deletions(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt index 4cc279b8bb..fd2788279b 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt @@ -34,6 +34,7 @@ interface ActiveNotificationsProvider { fun getMembershipNotificationForSession(sessionId: SessionId): List fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? + fun getFallbackNotification(sessionId: SessionId): StatusBarNotification? fun count(sessionId: SessionId): Int } @@ -76,6 +77,11 @@ class DefaultActiveNotificationsProvider( return getNotificationsForSession(sessionId).find { it.id == summaryId } } + override fun getFallbackNotification(sessionId: SessionId): StatusBarNotification? { + val fallbackId = NotificationIdProvider.getFallbackNotificationId(sessionId) + return getNotificationsForSession(sessionId).find { it.id == fallbackId } + } + override fun count(sessionId: SessionId): Int { return getNotificationsForSession(sessionId).size } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactory.kt index 7b06aae39f..970f19fd45 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactory.kt @@ -12,15 +12,12 @@ import dev.zacsweers.metro.Inject 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 -import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent -import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.systemclock.SystemClock @Inject class FallbackNotificationFactory( private val clock: SystemClock, - private val stringProvider: StringProvider, ) { fun create( sessionId: SessionId, @@ -36,7 +33,7 @@ class FallbackNotificationFactory( isRedacted = false, isUpdated = false, timestamp = clock.epochMillis(), - description = stringProvider.getString(R.string.notification_fallback_content), + description = "", cause = cause, ) } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt index 6a84f8df8f..f44ea41c45 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt @@ -21,7 +21,6 @@ import io.element.android.libraries.push.impl.notifications.model.FallbackNotifi 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.SimpleNotifiableEvent -import io.element.android.services.toolbox.api.strings.StringProvider interface NotificationDataFactory { suspend fun toNotifications( @@ -46,16 +45,15 @@ interface NotificationDataFactory { @JvmName("toNotificationFallbackEvents") @Suppress("INAPPLICABLE_JVM_NAME") - fun toNotifications( + fun toNotification( fallback: List, notificationAccountParams: NotificationAccountParams, - ): List + ): OneShotNotification? fun createSummaryNotification( roomNotifications: List, invitationNotifications: List, simpleNotifications: List, - fallbackNotifications: List, notificationAccountParams: NotificationAccountParams, ): SummaryNotification } @@ -66,7 +64,6 @@ class DefaultNotificationDataFactory( private val roomGroupMessageCreator: RoomGroupMessageCreator, private val summaryGroupMessageCreator: SummaryGroupMessageCreator, private val activeNotificationsProvider: ActiveNotificationsProvider, - private val stringProvider: StringProvider, ) : NotificationDataFactory { override suspend fun toNotifications( messages: List, @@ -141,25 +138,31 @@ class DefaultNotificationDataFactory( @JvmName("toNotificationFallbackEvents") @Suppress("INAPPLICABLE_JVM_NAME") - override fun toNotifications( + override fun toNotification( fallback: List, notificationAccountParams: NotificationAccountParams, - ): List { - return fallback.map { event -> - OneShotNotification( - tag = event.eventId.value, - notification = notificationCreator.createFallbackNotification(notificationAccountParams, event), - isNoisy = false, - timestamp = event.timestamp - ) - } + ): OneShotNotification? { + if (fallback.isEmpty()) return null + val existingNotification = activeNotificationsProvider + .getFallbackNotification(notificationAccountParams.user.userId) + ?.notification + val notification = notificationCreator.createFallbackNotification( + existingNotification, + notificationAccountParams, + fallback, + ) + return OneShotNotification( + tag = "FALLBACK", + notification = notification, + isNoisy = false, + timestamp = fallback.first().timestamp + ) } override fun createSummaryNotification( roomNotifications: List, invitationNotifications: List, simpleNotifications: List, - fallbackNotifications: List, notificationAccountParams: NotificationAccountParams, ): SummaryNotification { return when { @@ -169,7 +172,6 @@ class DefaultNotificationDataFactory( roomNotifications = roomNotifications, invitationNotifications = invitationNotifications, simpleNotifications = simpleNotifications, - fallbackNotifications = fallbackNotifications, notificationAccountParams = notificationAccountParams, ) ) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt index 26769f09ba..c26a495ce8 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt @@ -55,12 +55,11 @@ class NotificationRenderer( val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, imageLoader, notificationAccountParams) val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents, notificationAccountParams) val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents, notificationAccountParams) - val fallbackNotifications = notificationDataFactory.toNotifications(groupedEvents.fallbackEvents, notificationAccountParams) + val fallbackNotification = notificationDataFactory.toNotification(groupedEvents.fallbackEvents, notificationAccountParams) val summaryNotification = notificationDataFactory.createSummaryNotification( roomNotifications = roomNotifications, invitationNotifications = invitationNotifications, simpleNotifications = simpleNotifications, - fallbackNotifications = fallbackNotifications, notificationAccountParams = notificationAccountParams, ) @@ -107,13 +106,12 @@ class NotificationRenderer( } } - // Show only the first fallback notification - if (fallbackNotifications.isNotEmpty()) { - Timber.tag(loggerTag.value).d("Showing fallback notification") + if (fallbackNotification != null) { + Timber.tag(loggerTag.value).d("Showing or updating fallback notification") notificationDisplayer.showNotification( - tag = "FALLBACK", + tag = fallbackNotification.tag, id = NotificationIdProvider.getFallbackNotificationId(currentUser.userId), - notification = fallbackNotifications.first().notification + notification = fallbackNotification.notification, ) } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt index f7f0c057c6..d283adae71 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt @@ -22,7 +22,6 @@ interface SummaryGroupMessageCreator { roomNotifications: List, invitationNotifications: List, simpleNotifications: List, - fallbackNotifications: List, ): Notification } @@ -45,7 +44,6 @@ class DefaultSummaryGroupMessageCreator( roomNotifications: List, invitationNotifications: List, simpleNotifications: List, - fallbackNotifications: List, ): Notification { val summaryIsNoisy = roomNotifications.any { it.shouldBing } || invitationNotifications.any { it.isNoisy } || diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt index 9533f6b0ac..9707a35398 100755 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt @@ -75,8 +75,9 @@ interface NotificationCreator { ): Notification fun createFallbackNotification( + existingNotification: Notification?, notificationAccountParams: NotificationAccountParams, - fallbackNotifiableEvent: FallbackNotifiableEvent, + fallbackNotifiableEvents: List, ): Notification /** @@ -240,11 +241,13 @@ class DefaultNotificationCreator( .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) // Build the pending intent for when the notification is clicked - .setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent( - sessionId = inviteNotifiableEvent.sessionId, - roomId = inviteNotifiableEvent.roomId, - eventId = null, - )) + .setContentIntent( + pendingIntentFactory.createOpenRoomPendingIntent( + sessionId = inviteNotifiableEvent.sessionId, + roomId = inviteNotifiableEvent.roomId, + eventId = null, + ) + ) .apply { if (inviteNotifiableEvent.noisy) { // Compat @@ -276,12 +279,14 @@ class DefaultNotificationCreator( .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .configureWith(notificationAccountParams) .setAutoCancel(true) - .setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent( - sessionId = simpleNotifiableEvent.sessionId, - roomId = simpleNotifiableEvent.roomId, - eventId = null, - extras = bundleOf(ROOM_OPENED_FROM_NOTIFICATION to true), - )) + .setContentIntent( + pendingIntentFactory.createOpenRoomPendingIntent( + sessionId = simpleNotifiableEvent.sessionId, + roomId = simpleNotifiableEvent.roomId, + eventId = null, + extras = bundleOf(ROOM_OPENED_FROM_NOTIFICATION to true), + ) + ) .apply { if (simpleNotifiableEvent.noisy) { // Compat @@ -295,28 +300,35 @@ class DefaultNotificationCreator( } override fun createFallbackNotification( + existingNotification: Notification?, notificationAccountParams: NotificationAccountParams, - fallbackNotifiableEvent: FallbackNotifiableEvent, + fallbackNotifiableEvents: List, ): Notification { val channelId = notificationChannels.getChannelIdForMessage(false) + val existingCounter = existingNotification + ?.extras + ?.getInt(FALLBACK_COUNTER_EXTRA) + ?: 0 + val counter = existingCounter + fallbackNotifiableEvents.size + val fallbackNotifiableEvent = fallbackNotifiableEvents.first() return NotificationCompat.Builder(context, channelId) .setOnlyAlertOnce(true) .setContentTitle(buildMeta.applicationName.annotateForDebug(7)) - .setContentText(fallbackNotifiableEvent.description.orEmpty().annotateForDebug(8)) + .setContentText( + stringProvider.getQuantityString(R.plurals.notification_fallback_n_content, counter, counter) + .annotateForDebug(8) + ) + .setExtras( + bundleOf( + FALLBACK_COUNTER_EXTRA to counter + ) + ) + .setNumber(counter) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .configureWith(notificationAccountParams) .setAutoCancel(true) .setWhen(fallbackNotifiableEvent.timestamp) - // Ideally we'd use `createOpenRoomPendingIntent` here, but the broken notification might apply to an invite - // and the user won't have access to the room yet, resulting in an error screen. .setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(fallbackNotifiableEvent.sessionId)) - .setDeleteIntent( - pendingIntentFactory.createDismissEventPendingIntent( - fallbackNotifiableEvent.sessionId, - fallbackNotifiableEvent.roomId, - fallbackNotifiableEvent.eventId - ) - ) .setPriority(NotificationCompat.PRIORITY_LOW) .build() } @@ -503,6 +515,7 @@ class DefaultNotificationCreator( companion object { const val MESSAGE_EVENT_ID = "message_event_id" + private const val FALLBACK_COUNTER_EXTRA = "COUNTER" } } diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml index aac5c6c72b..15afb93d3f 100644 --- a/libraries/push/impl/src/main/res/values/localazy.xml +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -15,6 +15,10 @@ "The UnifiedPush notification distributor couldn\'t be registered, so you will not receive notifications anymore. Please check the notifications settings of the app and the status of the push distributor." "You have new messages." + + "You have %d new message." + "You have %d new messages." + "📹 Incoming call" "** Failed to send - please open room" "Join" diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt index 573ec37471..04a65073f6 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt @@ -153,6 +153,19 @@ class DefaultActiveNotificationsProviderTest { assertThat(activeNotificationsProvider.getSummaryNotification(A_SESSION_ID_2)).isNull() } + @Test + fun `getFallbackNotification returns only the fallback notification for that session id if it exists`() { + val activeNotifications = listOf( + aStatusBarNotification(id = notificationIdProvider.getFallbackNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getFallbackNotification(A_SESSION_ID)).isNotNull() + assertThat(activeNotificationsProvider.getFallbackNotification(A_SESSION_ID_2)).isNull() + } + private fun aStatusBarNotification(id: Int, groupId: String, tag: String? = null) = mockk { every { this@mockk.id } returns id every { this@mockk.tag } returns tag 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 58f97e55cb..ba58db2a3c 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 @@ -56,7 +56,6 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableMess 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.test.strings.FakeStringProvider 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 @@ -663,7 +662,7 @@ class DefaultNotifiableEventResolverTest { roomId = A_ROOM_ID, eventId = AN_EVENT_ID, editedEventId = null, - description = "You have new messages.", + description = "", canBeReplaced = true, isRedacted = false, isUpdated = false, @@ -895,7 +894,6 @@ class DefaultNotifiableEventResolverTest { callNotificationEventResolver = callNotificationEventResolver, fallbackNotificationFactory = FallbackNotificationFactory( clock = FakeSystemClock(), - stringProvider = FakeStringProvider(defaultResult = "You have new messages.") ), featureFlagService = FakeFeatureFlagService(), ) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index 58bb86e683..608c45e9b8 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -37,7 +37,6 @@ import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.aNavigationState -import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -224,7 +223,6 @@ class DefaultNotificationDrawerManagerTest { roomGroupMessageCreator = roomGroupMessageCreator, summaryGroupMessageCreator = summaryGroupMessageCreator, activeNotificationsProvider = activeNotificationsProvider, - stringProvider = FakeStringProvider(), ), enterpriseService = enterpriseService, sessionStore = sessionStore, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt index 01537fe09a..0973ee69ce 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt @@ -47,7 +47,6 @@ class DefaultSummaryGroupMessageCreatorTest { ), invitationNotifications = emptyList(), simpleNotifications = emptyList(), - fallbackNotifications = emptyList(), ) notificationCreator.createSummaryListNotificationResult.assertions() diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt index 2cd066ea71..c07c1ff89e 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt @@ -23,7 +23,6 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGrou import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent -import io.element.android.services.toolbox.test.strings.FakeStringProvider import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith @@ -47,7 +46,6 @@ class NotificationDataFactoryTest { roomGroupMessageCreator = fakeRoomGroupMessageCreator, summaryGroupMessageCreator = fakeSummaryGroupMessageCreator, activeNotificationsProvider = activeNotificationsProvider, - stringProvider = FakeStringProvider(), ) @Test diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt index 957d199138..db292fcc61 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt @@ -29,7 +29,6 @@ import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNot import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore -import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.test.runTest @@ -57,7 +56,6 @@ class NotificationRendererTest { roomGroupMessageCreator = roomGroupMessageCreator, summaryGroupMessageCreator = summaryGroupMessageCreator, activeNotificationsProvider = FakeActiveNotificationsProvider(), - stringProvider = FakeStringProvider(), ) private val notificationIdProvider = NotificationIdProvider diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt index 0504ae433b..a346aa839f 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt @@ -82,19 +82,22 @@ class DefaultNotificationCreatorTest { fun `test createFallbackNotification`() { val sut = createNotificationCreator() val result = sut.createFallbackNotification( + existingNotification = null, notificationAccountParams = aNotificationAccountParams(), - FallbackNotifiableEvent( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - eventId = AN_EVENT_ID, - editedEventId = null, - description = "description", - canBeReplaced = false, - isRedacted = false, - isUpdated = false, - timestamp = A_FAKE_TIMESTAMP, - cause = null, - ), + fallbackNotifiableEvents = listOf( + FallbackNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + description = "description", + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + timestamp = A_FAKE_TIMESTAMP, + cause = null, + ), + ) ) result.commonAssertions( expectedCategory = null, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt index ae3edce437..2b374e5ac1 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt @@ -21,6 +21,7 @@ class FakeActiveNotificationsProvider( private val getMembershipNotificationForSessionResult: (SessionId) -> List = { emptyList() }, private val getMembershipNotificationForRoomResult: (SessionId, RoomId) -> List = { _, _ -> emptyList() }, private val getSummaryNotificationResult: (SessionId) -> StatusBarNotification? = { null }, + private val getFallbackNotificationResult: (SessionId) -> StatusBarNotification? = { null }, private val countResult: (SessionId) -> Int = { 0 }, ) : ActiveNotificationsProvider { override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List { @@ -47,6 +48,10 @@ class FakeActiveNotificationsProvider( return getSummaryNotificationResult(sessionId) } + override fun getFallbackNotification(sessionId: SessionId): StatusBarNotification? { + return getFallbackNotificationResult(sessionId) + } + override fun count(sessionId: SessionId): Int { return countResult(sessionId) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt index d5e4ad9695..33071ef1ac 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiab import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder import io.element.android.tests.testutils.lambda.LambdaListAnyParamsRecorder import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder import io.element.android.tests.testutils.lambda.lambdaAnyRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -34,8 +35,8 @@ class FakeNotificationCreator( lambdaRecorder { _, _ -> A_NOTIFICATION }, var createSimpleNotificationResult: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> A_NOTIFICATION }, - var createFallbackNotificationResult: LambdaTwoParamsRecorder = - lambdaRecorder { _, _ -> A_NOTIFICATION }, + var createFallbackNotificationResult: LambdaThreeParamsRecorder, Notification> = + lambdaRecorder { _, _, _ -> A_NOTIFICATION }, var createSummaryListNotificationResult: LambdaFiveParamsRecorder< NotificationAccountParams, String, Boolean, Long, NotificationAccountParams, Notification > = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }, @@ -75,10 +76,15 @@ class FakeNotificationCreator( } override fun createFallbackNotification( + existingNotification: Notification?, notificationAccountParams: NotificationAccountParams, - fallbackNotifiableEvent: FallbackNotifiableEvent, + fallbackNotifiableEvents: List, ): Notification { - return createFallbackNotificationResult(notificationAccountParams, fallbackNotifiableEvent) + return createFallbackNotificationResult( + existingNotification, + notificationAccountParams, + fallbackNotifiableEvents, + ) } override fun createSummaryListNotification( diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt index 009513b640..32c50ed7fd 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt @@ -19,7 +19,7 @@ import io.element.android.libraries.push.impl.notifications.model.FallbackNotifi 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.SimpleNotifiableEvent -import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaFourParamsRecorder import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -28,18 +28,17 @@ class FakeNotificationDataFactory( var messageEventToNotificationsResult: LambdaThreeParamsRecorder< List, ImageLoader, NotificationAccountParams, List > = lambdaRecorder { _, _, _ -> emptyList() }, - var summaryToNotificationsResult: LambdaFiveParamsRecorder< + var summaryToNotificationsResult: LambdaFourParamsRecorder< List, List, List, - List, NotificationAccountParams, SummaryNotification - > = lambdaRecorder { _, _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) }, + > = lambdaRecorder { _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) }, var inviteToNotificationsResult: LambdaOneParamRecorder, List> = lambdaRecorder { _ -> emptyList() }, var simpleEventToNotificationsResult: LambdaOneParamRecorder, List> = lambdaRecorder { _ -> emptyList() }, - var fallbackEventToNotificationsResult: LambdaOneParamRecorder, List> = - lambdaRecorder { _ -> emptyList() }, + var fallbackEventToNotificationsResult: LambdaOneParamRecorder, OneShotNotification?> = + lambdaRecorder { _ -> null }, ) : NotificationDataFactory { override suspend fun toNotifications( messages: List, @@ -69,10 +68,10 @@ class FakeNotificationDataFactory( @JvmName("toNotificationFallbackEvents") @Suppress("INAPPLICABLE_JVM_NAME") - override fun toNotifications( + override fun toNotification( fallback: List, notificationAccountParams: NotificationAccountParams, - ): List { + ): OneShotNotification? { return fallbackEventToNotificationsResult(fallback) } @@ -80,14 +79,12 @@ class FakeNotificationDataFactory( roomNotifications: List, invitationNotifications: List, simpleNotifications: List, - fallbackNotifications: List, notificationAccountParams: NotificationAccountParams, ): SummaryNotification { return summaryToNotificationsResult( roomNotifications, invitationNotifications, simpleNotifications, - fallbackNotifications, notificationAccountParams, ) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt index 9db6853408..8bf309cd7b 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt @@ -14,27 +14,25 @@ import io.element.android.libraries.push.impl.notifications.RoomNotification import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION -import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaFourParamsRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder class FakeSummaryGroupMessageCreator( - var createSummaryNotificationResult: LambdaFiveParamsRecorder< - NotificationAccountParams, List, List, List, List, Notification> = - lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION } + var createSummaryNotificationResult: LambdaFourParamsRecorder< + NotificationAccountParams, List, List, List, Notification> = + lambdaRecorder { _, _, _, _ -> A_NOTIFICATION } ) : SummaryGroupMessageCreator { override fun createSummaryNotification( notificationAccountParams: NotificationAccountParams, roomNotifications: List, invitationNotifications: List, simpleNotifications: List, - fallbackNotifications: List, ): Notification { return createSummaryNotificationResult( notificationAccountParams, roomNotifications, invitationNotifications, simpleNotifications, - fallbackNotifications, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt index 7c95034985..043f996205 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -57,7 +57,6 @@ import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.Fa import io.element.android.libraries.workmanager.api.WorkManagerRequest import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider -import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaError @@ -724,7 +723,6 @@ class DefaultPushHandlerTest { appCoroutineScope = backgroundScope, fallbackNotificationFactory = FallbackNotificationFactory( clock = FakeSystemClock(), - stringProvider = FakeStringProvider(), ), syncOnNotifiableEvent = syncOnNotifiableEvent, featureFlagService = featureFlagService, From d2221160912a05e9a4b4fd3a66b0af2c4f167c4c Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 11 Feb 2026 17:32:15 +0100 Subject: [PATCH 11/40] Setting version for the release 26.02.0 --- plugins/src/main/kotlin/Versions.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 3c9d6d8c19..4e071e33b4 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -39,13 +39,13 @@ private const val versionYear = 26 * Month of the version on 2 digits. Value must be in [1,12]. * Do not update this value. it is updated by the release script. */ -private const val versionMonth = 1 +private const val versionMonth = 2 /** * Release number in the month. Value must be in [0,99]. * Do not update this value. it is updated by the release script. */ -private const val versionReleaseNumber = 2 +private const val versionReleaseNumber = 0 object Versions { /** From a29f50c2326e6db693168dae5a88d156fe6f1df4 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 11 Feb 2026 17:33:22 +0100 Subject: [PATCH 12/40] Adding fastlane file for version 26.02.0 --- fastlane/metadata/android/en-US/changelogs/202602000.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/202602000.txt diff --git a/fastlane/metadata/android/en-US/changelogs/202602000.txt b/fastlane/metadata/android/en-US/changelogs/202602000.txt new file mode 100644 index 0000000000..a4b397f1bb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202602000.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file From c31bfda23b8a3b1812dc8736294d3bb6fc6fcc78 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 11 Feb 2026 17:36:04 +0100 Subject: [PATCH 13/40] Fix stack overflow when quickly going back on a `Space` screen (#6180) --- .../io/element/android/features/space/impl/root/SpaceView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt index a15f9cb94d..05fb75ee5e 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt @@ -109,10 +109,12 @@ fun SpaceView( modifier: Modifier = Modifier, acceptDeclineInviteView: @Composable () -> Unit, ) { - BackHandler { + var handledBack by remember { mutableStateOf(false) } + BackHandler(enabled = !handledBack) { if (state.isManageMode) { state.eventSink(SpaceEvents.ExitManageMode) } else { + handledBack = true onBackClick() } } From e3ace3c87eccab65483e30e2b82dae52152c8333 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 11 Feb 2026 17:45:23 +0100 Subject: [PATCH 14/40] Rely on the SessionObserver to detect a sign out. --- .../DefaultNotificationDrawerManager.kt | 22 +++-- .../DefaultNotificationDrawerManagerTest.kt | 91 +++++++++++++------ ...aultOnMissedCallNotificationHandlerTest.kt | 12 +-- 3 files changed, 77 insertions(+), 48 deletions(-) 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 ee72c34b9e..cd3f0faf45 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 @@ -24,9 +24,10 @@ import io.element.android.libraries.push.api.notifications.NotificationIdProvide import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState -import io.element.android.services.appnavstate.api.currentSessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -46,28 +47,30 @@ class DefaultNotificationDrawerManager( private val matrixClientProvider: MatrixClientProvider, private val imageLoaderHolder: ImageLoaderHolder, private val activeNotificationsProvider: ActiveNotificationsProvider, + sessionObserver: SessionObserver, ) : NotificationCleaner { // TODO EAx add a setting per user for this private var useCompleteNotificationFormat = true + private val sessionListener = object : SessionListener { + override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) { + // User signed out, clear all notifications related to the session. + clearAllEvents(SessionId(userId)) + } + } + init { // Observe application state coroutineScope.launch { appNavigationStateService.appNavigationState .collect { onAppNavigationStateChange(it.navigationState) } } + sessionObserver.addListener(sessionListener) } - private var currentAppNavigationState: NavigationState? = null - private fun onAppNavigationStateChange(navigationState: NavigationState) { when (navigationState) { - NavigationState.Root -> { - currentAppNavigationState?.currentSessionId()?.let { sessionId -> - // User signed out, clear all notifications related to the session. - clearAllEvents(sessionId) - } - } + NavigationState.Root -> {} is NavigationState.Session -> {} is NavigationState.Space -> {} is NavigationState.Room -> { @@ -85,7 +88,6 @@ class DefaultNotificationDrawerManager( ) } } - currentAppNavigationState = navigationState } /** diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index 58bb86e683..9bbc2175e5 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -31,7 +31,9 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMe import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState @@ -205,35 +207,70 @@ class DefaultNotificationDrawerManagerTest { ) } - private fun TestScope.createDefaultNotificationDrawerManager( - notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(), - appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), - roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(), - summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(), - activeNotificationsProvider: FakeActiveNotificationsProvider = FakeActiveNotificationsProvider(), - matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), - sessionStore: SessionStore = InMemorySessionStore(), - enterpriseService: EnterpriseService = FakeEnterpriseService(), - ): DefaultNotificationDrawerManager { - return DefaultNotificationDrawerManager( + @Test + fun `when a session is signed out, clearAllEvent is invoked`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val notificationDisplayer = FakeNotificationDisplayer( + cancelNotificationResult = cancelNotificationResult, + ) + val summaryId = NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID) + val activeNotificationsProvider = FakeActiveNotificationsProvider( + getNotificationsForSessionResult = { + listOf( + mockk { + every { id } returns summaryId + every { tag } returns null + }, + ) + }, + countResult = { 1 }, + ) + val sessionObserver = FakeSessionObserver() + createDefaultNotificationDrawerManager( notificationDisplayer = notificationDisplayer, - notificationRenderer = NotificationRenderer( - notificationDisplayer = FakeNotificationDisplayer(), - notificationDataFactory = DefaultNotificationDataFactory( - notificationCreator = FakeNotificationCreator(), - roomGroupMessageCreator = roomGroupMessageCreator, - summaryGroupMessageCreator = summaryGroupMessageCreator, - activeNotificationsProvider = activeNotificationsProvider, - stringProvider = FakeStringProvider(), - ), - enterpriseService = enterpriseService, - sessionStore = sessionStore, - ), - appNavigationStateService = appNavigationStateService, - coroutineScope = backgroundScope, - matrixClientProvider = matrixClientProvider, - imageLoaderHolder = FakeImageLoaderHolder(), activeNotificationsProvider = activeNotificationsProvider, + sessionObserver = sessionObserver, + ) + // Simulate a session sign out + sessionObserver.onSessionDeleted(A_SESSION_ID.value) + // Verify we asked to cancel the notification with summaryId + cancelNotificationResult.assertions().isCalledExactly(1).withSequence( + listOf(value(null), value(summaryId)), ) } } + +fun TestScope.createDefaultNotificationDrawerManager( + notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(), + notificationRenderer: NotificationRenderer? = null, + appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), + roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(), + summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(), + activeNotificationsProvider: FakeActiveNotificationsProvider = FakeActiveNotificationsProvider(), + matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + sessionStore: SessionStore = InMemorySessionStore(), + enterpriseService: EnterpriseService = FakeEnterpriseService(), + sessionObserver: SessionObserver = FakeSessionObserver(), +): DefaultNotificationDrawerManager { + return DefaultNotificationDrawerManager( + notificationDisplayer = notificationDisplayer, + notificationRenderer = notificationRenderer ?: NotificationRenderer( + notificationDisplayer = FakeNotificationDisplayer(), + notificationDataFactory = DefaultNotificationDataFactory( + notificationCreator = FakeNotificationCreator(), + roomGroupMessageCreator = roomGroupMessageCreator, + summaryGroupMessageCreator = summaryGroupMessageCreator, + activeNotificationsProvider = activeNotificationsProvider, + stringProvider = FakeStringProvider(), + ), + enterpriseService = enterpriseService, + sessionStore = sessionStore, + ), + appNavigationStateService = appNavigationStateService, + coroutineScope = backgroundScope, + matrixClientProvider = matrixClientProvider, + imageLoaderHolder = FakeImageLoaderHolder(), + activeNotificationsProvider = activeNotificationsProvider, + sessionObserver = sessionObserver, + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt index 1a47200e12..9bba1c32d3 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt @@ -16,13 +16,9 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.notification.FakeNotificationService import io.element.android.libraries.matrix.test.notification.aNotificationData -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder -import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory -import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver -import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runCurrent @@ -47,16 +43,10 @@ class DefaultOnMissedCallNotificationHandlerTest { }) val defaultOnMissedCallNotificationHandler = DefaultOnMissedCallNotificationHandler( matrixClientProvider = matrixClientProvider, - defaultNotificationDrawerManager = DefaultNotificationDrawerManager( - notificationDisplayer = FakeNotificationDisplayer(), + defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( notificationRenderer = createNotificationRenderer( notificationDataFactory = dataFactory, ), - appNavigationStateService = FakeAppNavigationStateService(), - coroutineScope = backgroundScope, - matrixClientProvider = FakeMatrixClientProvider(), - imageLoaderHolder = FakeImageLoaderHolder(), - activeNotificationsProvider = FakeActiveNotificationsProvider(), ), callNotificationEventResolver = FakeCallNotificationEventResolver(resolveEventLambda = { _, _, _ -> Result.success(aNotifiableMessageEvent()) From 5f6ca3c30c915c454bf2a5d9b8fed54dc8027684 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 11 Feb 2026 18:31:51 +0100 Subject: [PATCH 15/40] Changelog for version 26.02.0 --- CHANGES.md | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 7419401245..9d16fcaed5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,77 @@ +Changes in Element X v26.02.0 +============================= + + + +## What's Changed +### ✨ Features +* When a background SDK task fails, react in the client by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6166 +* Enable space feature flags by default by @ganfra in https://github.com/element-hq/element-x-android/pull/6171 +### 🙌 Improvements +* Improve space management with pagination and partial failure handling by @ganfra in https://github.com/element-hq/element-x-android/pull/6099 +* Iterate on QrCode login error buttons by @bmarty in https://github.com/element-hq/element-x-android/pull/6101 +* Update icon shown for world_readable rooms by @richvdh in https://github.com/element-hq/element-x-android/pull/6111 +* QRCode login: treat not found error as expired error. by @bmarty in https://github.com/element-hq/element-x-android/pull/6161 +* Iterate on Space related UI by @ganfra in https://github.com/element-hq/element-x-android/pull/6150 +### 🔒 Security +* Ensure aspect ratio of images in the timeline is restricted by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6168 +### 🐛 Bugfixes +* Ensure that Element Call activity is not closed when using an external link by @bmarty in https://github.com/element-hq/element-x-android/pull/6114 +* Refresh a Space's room list after creating a room in it by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6135 +* When creating a DM, set room history visibility to `invited` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6138 +* Fix back navigation after creating a room in a space by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6134 +* Fix `LinkifyHelper` index out of bounds with parenthesis by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6140 +* Change role screen won't be dismissed until changes take effect by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6141 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6122 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6155 +### 🧱 Build +* Try fixing Maestro tests (again) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6149 +* Add a stale bot for X-Needs-Info issues. by @bmarty in https://github.com/element-hq/element-x-android/pull/6153 +* [Release script] Ensure that the release version will match the next Monday date by @bmarty in https://github.com/element-hq/element-x-android/pull/6152 +### 🚧 In development 🚧 +* Add Space Filters feature for Room List by @ganfra in https://github.com/element-hq/element-x-android/pull/6136 +* Add history sharing badges to room details by @kaylendog in https://github.com/element-hq/element-x-android/pull/6132 +### Dependency upgrades +* Update dependency androidx.work:work-runtime-ktx to v2.11.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6105 +* Update metro to v0.10.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6106 +* Update camera to v1.5.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6103 +* Update activity to v1.12.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6104 +* Update dependency com.posthog:posthog-android to v3.30.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6120 +* Update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6102 +* Update roborazzi to v1.58.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6124 +* Update kover by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6139 +* Update dependency com.posthog:posthog-android to v3.31.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6145 +* Update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6142 +* Update media3 to v1.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6151 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.02.6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6144 +* Update firebaseAppDistribution to v5.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6146 +* Update dependency com.google.firebase:firebase-bom to v34.9.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6148 +* Update dependency io.sentry:sentry-android to v8.32.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6157 +* Update metro to v0.10.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6164 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.2.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6169 +* chore(deps): update plugin paparazzi to v2.0.0-alpha04 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6048 +* fix(deps): update dependency org.jetbrains.kotlinx:kover-gradle-plugin to v0.9.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6173 +* fix(deps): update haze to v1.7.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6175 +### Others +* Improve favorite wording and icon of room by @bmarty in https://github.com/element-hq/element-x-android/pull/6097 +* Add special flow for leaving a space as the last owner by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6112 +* Remove `runBlocking` in `ThreadedMessagesNode` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6108 +* Revert "Add "call.pro.element.io" in the list of known hosts for Element Call." by @bmarty in https://github.com/element-hq/element-x-android/pull/6118 +* Refactor room list filtering to use Rust SDK by @ganfra in https://github.com/element-hq/element-x-android/pull/6117 +* Ensure http 429 are retried 3 times before failing. by @bmarty in https://github.com/element-hq/element-x-android/pull/6119 +* Remove `JoinRule.Private` from the codebase by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6129 +* Fix voice message recording not starting after permission is granted by @kknappe in https://github.com/element-hq/element-x-android/pull/6109 +* Use correct bg color. by @bmarty in https://github.com/element-hq/element-x-android/pull/6165 +* Document "Developer options" and remove outdated instructions by @MadLittleMods in https://github.com/element-hq/element-x-android/pull/6162 +* Update SpaceFilterButton selected state color by @ganfra in https://github.com/element-hq/element-x-android/pull/6178 + +## New Contributors +* @kknappe made their first contribution in https://github.com/element-hq/element-x-android/pull/6109 +* @MadLittleMods made their first contribution in https://github.com/element-hq/element-x-android/pull/6162 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.01.2...v26.02.0 + Changes in Element X v26.01.2 ============================= From 83fee6d9ba4bdfa2ffc00db6e886659ee651f853 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 11 Feb 2026 18:28:23 +0100 Subject: [PATCH 16/40] Remove `NavigationState.Space`. We do not have the concept of current space. --- .../android/appnav/LoggedInFlowNode.kt | 4 - .../libraries/matrix/api/core/SpaceId.kt | 5 - .../DefaultNotificationDrawerManager.kt | 5 +- .../api/AppNavigationStateService.kt | 4 - .../appnavstate/api/NavigationState.kt | 10 +- .../api/NavigationStateExtension.kt | 18 +-- .../impl/DefaultAppNavigationStateService.kt | 49 ++------ .../impl/DefaultNavigationStateServiceTest.kt | 118 ++---------------- .../appnavstate/test/AppNavStateFixture.kt | 12 +- .../test/FakeAppNavigationStateService.kt | 7 -- 10 files changed, 22 insertions(+), 210 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index c76150a67e..ea78faf50f 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -79,7 +79,6 @@ import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.core.MAIN_SPACE import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.UserId @@ -211,8 +210,6 @@ class LoggedInFlowNode( onCreate = { analyticsRoomListStateWatcher.start() appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId) - // TODO We do not support Space yet, so directly navigate to main space - appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) loggedInFlowProcessor.observeEvents(sessionCoroutineScope) matrixClient.sessionVerificationService.setListener(verificationListener) mediaPreviewConfigMigration() @@ -242,7 +239,6 @@ class LoggedInFlowNode( } }, onDestroy = { - appNavigationStateService.onLeavingSpace(id) appNavigationStateService.onLeavingSession(id) loggedInFlowProcessor.stopObserving() matrixClient.sessionVerificationService.setListener(null) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt index 6acf2e43d8..0f270b3453 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt @@ -9,8 +9,3 @@ package io.element.android.libraries.matrix.api.core typealias SpaceId = RoomId - -/** - * Value to use when no space is selected by the user. - */ -val MAIN_SPACE = SpaceId("!mainSpace:local") 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 cd3f0faf45..3dbf09e227 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 @@ -72,17 +72,16 @@ class DefaultNotificationDrawerManager( when (navigationState) { NavigationState.Root -> {} is NavigationState.Session -> {} - is NavigationState.Space -> {} is NavigationState.Room -> { // Cleanup notification for current room clearMessagesForRoom( - sessionId = navigationState.parentSpace.parentSession.sessionId, + sessionId = navigationState.parentSession.sessionId, roomId = navigationState.roomId, ) } is NavigationState.Thread -> { clearMessagesForThread( - sessionId = navigationState.parentRoom.parentSpace.parentSession.sessionId, + sessionId = navigationState.parentRoom.parentSession.sessionId, roomId = navigationState.parentRoom.roomId, threadId = navigationState.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 8387afca59..cf32bb0bf4 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 @@ -10,7 +10,6 @@ package io.element.android.services.appnavstate.api import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.ThreadId import kotlinx.coroutines.flow.StateFlow @@ -23,9 +22,6 @@ interface AppNavigationStateService { fun onNavigateToSession(owner: String, sessionId: SessionId) fun onLeavingSession(owner: String) - fun onNavigateToSpace(owner: String, spaceId: SpaceId) - fun onLeavingSpace(owner: String) - fun onNavigateToRoom(owner: String, roomId: RoomId) fun onLeavingRoom(owner: String) 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 index 0ff606e051..c29e153a64 100644 --- 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 @@ -10,7 +10,6 @@ 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 /** @@ -29,17 +28,10 @@ sealed class NavigationState(open 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, + val parentSession: Session, ) : NavigationState(owner) data class Thread( 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 index bf336a38bf..d0b263d698 100644 --- 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 @@ -10,26 +10,14 @@ 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 + is NavigationState.Room -> parentSession.sessionId + is NavigationState.Thread -> parentRoom.parentSession.sessionId } } @@ -37,7 +25,6 @@ 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 } @@ -47,7 +34,6 @@ 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/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 e820bf9262..cdc8f7b07f 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 @@ -15,7 +15,6 @@ import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.services.appnavstate.api.AppForegroundStateService import io.element.android.services.appnavstate.api.AppNavigationState @@ -62,7 +61,6 @@ class DefaultAppNavigationStateService( Timber.tag(loggerTag.value).d("Navigating to session $sessionId. Current state: $currentValue") val newValue: NavigationState.Session = when (currentValue) { is NavigationState.Session, - is NavigationState.Space, is NavigationState.Room, is NavigationState.Thread, is NavigationState.Root -> NavigationState.Session(owner, sessionId) @@ -70,28 +68,14 @@ class DefaultAppNavigationStateService( state.getAndUpdate { it.copy(navigationState = newValue) } } - override fun onNavigateToSpace(owner: String, spaceId: SpaceId) { - val currentValue = state.value.navigationState - Timber.tag(loggerTag.value).d("Navigating to space $spaceId. Current state: $currentValue") - val newValue: NavigationState.Space = when (currentValue) { - NavigationState.Root -> return logError("onNavigateToSession()") - is NavigationState.Session -> NavigationState.Space(owner, spaceId, currentValue) - is NavigationState.Space -> NavigationState.Space(owner, spaceId, currentValue.parentSession) - is NavigationState.Room -> NavigationState.Space(owner, spaceId, currentValue.parentSpace.parentSession) - is NavigationState.Thread -> NavigationState.Space(owner, spaceId, currentValue.parentRoom.parentSpace.parentSession) - } - state.getAndUpdate { it.copy(navigationState = newValue) } - } - override fun onNavigateToRoom(owner: String, roomId: RoomId) { val currentValue = state.value.navigationState Timber.tag(loggerTag.value).d("Navigating to room $roomId. Current state: $currentValue") val newValue: NavigationState.Room = when (currentValue) { NavigationState.Root -> return logError("onNavigateToSession()") - is NavigationState.Session -> return logError("onNavigateToSpace()") - is NavigationState.Space -> NavigationState.Room(owner, roomId, currentValue) - is NavigationState.Room -> NavigationState.Room(owner, roomId, currentValue.parentSpace) - is NavigationState.Thread -> NavigationState.Room(owner, roomId, currentValue.parentRoom.parentSpace) + is NavigationState.Session -> NavigationState.Room(owner, roomId, currentValue) + is NavigationState.Room -> NavigationState.Room(owner, roomId, currentValue.parentSession) + is NavigationState.Thread -> NavigationState.Room(owner, roomId, currentValue.parentRoom.parentSession) } state.getAndUpdate { it.copy(navigationState = newValue) } } @@ -101,8 +85,7 @@ class DefaultAppNavigationStateService( Timber.tag(loggerTag.value).d("Navigating to thread $threadId. Current state: $currentValue") val newValue: NavigationState.Thread = when (currentValue) { NavigationState.Root -> return logError("onNavigateToSession()") - is NavigationState.Session -> return logError("onNavigateToSpace()") - is NavigationState.Space -> return logError("onNavigateToRoom()") + is NavigationState.Session -> return logError("onNavigateToRoom()") is NavigationState.Room -> NavigationState.Thread(owner, threadId, currentValue) is NavigationState.Thread -> NavigationState.Thread(owner, threadId, currentValue.parentRoom) } @@ -115,8 +98,7 @@ class DefaultAppNavigationStateService( if (!currentValue.assertOwner(owner)) return val newValue: NavigationState.Room = when (currentValue) { NavigationState.Root -> return logError("onNavigateToSession()") - is NavigationState.Session -> return logError("onNavigateToSpace()") - is NavigationState.Space -> return logError("onNavigateToRoom()") + is NavigationState.Session -> return logError("onNavigateToRoom()") is NavigationState.Room -> return logError("onNavigateToThread()") is NavigationState.Thread -> currentValue.parentRoom } @@ -127,26 +109,11 @@ class DefaultAppNavigationStateService( val currentValue = state.value.navigationState Timber.tag(loggerTag.value).d("Leaving room. Current state: $currentValue") if (!currentValue.assertOwner(owner)) return - val newValue: NavigationState.Space = when (currentValue) { - NavigationState.Root -> return logError("onNavigateToSession()") - is NavigationState.Session -> return logError("onNavigateToSpace()") - is NavigationState.Space -> return logError("onNavigateToRoom()") - is NavigationState.Room -> currentValue.parentSpace - is NavigationState.Thread -> currentValue.parentRoom.parentSpace - } - state.getAndUpdate { it.copy(navigationState = newValue) } - } - - override fun onLeavingSpace(owner: String) { - val currentValue = state.value.navigationState - Timber.tag(loggerTag.value).d("Leaving space. Current state: $currentValue") - if (!currentValue.assertOwner(owner)) return val newValue: NavigationState.Session = when (currentValue) { NavigationState.Root -> return logError("onNavigateToSession()") - is NavigationState.Session -> return logError("onNavigateToSpace()") - is NavigationState.Space -> currentValue.parentSession - is NavigationState.Room -> currentValue.parentSpace.parentSession - is NavigationState.Thread -> currentValue.parentRoom.parentSpace.parentSession + is NavigationState.Session -> return logError("onNavigateToRoom()") + is NavigationState.Room -> currentValue.parentSession + is NavigationState.Thread -> currentValue.parentRoom.parentSession } state.getAndUpdate { it.copy(navigationState = newValue) } } diff --git a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt index 56f9c5e986..e29177f382 100644 --- a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt +++ b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultNavigationStateServiceTest.kt @@ -13,15 +13,12 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 -import io.element.android.libraries.matrix.test.A_SPACE_ID -import io.element.android.libraries.matrix.test.A_SPACE_ID_2 import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_THREAD_ID_2 import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.test.A_ROOM_OWNER import io.element.android.services.appnavstate.test.A_SESSION_OWNER -import io.element.android.services.appnavstate.test.A_SPACE_OWNER import io.element.android.services.appnavstate.test.A_THREAD_OWNER import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import kotlinx.coroutines.flow.first @@ -33,22 +30,17 @@ class DefaultNavigationStateServiceTest { private val navigationStateRoot = NavigationState.Root private val navigationStateSession = NavigationState.Session( owner = A_SESSION_OWNER, - sessionId = A_SESSION_ID - ) - private val navigationStateSpace = NavigationState.Space( - owner = A_SPACE_OWNER, - spaceId = A_SPACE_ID, - parentSession = navigationStateSession + sessionId = A_SESSION_ID, ) private val navigationStateRoom = NavigationState.Room( owner = A_ROOM_OWNER, roomId = A_ROOM_ID, - parentSpace = navigationStateSpace + parentSession = navigationStateSession, ) private val navigationStateThread = NavigationState.Thread( owner = A_THREAD_OWNER, threadId = A_THREAD_ID, - parentRoom = navigationStateRoom + parentRoom = navigationStateRoom, ) @Test @@ -57,8 +49,6 @@ class DefaultNavigationStateServiceTest { assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) - service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace) service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom) service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID) @@ -67,8 +57,6 @@ class DefaultNavigationStateServiceTest { service.onLeavingThread(A_THREAD_OWNER) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom) service.onLeavingRoom(A_ROOM_OWNER) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace) - service.onLeavingSpace(A_SPACE_OWNER) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) service.onLeavingSession(A_SESSION_OWNER) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) @@ -77,7 +65,7 @@ class DefaultNavigationStateServiceTest { @Test fun testFailure() = runTest { val service = createStateService() - service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) + service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) assertThat(service.appNavigationState.value.navigationState).isEqualTo(NavigationState.Root) } @@ -92,11 +80,6 @@ class DefaultNavigationStateServiceTest { service.navigateToSession() service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) - // From space (no effect) - service.reset() - service.navigateToSpace() - service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace) // From room service.reset() service.navigateToRoom() @@ -116,15 +99,10 @@ class DefaultNavigationStateServiceTest { // From root (no effect) service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) - // From session (no effect) + // From session service.reset() service.navigateToSession() service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) - // From space - service.reset() - service.navigateToSpace() - service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom) // From room service.reset() @@ -139,35 +117,6 @@ class DefaultNavigationStateServiceTest { assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom) } - @Test - fun testOnNavigateToSpace() = runTest { - val service = createStateService() - // From root (no effect) - service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) - // From session - service.reset() - service.navigateToSession() - service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace) - // From space - service.reset() - service.navigateToSpace() - // Navigate to another space - service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID_2) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace.copy(spaceId = A_SPACE_ID_2)) - // From room (no effect) - service.reset() - service.navigateToRoom() - service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace) - // From thread (no effect) - service.reset() - service.navigateToThread() - service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace) - } - @Test fun testOnNavigateToSession() = runTest { val service = createStateService() @@ -180,11 +129,6 @@ class DefaultNavigationStateServiceTest { // Navigate to another session service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID_2) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession.copy(sessionId = A_SESSION_ID_2)) - // From space - service.reset() - service.navigateToSpace() - service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) // From room service.reset() service.navigateToRoom() @@ -208,11 +152,6 @@ class DefaultNavigationStateServiceTest { service.navigateToSession() service.onLeavingThread(A_THREAD_OWNER) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) - // From space (no effect) - service.reset() - service.navigateToSpace() - service.onLeavingThread(A_THREAD_OWNER) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace) // From room (no effect) service.reset() service.navigateToRoom() @@ -236,16 +175,11 @@ class DefaultNavigationStateServiceTest { service.navigateToSession() service.onLeavingRoom(A_ROOM_OWNER) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) - // From space (no effect) - service.reset() - service.navigateToSpace() - service.onLeavingRoom(A_ROOM_OWNER) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace) // From room service.reset() service.navigateToRoom() service.onLeavingRoom(A_ROOM_OWNER) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace) + assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) // From thread (no effect) service.reset() service.navigateToThread() @@ -253,34 +187,6 @@ class DefaultNavigationStateServiceTest { assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread) } - @Test - fun testOnLeavingSpace() = runTest { - val service = createStateService() - // From root (no effect) - service.onLeavingSpace(A_SPACE_OWNER) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) - // From session (no effect) - service.reset() - service.navigateToSession() - service.onLeavingSpace(A_SPACE_OWNER) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) - // From space - service.reset() - service.navigateToSpace() - service.onLeavingSpace(A_SPACE_OWNER) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSession) - // From room (no effect) - service.reset() - service.navigateToRoom() - service.onLeavingSpace(A_SPACE_OWNER) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoom) - // From thread (no effect) - service.reset() - service.navigateToThread() - service.onLeavingSpace(A_SPACE_OWNER) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateThread) - } - @Test fun testOnLeavingSession() = runTest { val service = createStateService() @@ -292,11 +198,6 @@ class DefaultNavigationStateServiceTest { service.navigateToSession() service.onLeavingSession(A_SESSION_OWNER) assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateRoot) - // From space (no effect) - service.reset() - service.navigateToSpace() - service.onLeavingSession(A_SESSION_OWNER) - assertThat(service.appNavigationState.first().navigationState).isEqualTo(navigationStateSpace) // From room (no effect) service.reset() service.navigateToRoom() @@ -318,13 +219,8 @@ class DefaultNavigationStateServiceTest { onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID) } - private fun AppNavigationStateService.navigateToSpace() { - navigateToSession() - onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) - } - private fun AppNavigationStateService.navigateToRoom() { - navigateToSpace() + navigateToSession() onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) } 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 e9c2f6d2fa..7860320eee 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 @@ -8,21 +8,17 @@ package io.element.android.services.appnavstate.test -import io.element.android.libraries.matrix.api.core.MAIN_SPACE import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.services.appnavstate.api.NavigationState const val A_SESSION_OWNER = "aSessionOwner" -const val A_SPACE_OWNER = "aSpaceOwner" const val A_ROOM_OWNER = "aRoomOwner" const val A_THREAD_OWNER = "aThreadOwner" fun aNavigationState( sessionId: SessionId? = null, - spaceId: SpaceId? = MAIN_SPACE, roomId: RoomId? = null, threadId: ThreadId? = null, ): NavigationState { @@ -30,14 +26,10 @@ fun aNavigationState( return NavigationState.Root } val session = NavigationState.Session(A_SESSION_OWNER, sessionId) - if (spaceId == null) { + if (roomId == null) { return session } - val space = NavigationState.Space(A_SPACE_OWNER, spaceId, session) - if (roomId == null) { - return space - } - val room = NavigationState.Room(A_ROOM_OWNER, roomId, space) + val room = NavigationState.Room(A_ROOM_OWNER, roomId, session) if (threadId == null) { return room } diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt index d01bc4f34b..fd23b7c422 100644 --- a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt @@ -10,7 +10,6 @@ package io.element.android.services.appnavstate.test import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService @@ -28,15 +27,9 @@ class FakeAppNavigationStateService( override fun onNavigateToSession(owner: String, sessionId: SessionId) = Unit override fun onLeavingSession(owner: String) = Unit - override fun onNavigateToSpace(owner: String, spaceId: SpaceId) = Unit - - override fun onLeavingSpace(owner: String) = Unit - override fun onNavigateToRoom(owner: String, roomId: RoomId) = Unit - override fun onLeavingRoom(owner: String) = Unit override fun onNavigateToThread(owner: String, threadId: ThreadId) = Unit - override fun onLeavingThread(owner: String) = Unit } From 843e1a28b2693f1df034a78f6cd838ab3e4af162 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 11 Feb 2026 20:51:05 +0100 Subject: [PATCH 17/40] Cleanup --- .../push/impl/notifications/NotificationDataFactory.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt index f44ea41c45..5331519110 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt @@ -73,10 +73,7 @@ class DefaultNotificationDataFactory( val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() } .groupBy { it.roomId } return messagesToDisplay.flatMap { (roomId, events) -> - val roomName = events.lastOrNull()?.roomName ?: roomId.value - val isDm = events.lastOrNull()?.roomIsDm ?: false val eventsByThreadId = events.groupBy { it.threadId } - eventsByThreadId.map { (threadId, events) -> val notification = roomGroupMessageCreator.createRoomMessage( events = events, From 9fbb21fb39a2f1d6b863b3ed142b5f1cf7502637 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 11 Feb 2026 21:03:48 +0100 Subject: [PATCH 18/40] Add missing test --- .../NotificationDataFactoryTest.kt | 21 +++++++++++++++++++ .../DefaultNotificationCreatorTest.kt | 15 ++----------- .../fixtures/NotifiableEventFixture.kt | 15 +++++++++++++ .../push/impl/push/DefaultPushHandlerTest.kt | 15 ++----------- 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt index c07c1ff89e..f897388c66 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotif import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent @@ -33,6 +34,7 @@ private val MY_AVATAR_URL: String? = null private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID) private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID) private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) +private val A_FALLBACK_EVENT = aFallbackNotifiableEvent() @RunWith(RobolectricTestRunner::class) class NotificationDataFactoryTest { @@ -69,6 +71,25 @@ class NotificationDataFactoryTest { ) } + @Test + fun `given a fallback invitation when mapping to notification then it's added`() = testWith(notificationDataFactory) { + val fallbackEvents = listOf(A_FALLBACK_EVENT) + val expectedNotification = notificationCreator.createFallbackNotificationResult( + null, + aNotificationAccountParams(), + fallbackEvents, + ) + val result = toNotification(fallbackEvents, aNotificationAccountParams()) + assertThat(result).isEqualTo( + OneShotNotification( + notification = expectedNotification, + tag = "FALLBACK", + isNoisy = false, + timestamp = A_FALLBACK_EVENT.timestamp + ) + ) + } + @Test fun `given a simple event when mapping to notification then it's added`() = testWith(notificationDataFactory) { val expectedNotification = notificationCreator.createRoomInvitationNotificationResult( diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt index a346aa839f..ae343d811d 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt @@ -35,8 +35,8 @@ import io.element.android.libraries.push.impl.notifications.factories.action.Acc import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory +import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -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.SimpleNotifiableEvent import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider @@ -85,18 +85,7 @@ class DefaultNotificationCreatorTest { existingNotification = null, notificationAccountParams = aNotificationAccountParams(), fallbackNotifiableEvents = listOf( - FallbackNotifiableEvent( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - eventId = AN_EVENT_ID, - editedEventId = null, - description = "description", - canBeReplaced = false, - isRedacted = false, - isUpdated = false, - timestamp = A_FAKE_TIMESTAMP, - cause = null, - ), + aFallbackNotifiableEvent(), ) ) result.commonAssertions( diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt index edd0c2ba17..9b7929b6a0 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt @@ -24,10 +24,12 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_TIMESTAMP 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.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.SimpleNotifiableEvent +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP fun aSimpleNotifiableEvent( sessionId: SessionId = A_SESSION_ID, @@ -141,3 +143,16 @@ fun aNotifiableCallEvent( senderAvatarUrl = senderAvatarUrl, rtcNotificationType = rtcNotificationType, ) + +fun aFallbackNotifiableEvent() = FallbackNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + description = "A fallback notification", + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + timestamp = A_FAKE_TIMESTAMP, + cause = "Unable to decrypt event", +) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt index 043f996205..fc072144c7 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -40,9 +40,9 @@ import io.element.android.libraries.push.impl.notifications.DefaultNotificationR import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels +import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.libraries.push.impl.test.DefaultTestPush @@ -626,18 +626,7 @@ class DefaultPushHandlerTest { @Test fun `when receiving a fallback event, we notify the push history service about it not being resolved`() = runTest { - val aNotifiableFallbackEvent = FallbackNotifiableEvent( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - eventId = AN_EVENT_ID, - editedEventId = null, - description = "A fallback notification", - canBeReplaced = false, - isRedacted = false, - isUpdated = false, - timestamp = 0L, - cause = "Unable to decrypt event", - ) + val aNotifiableFallbackEvent = aFallbackNotifiableEvent() val notifiableEventResult = lambdaRecorder, Result>>> { _, _ -> val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) From ff4455b0cec9e241c28eb409033eefb061cbe35f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 09:12:17 +0100 Subject: [PATCH 19/40] Fix test. --- .../notifications/DefaultNotificationDrawerManagerTest.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index 9bbc2175e5..7f175a12c9 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -16,7 +16,6 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService 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_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.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider @@ -108,11 +107,9 @@ class DefaultNotificationDrawerManagerTest { runCurrent() appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID), isInForeground = true)) runCurrent() - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID), isInForeground = true)) + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_ROOM_ID), isInForeground = true)) runCurrent() - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID), isInForeground = true)) - runCurrent() - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID), isInForeground = true)) + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID), isInForeground = true)) runCurrent() // Like a user sign out appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) From 6778b53c14663a037dd2764995e6619d8db909d0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:12:54 +0100 Subject: [PATCH 20/40] fix(deps): update activity to v1.12.4 (#6184) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 109a923109..ecf0fcd5ef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ datastore = "1.2.0" constraintlayout = "2.2.1" constraintlayout_compose = "1.1.1" lifecycle = "2.10.0" -activity = "1.12.3" +activity = "1.12.4" media3 = "1.9.2" camera = "1.5.3" work = "2.11.1" From b6be7e051cfe7f34028c0d59a374c8d06b2e9803 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:10:36 +0100 Subject: [PATCH 21/40] fix(deps): update roborazzi to v1.59.0 (#6179) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ecf0fcd5ef..7bfea5df73 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ accompanist = "0.37.3" # Test test_core = "1.7.0" -roborazzi = "1.58.0" +roborazzi = "1.59.0" # Jetbrain datetime = "0.7.1" From 33a78ecda3ef1b10182cafc2cf6d6555439a6f08 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 12 Feb 2026 13:12:57 +0100 Subject: [PATCH 22/40] When linkifying, adjust the `URLSpan`'s url too (#6188) --- .../androidutils/text/LinkifyHelper.kt | 9 +++---- .../androidutils/text/LinkifierHelperTest.kt | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt index 5115605322..441f819546 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt @@ -16,8 +16,6 @@ import androidx.core.text.toSpannable import androidx.core.text.util.LinkifyCompat import io.element.android.libraries.core.extensions.runCatchingExceptions import timber.log.Timber -import kotlin.collections.component1 -import kotlin.collections.component2 /** * Helper class to linkify text while preserving existing URL spans. @@ -59,7 +57,8 @@ object LinkifyHelper { // Adapt the url in the URL span to the new end index too if needed if (end != newEnd) { - val url = spannable.subSequence(start, newEnd).toString() + val diff = end - newEnd + val url = urlSpan.url.substring(0, urlSpan.url.length - diff) spannable.removeSpan(urlSpan) spannable.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } else { @@ -87,12 +86,12 @@ object LinkifyHelper { var end = end // Trailing punctuation found, adjust the end index - while (spannable[end - 1] in sequenceOf('.', ',', ';', ':', '!', '?', '…') && end > start) { + while (end > start && spannable[end - 1] in sequenceOf('.', ',', ';', ':', '!', '?', '…')) { end-- } // If the last character is a closing parenthesis, check if it's part of a pair - if (spannable[end - 1] == ')' && end > start) { + if (end > start && spannable[end - 1] == ')') { val linkifiedTextLastPath = spannable.substring(start, end).substringAfterLast('/') val closingParenthesisCount = linkifiedTextLastPath.count { it == ')' } val openingParenthesisCount = linkifiedTextLastPath.count { it == '(' } diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt index 6fa18be1ac..22928781c9 100644 --- a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt @@ -10,7 +10,9 @@ package io.element.android.libraries.androidutils.text import android.telephony.TelephonyManager import android.text.style.URLSpan +import androidx.core.text.buildSpannedString import androidx.core.text.getSpans +import androidx.core.text.inSpans import androidx.core.text.toSpannable import com.google.common.truth.Truth.assertThat import io.element.android.tests.testutils.WarmUpRule @@ -140,4 +142,27 @@ class LinkifierHelperTest { assertThat(urlSpans.size).isEqualTo(1) assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android/READ(ME)") } + + @Test + fun `linkification handles trailing question marks`() { + val text = "A url: https://github.com/element-hq/element-android?" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android") + } + + @Test + fun `linkification doesn't modify existing URLSpan`() { + val text = buildSpannedString { + append("A url: ") + inSpans(URLSpan("https://github.com/element-hq/element-android?")) { + append("here") + } + } + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android?") + } } From 00fb4498f41c5226cdbd94aba6acd6fb53da941f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 09:57:50 +0100 Subject: [PATCH 23/40] Dismiss fallback notification when the room list is rendered. --- .../DefaultNotificationDrawerManager.kt | 16 +++++++++++++++- .../notifications/NotificationDataFactory.kt | 6 +++++- 2 files changed, 20 insertions(+), 2 deletions(-) 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 3dbf09e227..575206de10 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 @@ -71,7 +71,10 @@ class DefaultNotificationDrawerManager( private fun onAppNavigationStateChange(navigationState: NavigationState) { when (navigationState) { NavigationState.Root -> {} - is NavigationState.Session -> {} + is NavigationState.Session -> { + // Cleanup the fallback notification + clearFallbackForSession(navigationState.sessionId) + } is NavigationState.Room -> { // Cleanup notification for current room clearMessagesForRoom( @@ -121,6 +124,17 @@ class DefaultNotificationDrawerManager( .forEach { notificationDisplayer.cancelNotification(it.tag, it.id) } } + /** + * Remove the fallback notification for the session. + */ + fun clearFallbackForSession(sessionId: SessionId) { + notificationDisplayer.cancelNotification( + DefaultNotificationDataFactory.FALLBACK_NOTIFICATION_TAG, + NotificationIdProvider.getFallbackNotificationId(sessionId), + ) + clearSummaryNotificationIfNeeded(sessionId) + } + /** * Should be called when the application is currently opened and showing timeline for the given [roomId]. * Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room. diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt index 5331519110..957894e994 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt @@ -149,7 +149,7 @@ class DefaultNotificationDataFactory( fallback, ) return OneShotNotification( - tag = "FALLBACK", + tag = FALLBACK_NOTIFICATION_TAG, notification = notification, isNoisy = false, timestamp = fallback.first().timestamp @@ -174,6 +174,10 @@ class DefaultNotificationDataFactory( ) } } + + companion object { + const val FALLBACK_NOTIFICATION_TAG = "FALLBACK" + } } data class RoomNotification( From a651a1cd6f54a5cbc24b6d012fb75b5759f7eadd Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 10:53:51 +0100 Subject: [PATCH 24/40] Improve FakeAppNavigationStateService --- .../DefaultNotificationDrawerManagerTest.kt | 25 ++++++++----------- ...efaultAnalyticsRoomListStateWatcherTest.kt | 6 ++--- .../sentry/SentryAnalyticsProviderTest.kt | 9 +++---- .../test/FakeAppNavigationStateService.kt | 16 ++++++++---- 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index 83891fe4d0..ced45ca192 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -35,7 +35,6 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService -import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.aNavigationState import io.element.android.tests.testutils.lambda.any @@ -44,7 +43,6 @@ import io.element.android.tests.testutils.lambda.value import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -92,26 +90,25 @@ class DefaultNotificationDrawerManagerTest { @Test fun `react to applicationStateChange`() = runTest { // For now just call all the API. Later, add more valuable tests. - val appNavigationStateFlow: MutableStateFlow = MutableStateFlow( - AppNavigationState( - navigationState = NavigationState.Root, - isInForeground = true, - ) - ) - val appNavigationStateService = FakeAppNavigationStateService(appNavigationState = appNavigationStateFlow) + val appNavigationStateService = FakeAppNavigationStateService() createDefaultNotificationDrawerManager( appNavigationStateService = appNavigationStateService ) - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) + appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(), isInForeground = true)) runCurrent() - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID), isInForeground = true)) + appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(A_SESSION_ID), isInForeground = true)) runCurrent() - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_ROOM_ID), isInForeground = true)) + appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(A_SESSION_ID, A_ROOM_ID), isInForeground = true)) runCurrent() - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID), isInForeground = true)) + appNavigationStateService.emitNavigationState( + AppNavigationState( + aNavigationState(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID), + isInForeground = true + ) + ) runCurrent() // Like a user sign out - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) + appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(), isInForeground = true)) runCurrent() } diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt index 8796d6a2ee..80efdff7ae 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt @@ -63,9 +63,9 @@ class DefaultAnalyticsRoomListStateWatcherTest { @Test fun `Opening the app in a cold state does nothing`() = runTest { - val navigationStateService = FakeAppNavigationStateService().apply { - appNavigationState.emit(AppNavigationState(NavigationState.Root, false)) - } + val navigationStateService = FakeAppNavigationStateService( + initialAppNavigationState = AppNavigationState(NavigationState.Root, false) + ) val roomListService = FakeRoomListService().apply { postState(RoomListService.State.Idle) } diff --git a/services/analyticsproviders/sentry/src/test/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProviderTest.kt b/services/analyticsproviders/sentry/src/test/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProviderTest.kt index 24b938d9a5..a8dc9f3086 100644 --- a/services/analyticsproviders/sentry/src/test/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProviderTest.kt +++ b/services/analyticsproviders/sentry/src/test/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProviderTest.kt @@ -31,7 +31,6 @@ import io.sentry.Sentry import io.sentry.SentryTracer import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction -import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Test import org.junit.runner.RunWith @@ -149,7 +148,7 @@ class SentryAnalyticsProviderTest { ) }, appNavigationStateService = FakeAppNavigationStateService( - MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)) + initialAppNavigationState = AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true) ) ).run { init() @@ -182,7 +181,7 @@ class SentryAnalyticsProviderTest { ) }, appNavigationStateService = FakeAppNavigationStateService( - MutableStateFlow(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + initialAppNavigationState = AppNavigationState(navigationState = NavigationState.Root, isInForeground = true) ) ).run { init() @@ -203,7 +202,7 @@ class SentryAnalyticsProviderTest { ) }, appNavigationStateService = FakeAppNavigationStateService( - MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)) + initialAppNavigationState = AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true) ) ).run { init() @@ -221,7 +220,7 @@ class SentryAnalyticsProviderTest { buildMeta: BuildMeta = aBuildMeta(), getDatabaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) }, appNavigationStateService: FakeAppNavigationStateService = FakeAppNavigationStateService( - MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)) + initialAppNavigationState = AppNavigationState(NavigationState.Session("owner", A_SESSION_ID), isInForeground = true) ) ) = SentryAnalyticsProvider( context = InstrumentationRegistry.getInstrumentation().targetContext, diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt index fd23b7c422..1e9289f0b5 100644 --- a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt @@ -15,15 +15,21 @@ import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow class FakeAppNavigationStateService( - override val appNavigationState: MutableStateFlow = MutableStateFlow( - AppNavigationState( - navigationState = NavigationState.Root, - isInForeground = true, - ) + initialAppNavigationState: AppNavigationState = AppNavigationState( + navigationState = NavigationState.Root, + isInForeground = true, ), ) : AppNavigationStateService { + private val _appNavigationState: MutableStateFlow = MutableStateFlow(initialAppNavigationState) + override val appNavigationState = _appNavigationState.asStateFlow() + + fun emitNavigationState(state: AppNavigationState) { + _appNavigationState.value = state + } + override fun onNavigateToSession(owner: String, sessionId: SessionId) = Unit override fun onLeavingSession(owner: String) = Unit From 48fae51ef004fe44ca7cdbb725de9e8aa4a6ebf0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 10:46:20 +0100 Subject: [PATCH 25/40] Ignore fallback notification when the room list is rendered. Add more tests. --- .../DefaultNotificationDrawerManager.kt | 44 ++- .../model/NotifiableMessageEvent.kt | 25 -- .../DefaultNotificationDrawerManagerTest.kt | 266 +++++++++++++++++- .../fixtures/NotifiableEventFixture.kt | 6 +- .../appnavstate/test/AppNavStateFixture.kt | 9 + 5 files changed, 316 insertions(+), 34 deletions(-) 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 575206de10..a0e6193d99 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 @@ -22,12 +22,20 @@ import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +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.NotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom +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.SimpleNotifiableEvent import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.api.currentRoomId +import io.element.android.services.appnavstate.api.currentSessionId +import io.element.android.services.appnavstate.api.currentThreadId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -97,14 +105,11 @@ class DefaultNotificationDrawerManager( * Events might be grouped and there might not be one notification per event! */ suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { - if (notifiableEvent.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value)) { - return - } - renderEvents(listOf(notifiableEvent)) + onNotifiableEventsReceived(listOf(notifiableEvent)) } suspend fun onNotifiableEventsReceived(notifiableEvents: List) { - val eventsToNotify = notifiableEvents.filter { !it.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value) } + val eventsToNotify = notifiableEvents.filter { !it.shouldIgnoreRegardingApplicationState(appNavigationStateService.appNavigationState.value) } renderEvents(eventsToNotify) } @@ -206,3 +211,30 @@ class DefaultNotificationDrawerManager( } } } + +/** + * Used to check if a notification should be ignored based on the current application navigation state. + */ +private fun NotifiableEvent.shouldIgnoreRegardingApplicationState(appNavigationState: AppNavigationState): Boolean { + if (!appNavigationState.isInForeground) return false + return appNavigationState.navigationState.currentSessionId() == sessionId && + when (this) { + is NotifiableRingingCallEvent -> { + // Never ignore ringing call notifications + // Note that NotifiableRingingCallEvent are not handled by DefaultNotificationDrawerManage + false + } + is FallbackNotifiableEvent -> { + // Ignore if the room list is currently displayed + appNavigationState.navigationState is NavigationState.Session + } + is InviteNotifiableEvent, + is SimpleNotifiableEvent -> { + roomId == appNavigationState.navigationState.currentRoomId() + } + is NotifiableMessageEvent -> { + roomId == appNavigationState.navigationState.currentRoomId() && + threadId == appNavigationState.navigationState.currentThreadId() + } + } +} 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 07f7f8b3c7..1b29527cac 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 @@ -15,10 +15,6 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.EventType -import io.element.android.services.appnavstate.api.AppNavigationState -import io.element.android.services.appnavstate.api.currentRoomId -import io.element.android.services.appnavstate.api.currentSessionId -import io.element.android.services.appnavstate.api.currentThreadId data class NotifiableMessageEvent( override val sessionId: SessionId, @@ -56,24 +52,3 @@ data class NotifiableMessageEvent( val imageUri: Uri? get() = imageUriString?.toUri() } - -/** - * 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.navigationState.currentSessionId() ?: return false - return when (val currentRoomId = appNavigationState.navigationState.currentRoomId()) { - null -> false - else -> { - // Never ignore ringing call notifications - if (this is NotifiableRingingCallEvent) { - false - } else { - appNavigationState.isInForeground && - sessionId == currentSessionId && - roomId == currentRoomId && - (this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId() - } - } - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index ced45ca192..b080c77a2d 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -15,8 +15,11 @@ import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService 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_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.ui.components.aMatrixUser @@ -28,7 +31,11 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeNotificatio import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +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.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.sessionstorage.test.InMemorySessionStore @@ -37,6 +44,7 @@ import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.aNavigationState +import io.element.android.services.appnavstate.test.anAppNavigationState import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -231,6 +239,262 @@ class DefaultNotificationDrawerManagerTest { listOf(value(null), value(summaryId)), ) } + + @Test + fun `when the application is in background, all events trigger a notification`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID), + isInForeground = false, + ), + notifiableEvents = listOf( + aFallbackNotifiableEvent(sessionId = A_SESSION_ID), + aFallbackNotifiableEvent(sessionId = A_SESSION_ID_2), + anInviteNotifiableEvent(sessionId = A_SESSION_ID), + anInviteNotifiableEvent(sessionId = A_SESSION_ID_2), + aSimpleNotifiableEvent(sessionId = A_SESSION_ID), + aSimpleNotifiableEvent(sessionId = A_SESSION_ID_2), + aNotifiableMessageEvent(sessionId = A_SESSION_ID), + aNotifiableMessageEvent(sessionId = A_SESSION_ID_2), + aNotifiableMessageEvent(sessionId = A_SESSION_ID, threadId = A_THREAD_ID), + aNotifiableMessageEvent(sessionId = A_SESSION_ID_2, threadId = A_THREAD_ID_2), + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 2, + ) + + @Test + fun `fallback event is ignored when the room list is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID), + ), + notifiableEvents = listOf(aFallbackNotifiableEvent(sessionId = A_SESSION_ID)), + shouldEmitNotification = false, + ) + + @Test + fun `fallback event is not ignored when a room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID), + ), + notifiableEvents = listOf(aFallbackNotifiableEvent(sessionId = A_SESSION_ID)), + shouldEmitNotification = true, + ) + + @Test + fun `fallback event for other session is not ignored when the room list is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID_2), + ), + notifiableEvents = listOf(aFallbackNotifiableEvent(sessionId = A_SESSION_ID)), + shouldEmitNotification = true, + ) + + @Test + fun `invite notifiable event is emits a notification when the room list is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID), + ), + notifiableEvents = listOf(anInviteNotifiableEvent(sessionId = A_SESSION_ID)), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `invite notifiable event does not emit a notification when the same room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID), + ), + notifiableEvents = listOf( + anInviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + ), + shouldEmitNotification = false, + ) + + @Test + fun `invite notifiable event emits a notification when another room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID_2), + ), + notifiableEvents = listOf( + anInviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `simple notifiable event is emits a notification when the room list is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID), + ), + notifiableEvents = listOf(aSimpleNotifiableEvent(sessionId = A_SESSION_ID)), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `simple notifiable event does not emit a notification when the same room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID), + ), + notifiableEvents = listOf( + aSimpleNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + ), + shouldEmitNotification = false, + ) + + @Test + fun `simple notifiable event emits a notification when another room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID_2), + ), + notifiableEvents = listOf( + aSimpleNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `notifiable event is emits a notification when the room list is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID), + ), + notifiableEvents = listOf(aNotifiableMessageEvent(sessionId = A_SESSION_ID)), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `notifiable event does not emit a notification when the same room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID), + ), + notifiableEvents = listOf( + aNotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + ), + shouldEmitNotification = false, + ) + + @Test + fun `notifiable event for a thread emits a notification when the same room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID), + ), + notifiableEvents = listOf( + aNotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + ) + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `notifiable event for a thread does not emit a notification when the same thread is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID), + ), + notifiableEvents = listOf( + aNotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + ) + ), + shouldEmitNotification = false, + ) + + @Test + fun `notifiable event for a thread emits a notification when another thread is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID_2), + ), + notifiableEvents = listOf( + aNotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + ) + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `notifiable event for a thread emits a notification when a thread of another room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID_2, threadId = A_THREAD_ID_2), + ), + notifiableEvents = listOf( + aNotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + ) + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `notifiable event emits a notification when another room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID_2), + ), + notifiableEvents = listOf( + aNotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + private fun testOnNotifiableEventReceived( + appNavigationState: AppNavigationState, + notifiableEvents: List, + shouldEmitNotification: Boolean, + extraInvocationsForNotificationSummary: Int = 0, + ) = runTest { + val showNotificationResult = lambdaRecorder { _, _, _ -> + true + } + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + appNavigationStateService = FakeAppNavigationStateService( + initialAppNavigationState = appNavigationState, + ), + notificationDisplayer = FakeNotificationDisplayer( + showNotificationResult = showNotificationResult, + ) + ) + defaultNotificationDrawerManager.onNotifiableEventsReceived(notifiableEvents) + showNotificationResult.assertions().isCalledExactly( + if (shouldEmitNotification) { + notifiableEvents.size + extraInvocationsForNotificationSummary + } else { + 0 + } + ) + } } fun TestScope.createDefaultNotificationDrawerManager( @@ -248,7 +512,7 @@ fun TestScope.createDefaultNotificationDrawerManager( return DefaultNotificationDrawerManager( notificationDisplayer = notificationDisplayer, notificationRenderer = notificationRenderer ?: NotificationRenderer( - notificationDisplayer = FakeNotificationDisplayer(), + notificationDisplayer = notificationDisplayer, notificationDataFactory = DefaultNotificationDataFactory( notificationCreator = FakeNotificationCreator(), roomGroupMessageCreator = roomGroupMessageCreator, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt index 9b7929b6a0..0ab39d5180 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt @@ -144,8 +144,10 @@ fun aNotifiableCallEvent( rtcNotificationType = rtcNotificationType, ) -fun aFallbackNotifiableEvent() = FallbackNotifiableEvent( - sessionId = A_SESSION_ID, +fun aFallbackNotifiableEvent( + sessionId: SessionId = A_SESSION_ID, +) = FallbackNotifiableEvent( + sessionId = sessionId, roomId = A_ROOM_ID, eventId = AN_EVENT_ID, editedEventId = null, 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 7860320eee..e8338cb09c 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 @@ -11,6 +11,7 @@ package io.element.android.services.appnavstate.test import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.NavigationState const val A_SESSION_OWNER = "aSessionOwner" @@ -35,3 +36,11 @@ fun aNavigationState( } return NavigationState.Thread(A_THREAD_OWNER, threadId, room) } + +fun anAppNavigationState( + navigationState: NavigationState = aNavigationState(), + isInForeground: Boolean = true, +) = AppNavigationState( + navigationState = navigationState, + isInForeground = isInForeground, +) From 47c38ca19d219329301d80e0dec1a5e4ba4fe941 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:47:53 +0000 Subject: [PATCH 26/40] fix(deps): update dependency androidx.compose:compose-bom to v2026.02.00 (#6172) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7bfea5df73..ee81c32682 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ camera = "1.5.3" work = "2.11.1" # Compose -compose_bom = "2026.01.00" +compose_bom = "2026.02.00" # Coroutines coroutines = "1.10.2" From 0368aa8687c023003f412bb3895acd1b0202ca78 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 14:28:28 +0100 Subject: [PATCH 27/40] Fix warning --- .../android/features/messages/impl/MessagesPresenterTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index d52179f3ef..f6967de0e5 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -1238,7 +1238,7 @@ class MessagesPresenterTest { } @Test - fun `present - shows a "world_readable" icon if the room is encrypted and history is world_readable`() = runTest { + fun `present - shows a 'world_readable' icon if the room is encrypted and history is world_readable`() = runTest { val presenter = createMessagesPresenter( joinedRoom = FakeJoinedRoom( baseRoom = FakeBaseRoom( From 130ff40e5c1e4f3e28500156f7fd7da06fb80b77 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 14:42:58 +0100 Subject: [PATCH 28/40] Fix typo --- .../push/impl/notifications/DefaultNotificationDrawerManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a0e6193d99..0f64037aee 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 @@ -221,7 +221,7 @@ private fun NotifiableEvent.shouldIgnoreRegardingApplicationState(appNavigationS when (this) { is NotifiableRingingCallEvent -> { // Never ignore ringing call notifications - // Note that NotifiableRingingCallEvent are not handled by DefaultNotificationDrawerManage + // Note that NotifiableRingingCallEvent are not handled by DefaultNotificationDrawerManager false } is FallbackNotifiableEvent -> { From da6f289e621d35a6b760d2c7a8dd005532fd4c5b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 15:51:17 +0100 Subject: [PATCH 29/40] Incoming call screen: use color from theme and follow design. https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=16501-5740 --- .../android/features/call/impl/ui/IncomingCallScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt index 682c4cec73..62de00a10b 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt @@ -51,6 +51,9 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.ui.strings.CommonStrings +/** + * Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=16501-5740 + */ @Composable internal fun IncomingCallScreen( notificationData: CallNotificationData, @@ -143,7 +146,7 @@ private fun ActionButton( onClick = onClick, colors = IconButtonDefaults.filledIconButtonColors( containerColor = backgroundColor, - contentColor = Color.White, + contentColor = ElementTheme.colors.iconOnSolidPrimary, ) ) { Icon( From 55c78cbac0c67c2c945b4ade907408226c74b6d1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 16:01:05 +0100 Subject: [PATCH 30/40] Incoming call screen: ensure buttons stay grouped in the center of the screen. --- .../android/features/call/impl/ui/IncomingCallScreen.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt index 62de00a10b..5ec3689b44 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt @@ -97,11 +97,8 @@ internal fun IncomingCallScreen( ) } Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 24.dp, bottom = 64.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.padding(bottom = 64.dp), + horizontalArrangement = Arrangement.spacedBy(48.dp), ) { ActionButton( size = 64.dp, @@ -111,7 +108,6 @@ internal fun IncomingCallScreen( backgroundColor = ElementTheme.colors.iconSuccessPrimary, borderColor = ElementTheme.colors.borderSuccessSubtle ) - ActionButton( size = 64.dp, onClick = onCancel, From f5c250ed7a64cadfb7c40939cb6ea313a15a2ff4 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 12 Feb 2026 16:24:19 +0100 Subject: [PATCH 31/40] Fix Maestro tests again (#6167) * Increase the timeout for asserting 'Be in your element' is visible * Disable the chrome onboarding flow. This simplifies the code a lot and helps avoid corner cases. * More delays with timeout! * Add more info to the summary, specially when the tests fail, so we can quickly check what failed. * Make sure Maestro tests can't run in parallel, but APK builds can --- .github/workflows/maestro-local.yml | 24 +++++++++++++++---- .../maestro-local-with-screen-recording.sh | 7 ++++++ .maestro/tests/account/login.yaml | 21 ---------------- .maestro/tests/account/verifySession.yaml | 12 +++++++--- .../tests/assertions/assertInitDisplayed.yaml | 2 +- .../assertSessionVerificationDisplayed.yaml | 2 +- 6 files changed, 37 insertions(+), 31 deletions(-) diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index 4fd42534b7..a2851eb863 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -18,9 +18,8 @@ jobs: build-apk: name: Build APK runs-on: ubuntu-latest - # Allow one per PR. concurrency: - group: ${{ format('maestro-{0}', github.ref) }} + group: ${{ format('maestro-build-{0}', github.ref) }} cancel-in-progress: true steps: - uses: actions/checkout@v6 @@ -57,10 +56,10 @@ jobs: name: Maestro test suite runs-on: ubuntu-latest needs: [ build-apk ] - # Allow one per PR. + # Allow only one to run at a time, since they use the same environment. + # Otherwise, tests running in parallel can break each other. concurrency: - group: ${{ format('maestro-{0}', github.ref) }} - cancel-in-progress: true + group: maestro-test steps: - uses: actions/checkout@v6 if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' @@ -110,6 +109,21 @@ jobs: retention-days: 5 overwrite: true if-no-files-found: error + - name: Update summary (success) + if: steps.maestro_test.outcome == 'success' + run: | + echo "### Maestro tests worked :rocket:!" + - name: Update summary (failure) + if: steps.maestro_test.outcome != 'success' + run: | + LOG_FILE=$(find ~/.maestro/tests/ -name maestro.log) + echo "Log file: $LOG_FILE" + LOG_LINES="$(tail -n 30 $LOG_FILE)" + echo "### :x: Maestro tests failed... + + \`\`\` + $LOG_LINES + \`\`\`" >> $GITHUB_STEP_SUMMARY - name: Fail the workflow in case of error in test if: steps.maestro_test.outcome != 'success' run: | diff --git a/.github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh b/.github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh index a9f789a4f5..51a968fdec 100755 --- a/.github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh +++ b/.github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh @@ -8,6 +8,13 @@ # Please see LICENSE in the repository root for full details. # +# First we disable the onboarding flow on Chrome, which is a source of issues +# (see https://stackoverflow.com/a/64629745) +echo "Disabling Chrome onboarding flow" +adb shell am set-debug-app --persistent com.android.chrome +adb shell 'echo "chrome --disable-fre --no-default-browser-check --no-first-run" > /data/local/tmp/chrome-command-line' +adb shell am start -n com.android.chrome/com.google.android.apps.chrome.Main + adb install -r $1 echo "Starting the screen recording..." adb push .github/workflows/scripts/maestro/local-recording.sh /data/local/tmp/ diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml index 0661a30981..873843b22d 100644 --- a/.maestro/tests/account/login.yaml +++ b/.maestro/tests/account/login.yaml @@ -8,27 +8,6 @@ appId: ${MAESTRO_APP_ID} - tapOn: id: "login-continue" ## MAS page -## Conditional workflow to pass the Chrome first launch welcome page. -- retry: - maxRetries: 3 - commands: - - runFlow: - when: - visible: 'Use without an account' - commands: - - tapOn: "Use without an account" - ## For older chrome versions - - runFlow: - when: - visible: 'Accept & continue' - commands: - - tapOn: "Accept & continue" - - runFlow: - when: - visible: 'No thanks' - commands: - - tapOn: "No thanks" -## Working when running Maestro locally, but not on the CI yet. - retry: maxRetries: 3 commands: diff --git a/.maestro/tests/account/verifySession.yaml b/.maestro/tests/account/verifySession.yaml index a16322543f..a59f60b88f 100644 --- a/.maestro/tests/account/verifySession.yaml +++ b/.maestro/tests/account/verifySession.yaml @@ -1,5 +1,8 @@ appId: ${MAESTRO_APP_ID} --- +- extendedWaitUntil: + visible: "Enter recovery key" + timeout: 30000 - takeScreenshot: build/maestro/150-Verify - tapOn: "Enter recovery key" - tapOn: @@ -7,7 +10,10 @@ appId: ${MAESTRO_APP_ID} - inputText: ${MAESTRO_RECOVERY_KEY} - hideKeyboard - tapOn: "Continue" -- extendedWaitUntil: - visible: "Device verified" - timeout: 30000 +- retry: + maxRetries: 3 + commands: + - extendedWaitUntil: + visible: "Device verified" + timeout: 30000 - tapOn: "Continue" diff --git a/.maestro/tests/assertions/assertInitDisplayed.yaml b/.maestro/tests/assertions/assertInitDisplayed.yaml index 6e895d9bbf..7b5a2e665b 100644 --- a/.maestro/tests/assertions/assertInitDisplayed.yaml +++ b/.maestro/tests/assertions/assertInitDisplayed.yaml @@ -2,4 +2,4 @@ appId: ${MAESTRO_APP_ID} --- - extendedWaitUntil: visible: "Be in your element" - timeout: 10000 + timeout: 30000 diff --git a/.maestro/tests/assertions/assertSessionVerificationDisplayed.yaml b/.maestro/tests/assertions/assertSessionVerificationDisplayed.yaml index f983ced873..fff0fe7b32 100644 --- a/.maestro/tests/assertions/assertSessionVerificationDisplayed.yaml +++ b/.maestro/tests/assertions/assertSessionVerificationDisplayed.yaml @@ -2,4 +2,4 @@ appId: ${MAESTRO_APP_ID} --- - extendedWaitUntil: visible: "Confirm your identity" - timeout: 20000 + timeout: 60000 From 1c4405c89b82777074afd06bc8f011af1f105443 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 12 Feb 2026 15:37:16 +0000 Subject: [PATCH 32/40] Update screenshots --- .../features.call.impl.ui_IncomingCallScreen_Day_0_en.png | 4 ++-- .../features.call.impl.ui_IncomingCallScreen_Night_0_en.png | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_IncomingCallScreen_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_IncomingCallScreen_Day_0_en.png index d4a981b491..bcc1b05eb8 100644 --- a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_IncomingCallScreen_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_IncomingCallScreen_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:913d6230ab2b470dd5393344395bb9c25973318f09ecdc52499a17e9c9e8faba -size 66219 +oid sha256:4dac0f93eb31b26fa32173fbd834c7f661e4f47c79db66fa4d1536d938a4585d +size 66108 diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_IncomingCallScreen_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_IncomingCallScreen_Night_0_en.png index 0b23435bcd..b656d1e06c 100644 --- a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_IncomingCallScreen_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_IncomingCallScreen_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f59ff395027433af611ef1aec1a1d3e5a7d670df3c77d1c5d01154199c123a71 -size 58586 +oid sha256:4c3e5ef9368d68f661350a7a31b98b3ae3fbf975bc11d6f1b9e5ac908e6699dc +size 58355 From f7b80ca89bf2ca6317c256aeb70bb847e799b0b6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 16:27:55 +0100 Subject: [PATCH 33/40] Fix documentation --- .../impl/receivers/DeclineCallBroadcastReceiver.kt | 2 +- .../features/call/impl/ui/CallScreenPresenter.kt | 2 +- .../features/call/impl/ui/IncomingCallActivity.kt | 2 +- .../features/call/impl/utils/ActiveCallManager.kt | 9 +++++---- .../call/utils/DefaultActiveCallManagerTest.kt | 10 +++++----- .../features/call/utils/FakeActiveCallManager.kt | 6 +++--- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt index d2cbb0184d..902c4d7d8d 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt @@ -40,7 +40,7 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() { ?: return context.bindings().inject(this) appCoroutineScope.launch { - activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) + activeCallManager.hangUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt index 497b121da5..ba670e03aa 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt @@ -100,7 +100,7 @@ class CallScreenPresenter( ) } onDispose { - appCoroutineScope.launch { activeCallManager.hungUpCall(callType) } + appCoroutineScope.launch { activeCallManager.hangUpCall(callType) } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt index 714360a702..faedd2648c 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt @@ -118,7 +118,7 @@ class IncomingCallActivity : AppCompatActivity() { private fun onCancel() { val activeCall = activeCallManager.activeCall.value ?: return appCoroutineScope.launch { - activeCallManager.hungUpCall(callType = activeCall.callType) + activeCallManager.hangUpCall(callType = activeCall.callType) } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt index 4183e22531..2c0e2e7742 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt @@ -72,10 +72,10 @@ interface ActiveCallManager { suspend fun registerIncomingCall(notificationData: CallNotificationData) /** - * Called when the active call has been hung up. It will remove any existing UI and the active call. - * @param callType The type of call that the user hung up, either an external url one or a room one. + * Called to hang up the active call. It will hang up the call and remove any existing UI and the active call. + * @param callType The type of call that the user hangs up, either an external url one or a room one. */ - suspend fun hungUpCall(callType: CallType) + suspend fun hangUpCall(callType: CallType) /** * Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall]. @@ -192,8 +192,9 @@ class DefaultActiveCallManager( } } - override suspend fun hungUpCall(callType: CallType) = mutex.withLock { + override suspend fun hangUpCall(callType: CallType) = mutex.withLock { Timber.tag(tag).d("Hung up call: $callType") + Timber.tag(tag).d("Hang up call: $callType") val currentActiveCall = activeCall.value ?: run { Timber.tag(tag).w("No active call, ignoring hang up") return@withLock diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt index df14b4b423..2fac7634d7 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt @@ -155,7 +155,7 @@ class DefaultActiveCallManagerTest { } @Test - fun `hungUpCall - removes existing call if the CallType matches`() = runTest { + fun `hangUpCall - removes existing call if the CallType matches`() = runTest { setupShadowPowerManager() val notificationManagerCompat = mockk(relaxed = true) val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) @@ -165,7 +165,7 @@ class DefaultActiveCallManagerTest { assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeWakeLock?.isHeld).isTrue() - manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) + manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) assertThat(manager.activeCall.value).isNull() assertThat(manager.activeWakeLock?.isHeld).isFalse() @@ -192,7 +192,7 @@ class DefaultActiveCallManagerTest { val notificationData = aCallNotificationData(roomId = A_ROOM_ID) manager.registerIncomingCall(notificationData) - manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) + manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) coVerify { room.declineCall(notificationEventId = notificationData.eventId) @@ -269,7 +269,7 @@ class DefaultActiveCallManagerTest { } @Test - fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest { + fun `hangUpCall - does nothing if the CallType doesn't match`() = runTest { setupShadowPowerManager() val notificationManagerCompat = mockk(relaxed = true) val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) @@ -278,7 +278,7 @@ class DefaultActiveCallManagerTest { assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeWakeLock?.isHeld).isTrue() - manager.hungUpCall(CallType.ExternalUrl("https://example.com")) + manager.hangUpCall(CallType.ExternalUrl("https://example.com")) assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeWakeLock?.isHeld).isTrue() diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt index 74bd1c36ac..14b252e572 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt @@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow class FakeActiveCallManager( var registerIncomingCallResult: (CallNotificationData) -> Unit = {}, - var hungUpCallResult: (CallType) -> Unit = {}, + var hangUpCallResult: (CallType) -> Unit = {}, var joinedCallResult: (CallType) -> Unit = {}, ) : ActiveCallManager { override val activeCall = MutableStateFlow(null) @@ -26,8 +26,8 @@ class FakeActiveCallManager( registerIncomingCallResult(notificationData) } - override suspend fun hungUpCall(callType: CallType) = simulateLongTask { - hungUpCallResult(callType) + override suspend fun hangUpCall(callType: CallType) = simulateLongTask { + hangUpCallResult(callType) } override suspend fun joinedCall(callType: CallType) = simulateLongTask { From c95bbb6d07f8721d6f9119ecbb5bbfd412f512d1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 17:14:49 +0100 Subject: [PATCH 34/40] Let the call be declined from the notification if the application is killed when the call is ringing. --- .../receivers/DeclineCallBroadcastReceiver.kt | 9 ++++- .../call/impl/utils/ActiveCallManager.kt | 35 +++++++++++++++---- .../call/utils/FakeActiveCallManager.kt | 6 ++-- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt index 902c4d7d8d..b9775892c3 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt @@ -29,6 +29,7 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() { companion object { const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA" } + @Inject lateinit var activeCallManager: ActiveCallManager @@ -40,7 +41,13 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() { ?: return context.bindings().inject(this) appCoroutineScope.launch { - activeCallManager.hangUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) + activeCallManager.hangUpCall( + callType = CallType.RoomCall( + sessionId = notificationData.sessionId, + roomId = notificationData.roomId, + ), + notificationData = notificationData, + ) } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt index 2c0e2e7742..e85b101112 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt @@ -75,7 +75,10 @@ interface ActiveCallManager { * Called to hang up the active call. It will hang up the call and remove any existing UI and the active call. * @param callType The type of call that the user hangs up, either an external url one or a room one. */ - suspend fun hangUpCall(callType: CallType) + suspend fun hangUpCall( + callType: CallType, + notificationData: CallNotificationData? = null, + ) /** * Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall]. @@ -192,13 +195,30 @@ class DefaultActiveCallManager( } } - override suspend fun hangUpCall(callType: CallType) = mutex.withLock { - Timber.tag(tag).d("Hung up call: $callType") + override suspend fun hangUpCall( + callType: CallType, + notificationData: CallNotificationData?, + ) = mutex.withLock { Timber.tag(tag).d("Hang up call: $callType") + cancelIncomingCallNotification() val currentActiveCall = activeCall.value ?: run { + // activeCall.value can be null if the application has been killed while the call was ringing + // Build a currentActiveCall with the provided parameters. + if (notificationData != null) { + ActiveCall( + callType = callType, + callState = CallState.Ringing( + notificationData = notificationData, + ) + ) + } else { + null + } + } ?: run { Timber.tag(tag).w("No active call, ignoring hang up") return@withLock } + if (currentActiveCall.callType != callType) { Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring") return@withLock @@ -209,9 +229,13 @@ class DefaultActiveCallManager( matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull() ?.getRoom(notificationData.roomId) ?.declineCall(notificationData.eventId) + ?.onFailure { + Timber.e(it, "Failed to decline incoming call") + } + ?: run { + Timber.tag(tag).d("Couldn't find session or room to decline call for incoming call") + } } - - cancelIncomingCallNotification() if (activeWakeLock?.isHeld == true) { Timber.tag(tag).d("Releasing partial wakelock after hang up") activeWakeLock.release() @@ -222,7 +246,6 @@ class DefaultActiveCallManager( override suspend fun joinedCall(callType: CallType) = mutex.withLock { Timber.tag(tag).d("Joined call: $callType") - cancelIncomingCallNotification() if (activeWakeLock?.isHeld == true) { Timber.tag(tag).d("Releasing partial wakelock after joining call") diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt index 14b252e572..2d0e126ab5 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt @@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow class FakeActiveCallManager( var registerIncomingCallResult: (CallNotificationData) -> Unit = {}, - var hangUpCallResult: (CallType) -> Unit = {}, + var hangUpCallResult: (CallType, CallNotificationData?) -> Unit = { _, _ -> }, var joinedCallResult: (CallType) -> Unit = {}, ) : ActiveCallManager { override val activeCall = MutableStateFlow(null) @@ -26,8 +26,8 @@ class FakeActiveCallManager( registerIncomingCallResult(notificationData) } - override suspend fun hangUpCall(callType: CallType) = simulateLongTask { - hangUpCallResult(callType) + override suspend fun hangUpCall(callType: CallType, notificationData: CallNotificationData?) = simulateLongTask { + hangUpCallResult(callType, notificationData) } override suspend fun joinedCall(callType: CallType) = simulateLongTask { From 88a104a6d41ed2d982e11d3d5e0b26b5d28272fd Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 17:57:31 +0100 Subject: [PATCH 35/40] Swap receiver and parameter for a nicer code. --- .../DefaultNotificationDrawerManager.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 0f64037aee..e328c46209 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 @@ -109,7 +109,7 @@ class DefaultNotificationDrawerManager( } suspend fun onNotifiableEventsReceived(notifiableEvents: List) { - val eventsToNotify = notifiableEvents.filter { !it.shouldIgnoreRegardingApplicationState(appNavigationStateService.appNavigationState.value) } + val eventsToNotify = notifiableEvents.filter { !appNavigationStateService.appNavigationState.value.shouldIgnoreEvent(it) } renderEvents(eventsToNotify) } @@ -213,12 +213,12 @@ class DefaultNotificationDrawerManager( } /** - * Used to check if a notification should be ignored based on the current application navigation state. + * Used to check if a notifiableEvent should be ignored based on the current application navigation state. */ -private fun NotifiableEvent.shouldIgnoreRegardingApplicationState(appNavigationState: AppNavigationState): Boolean { - if (!appNavigationState.isInForeground) return false - return appNavigationState.navigationState.currentSessionId() == sessionId && - when (this) { +private fun AppNavigationState.shouldIgnoreEvent(event: NotifiableEvent): Boolean { + if (!isInForeground) return false + return navigationState.currentSessionId() == event.sessionId && + when (event) { is NotifiableRingingCallEvent -> { // Never ignore ringing call notifications // Note that NotifiableRingingCallEvent are not handled by DefaultNotificationDrawerManager @@ -226,15 +226,15 @@ private fun NotifiableEvent.shouldIgnoreRegardingApplicationState(appNavigationS } is FallbackNotifiableEvent -> { // Ignore if the room list is currently displayed - appNavigationState.navigationState is NavigationState.Session + navigationState is NavigationState.Session } is InviteNotifiableEvent, is SimpleNotifiableEvent -> { - roomId == appNavigationState.navigationState.currentRoomId() + event.roomId == navigationState.currentRoomId() } is NotifiableMessageEvent -> { - roomId == appNavigationState.navigationState.currentRoomId() && - threadId == appNavigationState.navigationState.currentThreadId() + event.roomId == navigationState.currentRoomId() && + event.threadId == navigationState.currentThreadId() } } } From 0233f4dde4821201db75d54b5aba2ae7ff2f6226 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 17:57:52 +0100 Subject: [PATCH 36/40] Add name parameters --- .../push/impl/notifications/NotificationDataFactory.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt index 957894e994..487b3df597 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt @@ -144,9 +144,9 @@ class DefaultNotificationDataFactory( .getFallbackNotification(notificationAccountParams.user.userId) ?.notification val notification = notificationCreator.createFallbackNotification( - existingNotification, - notificationAccountParams, - fallback, + existingNotification = existingNotification, + notificationAccountParams = notificationAccountParams, + fallbackNotifiableEvents = fallback, ) return OneShotNotification( tag = FALLBACK_NOTIFICATION_TAG, From 960884b55b6f4f89a7d71971dcb2276790156c17 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 18:00:33 +0100 Subject: [PATCH 37/40] Fix test compilation --- .../DefaultAnalyticsRoomListStateWatcherTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt index 80efdff7ae..5e378f6a31 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt @@ -43,9 +43,9 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // Make sure it's warm by changing its internal state - navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) + navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) runCurrent() - navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) runCurrent() // The transaction should be present now @@ -110,9 +110,9 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // Make sure it's warm by changing its internal state - navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) + navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) runCurrent() - navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) runCurrent() // The transaction should be present now @@ -145,9 +145,9 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // Make sure it's warm by changing its internal state - navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) + navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) runCurrent() - navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) runCurrent() // The transaction was never added From 44b13ef82bbac60fd80ee3facbacea3260399197 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 18:17:13 +0100 Subject: [PATCH 38/40] Fix quality issue --- .../android/features/call/impl/utils/ActiveCallManager.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt index e85b101112..a6663943bd 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt @@ -74,6 +74,7 @@ interface ActiveCallManager { /** * Called to hang up the active call. It will hang up the call and remove any existing UI and the active call. * @param callType The type of call that the user hangs up, either an external url one or a room one. + * @param notificationData The data for the incoming call notification. */ suspend fun hangUpCall( callType: CallType, @@ -204,15 +205,13 @@ class DefaultActiveCallManager( val currentActiveCall = activeCall.value ?: run { // activeCall.value can be null if the application has been killed while the call was ringing // Build a currentActiveCall with the provided parameters. - if (notificationData != null) { + notificationData?.let { ActiveCall( callType = callType, callState = CallState.Ringing( notificationData = notificationData, ) ) - } else { - null } } ?: run { Timber.tag(tag).w("No active call, ignoring hang up") From 5aae5607f62063517220391acb30b1515f3a32bb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 18:22:05 +0100 Subject: [PATCH 39/40] Fix test and add a new one. --- .../utils/DefaultActiveCallManagerTest.kt | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt index 2fac7634d7..6a4a215aec 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt @@ -199,6 +199,34 @@ class DefaultActiveCallManagerTest { } } + @Test + fun `Decline event - Hangup on a unknown call should send a decline event`() = runTest { + setupShadowPowerManager() + val notificationManagerCompat = mockk(relaxed = true) + + val room = mockk(relaxed = true) + + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) }) + + val manager = createActiveCallManager( + matrixClientProvider = clientProvider, + notificationManagerCompat = notificationManagerCompat + ) + + val notificationData = aCallNotificationData(roomId = A_ROOM_ID) + // Do not register the incoming call, so the manager doesn't know about it + manager.hangUpCall( + callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId), + notificationData = notificationData, + ) + coVerify { + room.declineCall(notificationEventId = notificationData.eventId) + } + } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `Decline event - Declining from another session should stop ringing`() = runTest { @@ -282,7 +310,8 @@ class DefaultActiveCallManagerTest { assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeWakeLock?.isHeld).isTrue() - verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) } + // The notification is always cancelled do not block the user + verify(exactly = 1) { notificationManagerCompat.cancel(notificationId) } } @OptIn(ExperimentalCoroutinesApi::class) From 0f170b671bff17efd3c76875944067adcab2d390 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 13 Feb 2026 15:19:31 +0100 Subject: [PATCH 40/40] Remove explicit dependency `androix.compose.material` (#6199) * Remove explicit dependency for `androix.compose.material` for compose library modules * Ensure `Button` uses the `material3.Icon` instead of the `material.Icon` * Remove entry in `libs.versions.toml` --- gradle/libs.versions.toml | 1 - .../android/libraries/designsystem/theme/components/Button.kt | 2 +- plugins/src/main/kotlin/extension/DependencyHandleScope.kt | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ee81c32682..179f5985b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -126,7 +126,6 @@ androidx_compose_ui_tooling = { module = "androidx.compose.ui:ui-tooling" } androidx_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx_compose_ui_test_manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx_compose_ui_test_junit = { module = "androidx.compose.ui:ui-test-junit4-android" } -androidx_compose_material = { module = "androidx.compose.material:material" } androidx_compose_material_icons = { module = "androidx.compose.material:material-icons-extended" } # Coroutines diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt index 676d34ee3e..db0aa39002 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt @@ -229,7 +229,7 @@ private fun ButtonInternal( Spacer(modifier = Modifier.width(8.dp)) } leadingIcon != null -> { - androidx.compose.material.Icon( + Icon( painter = leadingIcon.getPainter(), contentDescription = null, tint = LocalContentColor.current, diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 5a4c4f9aeb..e7cc47d7b8 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -74,7 +74,6 @@ fun DependencyHandlerScope.composeDependencies(libs: LibrariesForLibs) { implementation(composeBom) androidTestImplementation(composeBom) implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.icons) implementation(libs.androidx.compose.ui.tooling.preview)