From d20113cde7af4a6af998140a15505cdfd22d0746 Mon Sep 17 00:00:00 2001 From: Nick Hainke Date: Fri, 3 Jul 2026 00:02:27 +0200 Subject: [PATCH 1/6] Add per-room notification channels Add a RoomNotificationChannelManager that creates room-scoped Android notification channels for noisy room notifications. Channels are grouped as Private chats or Rooms, skip silent notifications, respect PIN privacy by falling back to the shared channel, and preserve the shared noisy channel sound. The manager also prunes stale unmodified channels. --- .../api/store/SessionPreferencesStore.kt | 20 ++ .../store/DefaultSessionPreferencesStore.kt | 38 +++ libraries/preferences/test/build.gradle.kts | 1 + .../test/InMemorySessionPreferencesStore.kt | 27 ++ .../RoomNotificationChannelManager.kt | 61 ++++ .../DefaultRoomNotificationChannelManager.kt | 239 ++++++++++++++ .../channels/NotificationChannels.kt | 32 ++ .../impl/src/main/res/values/temporary.xml | 10 + ...faultRoomNotificationChannelManagerTest.kt | 293 ++++++++++++++++++ .../channels/NotificationChannelsTest.kt | 18 ++ .../FakeRoomNotificationChannelManager.kt | 40 +++ 11 files changed, 779 insertions(+) create mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationChannelManager.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationChannelManager.kt create mode 100644 libraries/push/impl/src/main/res/values/temporary.xml create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationChannelManagerTest.kt create mode 100644 libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/channels/FakeRoomNotificationChannelManager.kt diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt index d3f3fb4e282..31fd821aa43 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.preferences.api.store +import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.flow.Flow interface SessionPreferencesStore { @@ -35,5 +36,24 @@ interface SessionPreferencesStore { suspend fun setVideoCompressionPreset(preset: VideoCompressionPreset) fun getVideoCompressionPreset(): Flow + /** + * Records "now" as the last time [roomId]'s auto-created per-room notification channel + * handled a notification. Used to retire long-inactive channels. + */ + suspend fun recordRoomChannelNotified(roomId: RoomId) + + /** Clears [roomId]'s recorded channel last-notified timestamp, if any. */ + suspend fun clearRoomChannelLastNotified(roomId: RoomId) + + /** + * Every recorded channel last-notified timestamp, keyed by the same room-id hash embedded in + * that room's channel id. Channel ids only carry a one-way hash of the room id, not the id + * itself, so a caller enumerating system channels can only look this up by that hash. + */ + suspend fun getRoomChannelLastNotifiedByHash(): Map + + /** Clears a channel last-notified timestamp by the room-id hash embedded in its channel id. */ + suspend fun clearRoomChannelLastNotifiedByHash(roomHash: String) + suspend fun clear() } diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt index 907d454ada6..36064f586f5 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt @@ -13,17 +13,20 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.hash.hash import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import java.io.File @@ -48,6 +51,16 @@ class DefaultSessionPreferencesStore( private val compressImages = booleanPreferencesKey("compressMedia") private val compressMediaPreset = stringPreferencesKey("compressMediaPreset") + // Keyed by a hash of the room id rather than declared statically: DataStore doesn't require + // enumerable keys, and one room can't collide with another's hashed prefix. Also looked up by + // raw hash, since a channel id enumerated from the system only carries that hash, not the + // original RoomId. + private val roomChannelLastNotifiedKeySuffix = "notifChannelLastNotified" + private fun roomChannelLastNotifiedKey(roomId: RoomId) = + longPreferencesKey("room_${roomId.value.hash().take(16)}_$roomChannelLastNotifiedKeySuffix") + private fun roomChannelLastNotifiedKeyFromHash(roomHash: String) = + longPreferencesKey("room_${roomHash}_$roomChannelLastNotifiedKeySuffix") + private val dataStoreFile = storeFile(context, sessionId) private val store = PreferenceDataStoreFactory.create( scope = sessionCoroutineScope, @@ -94,6 +107,31 @@ class DefaultSessionPreferencesStore( override fun getVideoCompressionPreset(): Flow = get(compressMediaPreset) { VideoCompressionPreset.STANDARD.name } .map { tryOrNull { VideoCompressionPreset.valueOf(it) } ?: VideoCompressionPreset.STANDARD } + override suspend fun recordRoomChannelNotified(roomId: RoomId) { + update(roomChannelLastNotifiedKey(roomId), System.currentTimeMillis()) + } + + override suspend fun clearRoomChannelLastNotified(roomId: RoomId) { + store.edit { prefs -> prefs.remove(roomChannelLastNotifiedKey(roomId)) } + } + + override suspend fun getRoomChannelLastNotifiedByHash(): Map { + val suffix = "_$roomChannelLastNotifiedKeySuffix" + return store.data.first().asMap().entries + .mapNotNull { (key, value) -> + if (key.name.startsWith("room_") && key.name.endsWith(suffix) && value is Long) { + key.name.removePrefix("room_").removeSuffix(suffix) to value + } else { + null + } + } + .toMap() + } + + override suspend fun clearRoomChannelLastNotifiedByHash(roomHash: String) { + store.edit { prefs -> prefs.remove(roomChannelLastNotifiedKeyFromHash(roomHash)) } + } + override suspend fun clear() { dataStoreFile.safeDelete() } diff --git a/libraries/preferences/test/build.gradle.kts b/libraries/preferences/test/build.gradle.kts index d433490e73f..41ed2231b8b 100644 --- a/libraries/preferences/test/build.gradle.kts +++ b/libraries/preferences/test/build.gradle.kts @@ -16,6 +16,7 @@ android { dependencies { api(projects.libraries.preferences.api) + implementation(projects.libraries.androidutils) implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) implementation(libs.coroutines.core) diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemorySessionPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemorySessionPreferencesStore.kt index 7e2027d58fa..ec4c860485b 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemorySessionPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemorySessionPreferencesStore.kt @@ -8,6 +8,8 @@ package io.element.android.libraries.preferences.test +import io.element.android.libraries.androidutils.hash.hash +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import kotlinx.coroutines.flow.Flow @@ -31,9 +33,15 @@ class InMemorySessionPreferencesStore( private val isSessionVerificationSkipped = MutableStateFlow(isSessionVerificationSkipped) private val doesCompressMedia = MutableStateFlow(doesCompressMedia) private val videoCompressionPreset = MutableStateFlow(videoCompressionPreset) + + // Keyed by the same room-id hash used in channel ids (see DefaultRoomNotificationChannelManager), + // matching how the real store has to bridge RoomId <-> channel-id-embedded hash. + private val roomChannelLastNotified = mutableMapOf() var clearCallCount = 0 private set + private fun roomHash(roomId: RoomId): String = roomId.value.hash().take(16) + override suspend fun setSharePresence(enabled: Boolean) { isSharePresenceEnabled.tryEmit(enabled) } @@ -84,6 +92,25 @@ class InMemorySessionPreferencesStore( return videoCompressionPreset } + override suspend fun recordRoomChannelNotified(roomId: RoomId) { + roomChannelLastNotified[roomHash(roomId)] = System.currentTimeMillis() + } + + override suspend fun clearRoomChannelLastNotified(roomId: RoomId) { + roomChannelLastNotified.remove(roomHash(roomId)) + } + + override suspend fun getRoomChannelLastNotifiedByHash(): Map = roomChannelLastNotified.toMap() + + override suspend fun clearRoomChannelLastNotifiedByHash(roomHash: String) { + roomChannelLastNotified.remove(roomHash) + } + + /** Test-only helper to backdate a room's channel last-notified timestamp. */ + fun givenRoomChannelLastNotified(roomId: RoomId, timestampMs: Long) { + roomChannelLastNotified[roomHash(roomId)] = timestampMs + } + override suspend fun clear() { clearCallCount++ isSendPublicReadReceiptsEnabled.tryEmit(true) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationChannelManager.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationChannelManager.kt new file mode 100644 index 00000000000..f1f29a46635 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationChannelManager.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.notifications + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * Manages a per-room Android `NotificationChannel`, created automatically the first time a room + * produces a noisy notification. It is linked to the app's shared "noisy" channel via + * `setConversationId`, which is what makes the room eligible for Android's Conversations UI + * (grouped shade section, Priority Conversation toggle in system Settings) - without it, a + * room's notification is never listed there no matter how it's otherwise built (shortcut, + * `MessagingStyle`, etc). + * + * A channel is only ever created from a *noisy* notification: some Matrix push rule modes (e.g. + * "mentions only") vary noisy per-event within the same room, and a channel's importance can + * never change after creation, so creating one from a silent event would permanently silence a + * room's future mentions. A silent notification for a room with no channel yet keeps using the + * app's plain shared silent channel exactly as before this existed. + */ +interface RoomNotificationChannelManager { + /** + * Returns the channel id to notify on for [roomId]: its own channel if [noisy] (creating it + * on first use), otherwise the app's shared channel (same as + * [io.element.android.libraries.push.impl.notifications.channels.NotificationChannels.getChannelIdForMessage]). + * A created channel is filed under the "Private chats" or "Rooms" system channel group + * depending on [isDm], so it doesn't fall into Android's generic "Other" bucket for ungrouped + * channels. + */ + suspend fun getChannelIdForRoom(sessionId: SessionId, roomId: RoomId, roomDisplayName: String, isDm: Boolean, noisy: Boolean): String + + /** Deletes [roomId]'s channel (if any) and its persisted last-notified state. Idempotent. */ + suspend fun clearRoomChannel(sessionId: SessionId, roomId: RoomId) + + /** Deletes channels for rooms no longer in [roomIds] (left via another device/client, etc). */ + suspend fun pruneChannelsForSession(sessionId: SessionId, roomIds: Set) + + /** Deletes every per-room channel for [sessionId]. Call on logout. */ + suspend fun clearAllChannelsForSession(sessionId: SessionId) + + /** + * Retires long-inactive, unmodified per-room channels for [sessionId], keeping the total + * bounded, since Android gives apps no automatic garbage collection for channels. + * + * A channel is skipped (never deleted) if either: + * - the user has marked it a Priority Conversation ([android.app.NotificationChannel.isImportantConversation]), or + * - its live importance/sound/vibration/lights no longer match what this manager would create, + * meaning the user (or something else) changed it directly via system Settings. + * + * Among the remaining, unprotected candidates, a channel is deleted if it hasn't notified in + * over 30 days, or - if the session still has more than 50 remaining after that - the oldest + * ones are removed down to that limit. + */ + suspend fun pruneInactiveChannels(sessionId: SessionId) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationChannelManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationChannelManager.kt new file mode 100644 index 00000000000..1975520483e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationChannelManager.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.channels + +import android.app.NotificationChannel +import android.media.AudioAttributes +import android.media.AudioAttributes.USAGE_NOTIFICATION +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationManagerCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.appconfig.NotificationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.libraries.androidutils.hash.hash +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.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory +import io.element.android.libraries.push.api.notifications.RoomNotificationChannelManager +import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first + +private const val ROOM_NOTIFICATION_CHANNEL_ID_BASE = "ROOM_NOTIFICATION_CHANNEL" + +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) +private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultRoomNotificationChannelManager( + private val notificationManager: NotificationManagerCompat, + private val notificationChannels: NotificationChannels, + private val enterpriseService: EnterpriseService, + private val lockScreenService: LockScreenService, + private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : RoomNotificationChannelManager { + override suspend fun getChannelIdForRoom(sessionId: SessionId, roomId: RoomId, roomDisplayName: String, isDm: Boolean, noisy: Boolean): String { + // An MDM-managed channel always wins over a per-room channel, same as + // NotificationChannels.getChannelIdForMessage: this only ever applies to the noisy path, + // the silent channel is never enterprise-overridden. + if (noisy && enterpriseService.getNoisyNotificationChannelId(sessionId) != null) { + return notificationChannels.getChannelIdForMessage(sessionId, noisy) + } + if (!noisy) { + // See the class doc: only ever promote a room to its own channel from a genuinely + // noisy notification, so a room whose push rules only bing on mentions doesn't get + // permanently stuck at whatever importance its first (possibly silent) event implied. + return notificationChannels.getChannelIdForMessage(sessionId, noisy) + } + if (lockScreenService.isPinSetup().first()) { + // Per-room channels expose the room name and avatar in Android Settings. When app PIN + // privacy is active, keep using the shared noisy channel just like shortcuts do. + return notificationChannels.getChannelIdForMessage(sessionId, noisy) + } + return ensureRoomChannel(sessionId, roomId, roomDisplayName, isDm) + } + + override suspend fun clearRoomChannel(sessionId: SessionId, roomId: RoomId) { + if (!supportNotificationChannels()) return + notificationManager.deleteNotificationChannel(roomChannelId(sessionId, roomId)) + sessionStore(sessionId).clearRoomChannelLastNotified(roomId) + } + + override suspend fun pruneChannelsForSession(sessionId: SessionId, roomIds: Set) { + if (!supportNotificationChannels()) return + val prefix = roomChannelSessionPrefix(sessionId) + val validRoomHashes = roomIds.mapTo(mutableSetOf()) { it.value.hash().take(ROOM_HASH_LENGTH) } + val store = sessionStore(sessionId) + for (channel in notificationManager.notificationChannels) { + val id = channel.id + if (!id.startsWith(prefix)) continue + val roomHash = id.removePrefix(prefix) + if (roomHash !in validRoomHashes) { + notificationManager.deleteNotificationChannel(id) + store.clearRoomChannelLastNotifiedByHash(roomHash) + } + } + store.getRoomChannelLastNotifiedByHash().keys + .filter { it !in validRoomHashes } + .forEach { store.clearRoomChannelLastNotifiedByHash(it) } + } + + override suspend fun clearAllChannelsForSession(sessionId: SessionId) { + if (!supportNotificationChannels()) return + val prefix = roomChannelSessionPrefix(sessionId) + for (channel in notificationManager.notificationChannels) { + if (channel.id.startsWith(prefix)) { + notificationManager.deleteNotificationChannel(channel.id) + sessionStore(sessionId).clearRoomChannelLastNotifiedByHash(channel.id.removePrefix(prefix)) + } + } + val store = sessionStore(sessionId) + store.getRoomChannelLastNotifiedByHash().keys.forEach { roomHash -> + store.clearRoomChannelLastNotifiedByHash(roomHash) + } + } + + override suspend fun pruneInactiveChannels(sessionId: SessionId) { + if (!supportNotificationChannels()) return + val prefix = roomChannelSessionPrefix(sessionId) + val lastNotifiedByHash = sessionStore(sessionId).getRoomChannelLastNotifiedByHash() + val now = System.currentTimeMillis() + + val candidates = notificationManager.notificationChannels.mapNotNull { channel -> + val id = channel.id + if (!id.startsWith(prefix)) return@mapNotNull null + if (isProtectedChannel(channel)) return@mapNotNull null + val roomHash = id.removePrefix(prefix) + ChannelCandidate(channelId = id, roomHash = roomHash, lastNotifiedAt = lastNotifiedByHash[roomHash] ?: 0L) + } + + val staleByRetention = candidates.filter { now - it.lastNotifiedAt > CHANNEL_RETENTION_MILLIS } + val remaining = candidates - staleByRetention.toSet() + val overBudgetCount = (remaining.size - MAX_CHANNELS).coerceAtLeast(0) + val oldestOverBudget = remaining.sortedBy { it.lastNotifiedAt }.take(overBudgetCount) + + for (candidate in staleByRetention + oldestOverBudget) { + notificationManager.deleteNotificationChannel(candidate.channelId) + sessionStore(sessionId).clearRoomChannelLastNotifiedByHash(candidate.roomHash) + } + } + + /** + * True if [channel]'s live settings no longer match what [ensureRoomChannel] would create, or + * the user marked it a Priority Conversation - either way, something other than this + * manager's own creation logic touched it, so [pruneInactiveChannels] must leave it alone. + */ + private fun isProtectedChannel(channel: NotificationChannel): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && channel.isImportantConversation) return true + val parentChannel = channel.parentChannelId?.let(notificationManager::getNotificationChannel) + val expectedSound = if (parentChannel == null) Settings.System.DEFAULT_NOTIFICATION_URI else parentChannel.sound + return channel.importance != NotificationManagerCompat.IMPORTANCE_DEFAULT || + channel.sound != expectedSound || + !channel.shouldVibrate() || + !channel.shouldShowLights() || + channel.lightColor != NotificationConfig.NOTIFICATION_ACCENT_COLOR + } + + private data class ChannelCandidate(val channelId: String, val roomHash: String, val lastNotifiedAt: Long) + + private fun sessionStore(sessionId: SessionId): SessionPreferencesStore = + sessionPreferencesStoreFactory.get(sessionId, appCoroutineScope) + + /** + * Creates the room's channel if it doesn't already exist, records that it was just used (for + * retention pruning), and returns its id. + */ + private suspend fun ensureRoomChannel( + sessionId: SessionId, + roomId: RoomId, + roomDisplayName: String, + isDm: Boolean, + ): String { + if (!supportNotificationChannels()) return notificationChannels.getChannelIdForMessage(sessionId, noisy = true) + val id = roomChannelId(sessionId, roomId) + if (notificationManager.getNotificationChannel(id) == null) { + notificationManager.createNotificationChannel(buildRoomChannel(id, sessionId, roomId, roomDisplayName, isDm)) + } + sessionStore(sessionId).recordRoomChannelNotified(roomId) + return id + } + + /** + * Matches the app's shared noisy channel's defaults (default importance, system default + * sound, vibration and lights on), since a channel is only ever created from a notification + * that was already noisy - see the class doc on [RoomNotificationChannelManager]. + */ + private fun buildRoomChannel( + id: String, + sessionId: SessionId, + roomId: RoomId, + roomDisplayName: String, + isDm: Boolean, + ): NotificationChannelCompat { + val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR + val parentChannelId = notificationChannels.getChannelIdForMessage(sessionId, noisy = true) + val parentChannel = notificationManager.getNotificationChannel(parentChannelId) + val sound = if (parentChannel == null) Settings.System.DEFAULT_NOTIFICATION_URI else parentChannel.sound + val audioAttributes = parentChannel?.audioAttributes ?: notificationAudioAttributes() + return NotificationChannelCompat.Builder(id, NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName(roomDisplayName) + .setVibrationEnabled(true) + .setLightsEnabled(true) + .setLightColor(accentColor) + .setGroup(if (isDm) PRIVATE_CHATS_CHANNEL_GROUP_ID else ROOMS_CHANNEL_GROUP_ID) + // Lets Android group this under "Conversations" in system settings and surface the + // Priority toggle scoped to this one room, rather than the whole shared channel. The + // conversationId must match the shortcut's id exactly, since that's how system + // Settings resolves the shortcut (and its icon) for this conversation. + .setConversationId(parentChannelId, createShortcutId(sessionId, roomId)) + .apply { setRoomChannelSound(sound, audioAttributes) } + .build() + } + + private fun NotificationChannelCompat.Builder.setRoomChannelSound(sound: Uri?, audioAttributes: AudioAttributes): NotificationChannelCompat.Builder { + return if (sound == null) { + setSound(null, null) + } else { + setSound(sound, audioAttributes) + } + } + + private fun notificationAudioAttributes(): AudioAttributes { + return AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(USAGE_NOTIFICATION) + .build() + } + + companion object { + private const val ROOM_HASH_LENGTH = 16 + + /** Retention window for a channel that hasn't notified: 30 days. */ + private const val CHANNEL_RETENTION_MILLIS = 30L * 24 * 60 * 60 * 1000 + + /** Soft cap on per-room channels per session; the oldest-notified ones are trimmed above this. */ + private const val MAX_CHANNELS = 50 + + private fun roomChannelSessionPrefix(sessionId: SessionId): String = + "${ROOM_NOTIFICATION_CHANNEL_ID_BASE}_${sessionId.value.hash().take(ROOM_HASH_LENGTH)}_" + + private fun roomChannelId(sessionId: SessionId, roomId: RoomId): String = + "${roomChannelSessionPrefix(sessionId)}${roomId.value.hash().take(ROOM_HASH_LENGTH)}" + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt index 8d9b7766941..173c0cb89c4 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt @@ -19,6 +19,7 @@ import android.os.Build import android.provider.Settings import androidx.annotation.ChecksSdkIntAtLeast import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationChannelGroupCompat import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri import dev.zacsweers.metro.AppScope @@ -48,6 +49,12 @@ internal const val NOISY_NOTIFICATION_CHANNEL_ID_BASE = "DEFAULT_NOISY_NOTIFICAT internal const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V3" internal const val RINGING_CALL_NOTIFICATION_CHANNEL_ID_BASE = "RINGING_CALL_NOTIFICATION_CHANNEL_ID" +/* ========================================================================================== + * IDs for channel groups + * ========================================================================================== */ +internal const val PRIVATE_CHATS_CHANNEL_GROUP_ID = "private_chats" +internal const val ROOMS_CHANNEL_GROUP_ID = "rooms" + private fun versionedChannelId(base: String, version: Int): String = if (version <= 0) base else "${base}_v$version" @@ -109,6 +116,29 @@ class DefaultNotificationChannels( createNotificationChannels() } + /** + * Creates the 2 channel groups Settings organizes per-room conversation channels under: + * "Private chats" (DM rooms) and "Rooms" (everything else Matrix-side). Deliberately not + * called "Conversations", to avoid confusion with Android's own Conversation / Priority + * Conversation feature. The shared silent/noisy/call channels are left ungrouped, same as + * every other non-conversation channel in the app: Android already buckets ungrouped + * channels under its own generic "Other" heading, and they serve both DM and non-DM rooms so + * they can't be filed under either group. Idempotent: creating a group that already exists is + * a no-op. + */ + private fun ensureChannelGroups() { + notificationManager.createNotificationChannelGroupsCompat( + listOf( + NotificationChannelGroupCompat.Builder(PRIVATE_CHATS_CHANNEL_GROUP_ID) + .setName(stringProvider.getString(R.string.notification_channel_group_private_chats).ifEmpty { "Private chats" }) + .build(), + NotificationChannelGroupCompat.Builder(ROOMS_CHANNEL_GROUP_ID) + .setName(stringProvider.getString(R.string.notification_channel_group_rooms).ifEmpty { "Rooms" }) + .build(), + ) + ) + } + /** * Create notification channels. */ @@ -117,6 +147,8 @@ class DefaultNotificationChannels( return } + ensureChannelGroups() + val accentColor = NotificationConfig.NOTIFICATION_ACCENT_COLOR // Single-snapshot read; keep this path minimal — extra ContentResolver lookups here would diff --git a/libraries/push/impl/src/main/res/values/temporary.xml b/libraries/push/impl/src/main/res/values/temporary.xml new file mode 100644 index 00000000000..df5569bc3a1 --- /dev/null +++ b/libraries/push/impl/src/main/res/values/temporary.xml @@ -0,0 +1,10 @@ + + + Private chats + Rooms + diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationChannelManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationChannelManagerTest.kt new file mode 100644 index 00000000000..c927a0505e1 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationChannelManagerTest.kt @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.channels + +import android.app.NotificationManager +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationManagerCompat +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.lockscreen.test.FakeLockScreenService +import io.element.android.libraries.androidutils.hash.hash +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) +class DefaultRoomNotificationChannelManagerTest : RobolectricTest() { + private val sessionId = SessionId("@alice:example.org") + private val roomA = RoomId("!roomA:example.org") + private val roomB = RoomId("!roomB:example.org") + private val notificationManager = NotificationManagerCompat.from(RuntimeEnvironment.getApplication()) + private val store = InMemorySessionPreferencesStore() + + private fun createManager( + enterpriseService: FakeEnterpriseService = FakeEnterpriseService(getNoisyNotificationChannelIdResult = { null }), + lockScreenService: FakeLockScreenService = FakeLockScreenService(), + notificationChannels: FakeNotificationChannels = FakeNotificationChannels( + channelIdForMessage = { _, noisy -> if (noisy) "SHARED_NOISY" else "SHARED_SILENT" }, + ), + ) = DefaultRoomNotificationChannelManager( + notificationManager = notificationManager, + notificationChannels = notificationChannels, + enterpriseService = enterpriseService, + lockScreenService = lockScreenService, + sessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory(getLambda = lambdaRecorder { _, _ -> store }), + appCoroutineScope = MainScope(), + ) + + private fun channelIdFor(roomId: RoomId): String = + "ROOM_NOTIFICATION_CHANNEL_${sessionId.value.hash().take(16)}_${roomId.value.hash().take(16)}" + + @Test + fun `a silent notification for a room with no channel falls back to the shared silent channel`() = runTest { + val manager = createManager() + + val channelId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = false) + + assertThat(channelId).isEqualTo("SHARED_SILENT") + } + + @Test + fun `a noisy notification for a room with no channel creates one`() = runTest { + val manager = createManager() + + val channelId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + + assertThat(channelId).isNotEqualTo("SHARED_NOISY") + val channel = notificationManager.getNotificationChannel(channelId) + assertThat(channel).isNotNull() + assertThat(channel!!.name).isEqualTo("Room A") + assertThat(channel.importance).isEqualTo(NotificationManager.IMPORTANCE_DEFAULT) + assertThat(channel.conversationId).isEqualTo(createShortcutId(sessionId, roomA)) + assertThat(channel.parentChannelId).isEqualTo("SHARED_NOISY") + } + + @Test + fun `a noisy notification falls back to the shared noisy channel while PIN lock is enabled`() = runTest { + val lockScreenService = FakeLockScreenService().apply { setIsPinSetup(true) } + val manager = createManager(lockScreenService = lockScreenService) + + val channelId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + + assertThat(channelId).isEqualTo("SHARED_NOISY") + assertThat(notificationManager.notificationChannels.map { it.id }).doesNotContain(channelIdFor(roomA)) + } + + @Test + fun `a room channel copies the current shared noisy channel sound`() = runTest { + val parentChannelId = "SHARED_NOISY_CUSTOM" + val parentSound = Uri.parse("content://example.test/custom-message-sound") + notificationManager.createNotificationChannel( + NotificationChannelCompat.Builder(parentChannelId, NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName("Noisy") + .setSound(parentSound, null) + .build() + ) + val room = RoomId("!roomWithCustomSound:example.org") + val manager = createManager( + notificationChannels = FakeNotificationChannels( + channelIdForMessage = { _, noisy -> if (noisy) parentChannelId else "SHARED_SILENT" }, + ) + ) + + val channelId = manager.getChannelIdForRoom(sessionId, room, "Room with custom sound", isDm = false, noisy = true) + + assertThat(notificationManager.getNotificationChannel(channelId)!!.sound).isEqualTo(parentSound) + } + + @Test + fun `a room channel preserves a silent shared noisy channel`() = runTest { + val parentChannelId = "SHARED_NOISY_SILENT" + notificationManager.createNotificationChannel( + NotificationChannelCompat.Builder(parentChannelId, NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName("Noisy") + .setSound(null, null) + .build() + ) + val room = RoomId("!roomWithSilentSound:example.org") + val manager = createManager( + notificationChannels = FakeNotificationChannels( + channelIdForMessage = { _, noisy -> if (noisy) parentChannelId else "SHARED_SILENT" }, + ) + ) + + val channelId = manager.getChannelIdForRoom(sessionId, room, "Room with silent sound", isDm = false, noisy = true) + + assertThat(notificationManager.getNotificationChannel(channelId)!!.sound).isNull() + } + + @Test + fun `a DM room's channel is filed under the Private chats group`() = runTest { + val manager = createManager() + + val channelId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = true, noisy = true) + + assertThat(notificationManager.getNotificationChannel(channelId)!!.group).isEqualTo(PRIVATE_CHATS_CHANNEL_GROUP_ID) + } + + @Test + fun `a non-DM room's channel is filed under the Rooms group`() = runTest { + val manager = createManager() + + val channelId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + + assertThat(notificationManager.getNotificationChannel(channelId)!!.group).isEqualTo(ROOMS_CHANNEL_GROUP_ID) + } + + @Test + fun `a channel is only created once, not on every notification`() = runTest { + val manager = createManager() + + val firstId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + val secondId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + + assertThat(secondId).isEqualTo(firstId) + } + + @Test + fun `a silent notification for a room with an existing channel still uses the shared silent channel`() = runTest { + // Regression test: some push rule modes only bing on specific events (e.g. mentions) within + // an otherwise-quiet room. A room's own channel must not swallow that distinction. + val manager = createManager() + manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + + val channelId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = false) + + assertThat(channelId).isEqualTo("SHARED_SILENT") + } + + @Test + fun `enterprise override always wins over a per-room channel`() = runTest { + // The manager delegates to notificationChannels.getChannelIdForMessage() once it has + // already checked the enterprise override itself, so the fake must apply that same + // override to mirror what the real NotificationChannels.getChannelIdForMessage does. + val enterpriseService = FakeEnterpriseService(getNoisyNotificationChannelIdResult = { "MDM_CHANNEL" }) + val manager = createManager( + enterpriseService = enterpriseService, + notificationChannels = FakeNotificationChannels( + channelIdForMessage = { sid, noisy -> if (noisy) enterpriseService.getNoisyNotificationChannelId(sid) ?: "SHARED_NOISY" else "SHARED_SILENT" }, + ), + ) + + val channelId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + + assertThat(channelId).isEqualTo("MDM_CHANNEL") + } + + @Test + fun `clearRoomChannel deletes the channel and the persisted last-notified state`() = runTest { + val manager = createManager() + val channelId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + assertThat(notificationManager.getNotificationChannel(channelId)).isNotNull() + + manager.clearRoomChannel(sessionId, roomA) + + assertThat(notificationManager.getNotificationChannel(channelId)).isNull() + assertThat(store.getRoomChannelLastNotifiedByHash()).isEmpty() + } + + @Test + fun `pruneChannelsForSession deletes channels for rooms no longer present`() = runTest { + val manager = createManager() + val idA = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + val idB = manager.getChannelIdForRoom(sessionId, roomB, "Room B", isDm = false, noisy = true) + + manager.pruneChannelsForSession(sessionId, roomIds = setOf(roomB)) + + assertThat(notificationManager.getNotificationChannel(idA)).isNull() + assertThat(notificationManager.getNotificationChannel(idB)).isNotNull() + assertThat(store.getRoomChannelLastNotifiedByHash()).containsKey(idB.substringAfterLast("_")) + assertThat(store.getRoomChannelLastNotifiedByHash()).doesNotContainKey(idA.substringAfterLast("_")) + } + + @Test + fun `clearAllChannelsForSession deletes every room channel for that session`() = runTest { + val manager = createManager() + val idA = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + val idB = manager.getChannelIdForRoom(sessionId, roomB, "Room B", isDm = false, noisy = true) + + manager.clearAllChannelsForSession(sessionId) + + assertThat(notificationManager.getNotificationChannel(idA)).isNull() + assertThat(notificationManager.getNotificationChannel(idB)).isNull() + assertThat(store.getRoomChannelLastNotifiedByHash()).isEmpty() + } + + @Test + fun `pruneInactiveChannels leaves a channel alone if its live settings were changed`() = runTest { + val manager = createManager() + val channelId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + // Simulate the user changing importance for this specific channel via system Settings. + notificationManager.getNotificationChannel(channelId)!!.importance = NotificationManager.IMPORTANCE_LOW + store.givenRoomChannelLastNotified(roomA, System.currentTimeMillis() - THIRTY_ONE_DAYS_MILLIS) + + manager.pruneInactiveChannels(sessionId) + + assertThat(notificationManager.getNotificationChannel(channelId)).isNotNull() + } + + @Test + fun `pruneInactiveChannels deletes an unmodified channel past the retention window`() = runTest { + val manager = createManager() + val channelId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + store.givenRoomChannelLastNotified(roomA, System.currentTimeMillis() - THIRTY_ONE_DAYS_MILLIS) + + manager.pruneInactiveChannels(sessionId) + + assertThat(notificationManager.getNotificationChannel(channelId)).isNull() + } + + @Test + fun `pruneInactiveChannels keeps a recently-notified channel`() = runTest { + val manager = createManager() + val channelId = manager.getChannelIdForRoom(sessionId, roomA, "Room A", isDm = false, noisy = true) + + manager.pruneInactiveChannels(sessionId) + + assertThat(notificationManager.getNotificationChannel(channelId)).isNotNull() + } + + @Test + fun `pruneInactiveChannels trims the oldest channels once over the count limit`() = runTest { + val manager = createManager() + val now = System.currentTimeMillis() + val roomIds = (0 until MAX_CHANNELS + 2).map { RoomId("!room$it:example.org") } + val channelIds = roomIds.mapIndexed { index, roomId -> + val id = manager.getChannelIdForRoom(sessionId, roomId, "Room $index", isDm = false, noisy = true) + // All well within the retention window, but with a clear oldest-first order to trim. + store.givenRoomChannelLastNotified(roomId, now - (roomIds.size - index)) + id + } + + manager.pruneInactiveChannels(sessionId) + + val remaining = channelIds.count { notificationManager.getNotificationChannel(it) != null } + assertThat(remaining).isEqualTo(MAX_CHANNELS) + // The two oldest-notified rooms (index 0 and 1) should be the ones trimmed. + assertThat(notificationManager.getNotificationChannel(channelIds[0])).isNull() + assertThat(notificationManager.getNotificationChannel(channelIds[1])).isNull() + assertThat(notificationManager.getNotificationChannel(channelIds.last())).isNotNull() + } + + private companion object { + const val THIRTY_ONE_DAYS_MILLIS = 31L * 24 * 60 * 60 * 1000 + const val MAX_CHANNELS = 50 + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannelsTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannelsTest.kt index 1df87232f5a..8d6fc148f1b 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannelsTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannelsTest.kt @@ -11,6 +11,7 @@ package io.element.android.libraries.push.impl.notifications.channels import android.os.Build import android.provider.Settings import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationChannelGroupCompat import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri import com.google.common.truth.Truth.assertThat @@ -45,6 +46,23 @@ class NotificationChannelsTest : RobolectricTest() { verify { notificationManager.deleteNotificationChannel(any()) } } + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `init - creates the Private chats and Rooms notification channel groups`() { + val captured = slot>() + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + every { createNotificationChannelGroupsCompat(capture(captured)) } returns Unit + } + + createNotificationChannels(notificationManager = notificationManager) + + assertThat(captured.captured.map { it.id }).containsExactly( + PRIVATE_CHATS_CHANNEL_GROUP_ID, + ROOMS_CHANNEL_GROUP_ID, + ) + } + @Test fun `getChannelForIncomingCall - returns the right channel`() { val notificationChannels = createNotificationChannels() diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/channels/FakeRoomNotificationChannelManager.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/channels/FakeRoomNotificationChannelManager.kt new file mode 100644 index 00000000000..e68937ffd49 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/channels/FakeRoomNotificationChannelManager.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test.notifications.channels + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.notifications.RoomNotificationChannelManager +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeRoomNotificationChannelManager( + private val getChannelIdForRoomLambda: (SessionId, RoomId, String, Boolean, Boolean) -> String = { _, _, _, _, _ -> lambdaError() }, + private val clearRoomChannelLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() }, + private val pruneChannelsForSessionLambda: (SessionId, Set) -> Unit = { _, _ -> lambdaError() }, + private val clearAllChannelsForSessionLambda: (SessionId) -> Unit = { lambdaError() }, + private val pruneInactiveChannelsLambda: (SessionId) -> Unit = { lambdaError() }, +) : RoomNotificationChannelManager { + override suspend fun getChannelIdForRoom(sessionId: SessionId, roomId: RoomId, roomDisplayName: String, isDm: Boolean, noisy: Boolean): String = + getChannelIdForRoomLambda(sessionId, roomId, roomDisplayName, isDm, noisy) + + override suspend fun clearRoomChannel(sessionId: SessionId, roomId: RoomId) { + clearRoomChannelLambda(sessionId, roomId) + } + + override suspend fun pruneChannelsForSession(sessionId: SessionId, roomIds: Set) { + pruneChannelsForSessionLambda(sessionId, roomIds) + } + + override suspend fun clearAllChannelsForSession(sessionId: SessionId) { + clearAllChannelsForSessionLambda(sessionId) + } + + override suspend fun pruneInactiveChannels(sessionId: SessionId) { + pruneInactiveChannelsLambda(sessionId) + } +} From 70f42d3d4bbf835e54306f8b8ea10dde73028d4f Mon Sep 17 00:00:00 2001 From: Nick Hainke Date: Fri, 3 Jul 2026 00:02:44 +0200 Subject: [PATCH 2/6] Use room channels for notifications Route notification creation through RoomNotificationChannelManager. Reuse the existing conversation lifecycle hooks to delete room channels when rooms or sessions disappear, and to clear them when PIN privacy is enabled. --- .../DefaultNotificationConversationService.kt | 43 ++++++++- .../factories/NotificationCreator.kt | 7 +- ...aultNotificationConversationServiceTest.kt | 87 +++++++++++++++++++ .../DefaultNotificationCreatorTest.kt | 51 +++++++++++ 4 files changed, 186 insertions(+), 2 deletions(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt index f898e4a9ed7..06a12945761 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt @@ -29,10 +29,12 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader +import io.element.android.libraries.push.api.notifications.RoomNotificationChannelManager import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.push.impl.intent.IntentProvider import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId import io.element.android.libraries.push.impl.notifications.shortcut.filterBySession +import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.ui.strings.CommonStrings @@ -51,6 +53,8 @@ class DefaultNotificationConversationService( private val matrixClientProvider: MatrixClientProvider, private val imageLoaderHolder: ImageLoaderHolder, private val lockScreenService: LockScreenService, + private val roomNotificationChannelManager: RoomNotificationChannelManager, + private val sessionStore: SessionStore, sessionObserver: SessionObserver, @AppCoroutineScope private val coroutineScope: CoroutineScope, ) : NotificationConversationService { @@ -67,7 +71,11 @@ class DefaultNotificationConversationService( .withPreviousValue() .onEach { (hadPinCode, hasPinCode) -> if (hadPinCode == false && hasPinCode) { + // Shortcuts and per-room channels both surface a room's display name/icon in + // system UI (launcher, Settings) regardless of in-app lock state, so both must + // be wiped the moment the user opts into hiding that - not just shortcuts. clearShortcuts() + clearAllRoomChannelsForAllSessions() } } .launchIn(coroutineScope) @@ -139,6 +147,11 @@ class DefaultNotificationConversationService( }.onFailure { Timber.e(it, "Failed to remove shortcut for room $roomId in session $sessionId") } + runCatchingExceptions { + roomNotificationChannelManager.clearRoomChannel(sessionId, roomId) + }.onFailure { + Timber.e(it, "Failed to clear notification channel for room $roomId in session $sessionId") + } } override suspend fun onAvailableRoomsChanged(sessionId: SessionId, roomIds: Set) { @@ -167,6 +180,16 @@ class DefaultNotificationConversationService( }.onFailure { Timber.e(it, "Failed to remove shortcuts for session $sessionId") } + runCatchingExceptions { + roomNotificationChannelManager.pruneChannelsForSession(sessionId, roomIds) + }.onFailure { + Timber.e(it, "Failed to prune notification channels for session $sessionId") + } + runCatchingExceptions { + roomNotificationChannelManager.pruneInactiveChannels(sessionId) + }.onFailure { + Timber.e(it, "Failed to prune inactive notification channels for session $sessionId") + } } private fun clearShortcuts() { @@ -177,7 +200,20 @@ class DefaultNotificationConversationService( } } - private fun onSessionLogOut(sessionId: SessionId) { + private suspend fun clearAllRoomChannelsForAllSessions() { + runCatchingExceptions { sessionStore.getAllSessions() } + .onFailure { Timber.e(it, "Failed to list sessions to clear notification channels after enabling PIN lock") } + .getOrElse { emptyList() } + .forEach { sessionData -> + runCatchingExceptions { + roomNotificationChannelManager.clearAllChannelsForSession(SessionId(sessionData.userId)) + }.onFailure { + Timber.e(it, "Failed to clear notification channels for session ${sessionData.userId} after enabling PIN lock") + } + } + } + + private suspend fun onSessionLogOut(sessionId: SessionId) { runCatchingExceptions { val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) val shortcutIdsToRemove = shortcuts.filterBySession(sessionId).map { it.id } @@ -193,5 +229,10 @@ class DefaultNotificationConversationService( }.onFailure { Timber.e(it, "Failed to remove shortcuts for session $sessionId after logout") } + runCatchingExceptions { + roomNotificationChannelManager.clearAllChannelsForSession(sessionId) + }.onFailure { + Timber.e(it, "Failed to clear notification channels for session $sessionId after logout") + } } } 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 44a9b5ca30a..b74552630ae 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 @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getBestName import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader +import io.element.android.libraries.push.api.notifications.RoomNotificationChannelManager import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels @@ -114,6 +115,7 @@ interface NotificationCreator { class DefaultNotificationCreator( @ApplicationContext private val context: Context, private val notificationChannels: NotificationChannels, + private val roomNotificationChannelManager: RoomNotificationChannelManager, private val stringProvider: StringProvider, private val buildMeta: BuildMeta, private val pendingIntentFactory: PendingIntentFactory, @@ -152,8 +154,11 @@ class DefaultNotificationCreator( val channelId = if (containsMissedCall) { notificationChannels.getChannelForIncomingCall(false) } else { - notificationChannels.getChannelIdForMessage( + roomNotificationChannelManager.getChannelIdForRoom( sessionId = roomInfo.sessionId, + roomId = roomInfo.roomId, + roomDisplayName = roomInfo.roomDisplayName, + isDm = roomInfo.isDm, noisy = roomInfo.shouldBing, ) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt index 41e601c50da..6d6d2245aae 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt @@ -24,7 +24,10 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder import io.element.android.libraries.push.impl.notifications.factories.FakeIntentProvider import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId +import io.element.android.libraries.push.test.notifications.channels.FakeRoomNotificationChannelManager import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -141,6 +144,34 @@ class DefaultNotificationConversationServiceTest : RobolectricTest() { assertThat(shortcuts).isEmpty() } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `on pin code enabled, notification channels are cleared for every session`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val lockScreenService = FakeLockScreenService() + val clearedSessions = mutableListOf() + val roomNotificationChannelManager = FakeRoomNotificationChannelManager( + clearAllChannelsForSessionLambda = { sessionId -> clearedSessions.add(sessionId) }, + ) + val sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value), aSessionData(sessionId = A_SESSION_ID_2.value)), + ) + createService( + context, + lockScreenService = lockScreenService, + roomNotificationChannelManager = roomNotificationChannelManager, + sessionStore = sessionStore, + ) + + lockScreenService.setIsPinSetup(false) + runCurrent() + + lockScreenService.setIsPinSetup(true) + runCurrent() + + assertThat(clearedSessions).containsExactly(A_SESSION_ID, A_SESSION_ID_2) + } + @Test fun `on session logged out, all shortcuts for the session are cleared`() = runTest { val context = InstrumentationRegistry.getInstrumentation().context @@ -172,10 +203,64 @@ class DefaultNotificationConversationServiceTest : RobolectricTest() { assertThat(shortcuts.first().id).startsWith(A_SESSION_ID_2.value) } + @Test + fun `onLeftRoom clears the room's notification channel`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + var clearedRoom: Pair? = null + val roomNotificationChannelManager = FakeRoomNotificationChannelManager( + clearRoomChannelLambda = { sessionId, roomId -> clearedRoom = sessionId to roomId }, + ) + val service = createService(context, roomNotificationChannelManager = roomNotificationChannelManager) + + service.onLeftRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID) + + assertThat(clearedRoom).isEqualTo(A_SESSION_ID to A_ROOM_ID) + } + + @Test + fun `onAvailableRoomsChanged prunes and prunes inactive channels on the channel manager`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + var prunedRooms: Pair? = null + var prunedInactiveSession: Any? = null + val roomNotificationChannelManager = FakeRoomNotificationChannelManager( + pruneChannelsForSessionLambda = { sessionId, roomIds -> prunedRooms = sessionId to roomIds }, + pruneInactiveChannelsLambda = { sessionId -> prunedInactiveSession = sessionId }, + ) + val service = createService(context, roomNotificationChannelManager = roomNotificationChannelManager) + + service.onAvailableRoomsChanged(sessionId = A_SESSION_ID, roomIds = setOf(A_ROOM_ID)) + + assertThat(prunedRooms).isEqualTo(A_SESSION_ID to setOf(A_ROOM_ID)) + assertThat(prunedInactiveSession).isEqualTo(A_SESSION_ID) + } + + @Test + fun `on session logged out, the session's notification channels are cleared`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sessionObserver = FakeSessionObserver() + var clearedSession: Any? = null + val roomNotificationChannelManager = FakeRoomNotificationChannelManager( + clearAllChannelsForSessionLambda = { sessionId -> clearedSession = sessionId }, + ) + createService(context, sessionObserver = sessionObserver, roomNotificationChannelManager = roomNotificationChannelManager) + + sessionObserver.onSessionCreated(A_SESSION_ID.value) + sessionObserver.onSessionDeleted(A_SESSION_ID.value) + + assertThat(clearedSession).isEqualTo(A_SESSION_ID) + } + private fun TestScope.createService( context: Context = InstrumentationRegistry.getInstrumentation().context, sessionObserver: FakeSessionObserver = FakeSessionObserver(), lockScreenService: FakeLockScreenService = FakeLockScreenService(), + roomNotificationChannelManager: FakeRoomNotificationChannelManager = FakeRoomNotificationChannelManager( + clearRoomChannelLambda = { _, _ -> }, + pruneChannelsForSessionLambda = { _, _ -> }, + clearAllChannelsForSessionLambda = { }, + pruneInactiveChannelsLambda = { }, + ), + sessionStore: InMemorySessionStore = InMemorySessionStore(), ) = DefaultNotificationConversationService( context = context, intentProvider = FakeIntentProvider(), @@ -184,6 +269,8 @@ class DefaultNotificationConversationServiceTest : RobolectricTest() { imageLoaderHolder = FakeImageLoaderHolder(), sessionObserver = sessionObserver, lockScreenService = lockScreenService, + roomNotificationChannelManager = roomNotificationChannelManager, + sessionStore = sessionStore, coroutineScope = backgroundScope, ) } 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 38b387a1805..f5be23746ad 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 @@ -18,6 +18,7 @@ import io.element.android.appconfig.NotificationConfig import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_COLOR_INT import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -28,6 +29,7 @@ import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader import io.element.android.libraries.matrix.ui.media.test.FakeInitialsAvatarBitmapGenerator import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader +import io.element.android.libraries.push.api.notifications.RoomNotificationChannelManager import io.element.android.libraries.push.impl.notifications.DefaultNotificationBitmapLoader import io.element.android.libraries.push.impl.notifications.NotificationActionIds import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo @@ -41,6 +43,7 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNo import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.libraries.push.test.notifications.channels.FakeRoomNotificationChannelManager 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.A_FAKE_TIMESTAMP @@ -302,6 +305,50 @@ class DefaultNotificationCreatorTest : RobolectricTest() { result.commonAssertions() } + @Test + fun `test createMessagesListNotification asks the room channel manager for the channel id`() = runTest { + var requestedRoomId: RoomId? = null + var requestedRoomDisplayName: String? = null + var requestedIsDm: Boolean? = null + var requestedNoisy: Boolean? = null + val sut = createNotificationCreator( + roomNotificationChannelManager = FakeRoomNotificationChannelManager( + getChannelIdForRoomLambda = { _, roomId, roomDisplayName, isDm, noisy -> + requestedRoomId = roomId + requestedRoomDisplayName = roomDisplayName + requestedIsDm = isDm + requestedNoisy = noisy + "A_ROOM_CHANNEL_ID" + }, + ), + ) + sut.createMessagesListNotification( + notificationAccountParams = aNotificationAccountParams(), + roomInfo = RoomEventGroupInfo( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + roomDisplayName = "roomDisplayName", + isDm = true, + hasSmartReplyError = false, + shouldBing = true, + customSound = null, + isUpdated = false, + ), + threadId = null, + largeIcon = null, + lastMessageTimestamp = 123_456L, + tickerText = "tickerText", + existingNotification = null, + imageLoader = FakeImageLoader(), + events = listOf(aNotifiableMessageEvent()), + ) + + assertThat(requestedRoomId).isEqualTo(A_ROOM_ID) + assertThat(requestedRoomDisplayName).isEqualTo("roomDisplayName") + assertThat(requestedIsDm).isTrue() + assertThat(requestedNoisy).isTrue() + } + private fun Notification.commonAssertions( expectedGroup: String? = aMatrixUser().userId.value, expectedCategory: String? = NotificationCompat.CATEGORY_MESSAGE, @@ -322,6 +369,9 @@ fun createNotificationCreator( buildMeta: BuildMeta = aBuildMeta(), enterpriseService: EnterpriseService = FakeEnterpriseService(), notificationChannels: NotificationChannels = createNotificationChannels(enterpriseService), + roomNotificationChannelManager: RoomNotificationChannelManager = FakeRoomNotificationChannelManager( + getChannelIdForRoomLambda = { sessionId, _, _, _, noisy -> notificationChannels.getChannelIdForMessage(sessionId, noisy) }, + ), bitmapLoader: NotificationBitmapLoader = DefaultNotificationBitmapLoader( context = context, sdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.R), @@ -331,6 +381,7 @@ fun createNotificationCreator( return DefaultNotificationCreator( context = context, notificationChannels = notificationChannels, + roomNotificationChannelManager = roomNotificationChannelManager, stringProvider = FakeStringProvider("test"), buildMeta = buildMeta, pendingIntentFactory = PendingIntentFactory( From 961a20cff83f4853edc07e66442a2c43625e8aca Mon Sep 17 00:00:00 2001 From: Nick Hainke Date: Fri, 3 Jul 2026 00:34:40 +0200 Subject: [PATCH 3/6] Create shortcuts for incoming rooms Refresh a room shortcut when the first incoming non-thread notification is received, not only when the user sends a message. This gives room channels an Android conversation shortcut, icon, and avatar before the user replies. --- .../impl/push/OnNotifiableEventReceived.kt | 27 +++++++ .../push/OnNotifiableEventReceivedTest.kt | 78 +++++++++++++++++++ .../FakeNotificationConversationService.kt | 8 +- 3 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceivedTest.kt diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt index 2c4339d16a4..9d5d28f18a4 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt @@ -11,8 +11,10 @@ package io.element.android.libraries.push.impl.push import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -24,12 +26,37 @@ interface OnNotifiableEventReceived { @ContributesBinding(AppScope::class) class DefaultOnNotifiableEventReceived( private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, + private val notificationConversationService: NotificationConversationService, @AppCoroutineScope private val coroutineScope: CoroutineScope, ) : OnNotifiableEventReceived { override fun onNotifiableEventsReceived(notifiableEvents: List) { coroutineScope.launch { + notificationConversationService.pushShortcutsForIncomingMessages(notifiableEvents) defaultNotificationDrawerManager.onNotifiableEventsReceived(notifiableEvents.filter { it !is NotifiableRingingCallEvent }) } } } + +/** + * Otherwise, [NotificationConversationService.onSendMessage] (despite the name, it just + * creates/refreshes a room's conversation shortcut) is only called from the message composer, so + * a room you've only ever received messages in never gets a shortcut - and without one, its + * per-room notification channel has no icon/avatar to show in system Settings until you reply. + */ +internal suspend fun NotificationConversationService.pushShortcutsForIncomingMessages(notifiableEvents: List) { + notifiableEvents + .asSequence() + .filterIsInstance() + .filter { !it.outGoingMessage && it.threadId == null } + .distinctBy { it.sessionId to it.roomId } + .forEach { event -> + onSendMessage( + sessionId = event.sessionId, + roomId = event.roomId, + roomName = event.roomName ?: event.roomId.value, + roomIsDirect = event.roomIsDm, + roomAvatarUrl = event.roomAvatarPath, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceivedTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceivedTest.kt new file mode 100644 index 00000000000..9c910d0a559 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceivedTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +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.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class OnNotifiableEventReceivedTest { + @Test + fun `pushes a shortcut for a new incoming message`() = runTest { + val onSendMessage = lambdaRecorder { _, _, _, _, _ -> } + val service = FakeNotificationConversationService(onSendMessageLambda = onSendMessage) + + service.pushShortcutsForIncomingMessages(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) + + onSendMessage.assertions().isCalledOnce().with( + value(A_SESSION_ID), + value(A_ROOM_ID), + value(A_ROOM_NAME), + value(false), + value(null), + ) + } + + @Test + fun `skips outgoing messages`() = runTest { + val onSendMessage = lambdaRecorder { _, _, _, _, _ -> } + val service = FakeNotificationConversationService(onSendMessageLambda = onSendMessage) + val outgoing = aNotifiableMessageEvent(roomId = A_ROOM_ID).copy(outGoingMessage = true) + + service.pushShortcutsForIncomingMessages(listOf(outgoing)) + + onSendMessage.assertions().isNeverCalled() + } + + @Test + fun `skips thread messages`() = runTest { + val onSendMessage = lambdaRecorder { _, _, _, _, _ -> } + val service = FakeNotificationConversationService(onSendMessageLambda = onSendMessage) + val threadEvent = aNotifiableMessageEvent(roomId = A_ROOM_ID, threadId = ThreadId("\$a-thread-id")) + + service.pushShortcutsForIncomingMessages(listOf(threadEvent)) + + onSendMessage.assertions().isNeverCalled() + } + + @Test + fun `dedupes multiple events for the same room`() = runTest { + val onSendMessage = lambdaRecorder { _, _, _, _, _ -> } + val service = FakeNotificationConversationService(onSendMessageLambda = onSendMessage) + + service.pushShortcutsForIncomingMessages( + listOf( + aNotifiableMessageEvent(roomId = A_ROOM_ID), + aNotifiableMessageEvent(roomId = A_ROOM_ID), + aNotifiableMessageEvent(roomId = A_ROOM_ID_2), + ) + ) + + onSendMessage.assertions().isCalledExactly(2) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt index a2022ea22d4..05dac93653b 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt @@ -11,15 +11,19 @@ package io.element.android.libraries.push.test.notifications.conversations import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService +import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder -class FakeNotificationConversationService : NotificationConversationService { +class FakeNotificationConversationService( + private val onSendMessageLambda: LambdaFiveParamsRecorder = lambdaRecorder { _, _, _, _, _ -> }, +) : NotificationConversationService { override suspend fun onSendMessage( sessionId: SessionId, roomId: RoomId, roomName: String?, roomIsDirect: Boolean, roomAvatarUrl: String?, - ) = Unit + ) = onSendMessageLambda(sessionId, roomId, roomName, roomIsDirect, roomAvatarUrl) override suspend fun onLeftRoom(sessionId: SessionId, roomId: RoomId) = Unit From 5829297cad029c926cc9b656a25b1c7dff135434 Mon Sep 17 00:00:00 2001 From: Nick Hainke Date: Sun, 5 Jul 2026 00:40:48 +0200 Subject: [PATCH 4/6] Create room channels after sends Split conversation updates into sent and received paths. Sent text and attachment messages refresh the shortcut and create a room channel only when the room notification mode is ALL_MESSAGES. Received messages still only refresh the shortcut; notification creation handles their per-event noisiness. --- .../preview/AttachmentsPreviewPresenter.kt | 22 +++ .../MessageComposerPresenter.kt | 2 +- .../AttachmentsPreviewPresenterTest.kt | 11 ++ .../RoomNotificationChannelManager.kt | 23 +-- .../NotificationConversationService.kt | 25 ++- .../DefaultRoomNotificationChannelManager.kt | 4 +- .../DefaultNotificationConversationService.kt | 102 ++++++++++-- .../impl/push/OnNotifiableEventReceived.kt | 10 +- ...aultNotificationConversationServiceTest.kt | 145 +++++++++++++++++- .../push/OnNotifiableEventReceivedTest.kt | 24 +-- .../FakeNotificationConversationService.kt | 15 +- 11 files changed, 328 insertions(+), 55 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 8ebb580a908..d4197abab10 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -41,6 +41,8 @@ import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.getDirectRoomMember import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider @@ -48,6 +50,7 @@ import io.element.android.libraries.mediaupload.api.MediaSenderFactory import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.allFiles import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import kotlinx.collections.immutable.ImmutableList @@ -75,6 +78,8 @@ class AttachmentsPreviewPresenter( @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val dispatchers: CoroutineDispatchers, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, + private val room: JoinedRoom, + private val notificationConversationService: NotificationConversationService, ) : Presenter { @AssistedFactory interface Factory { @@ -547,6 +552,7 @@ class AttachmentsPreviewPresenter( } }.fold( onSuccess = { + notifyMessageSent() mediaUploadInfos.forEach { cleanUp(it) } sendActionState.value = SendActionState.Done onDoneListener() @@ -560,4 +566,20 @@ class AttachmentsPreviewPresenter( } } ) + + private suspend fun notifyMessageSent() { + runCatchingExceptions { + val roomInfo = room.info() + val roomMembers = room.membersStateFlow.value + notificationConversationService.onMessageSent( + sessionId = room.sessionId, + roomId = roomInfo.id, + roomName = roomInfo.name, + roomIsDirect = roomInfo.isDm, + roomAvatarUrl = roomInfo.avatarUrl ?: roomMembers.getDirectRoomMember(roomInfo = roomInfo, sessionId = room.sessionId)?.avatarUrl, + ) + }.onFailure { + Timber.e(it, "Failed to update notification conversation after sending attachment") + } + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 858e872365e..d9f4288914b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -588,7 +588,7 @@ class MessageComposerPresenter( val roomInfo = room.info() val roomMembers = room.membersStateFlow.value - notificationConversationService.onSendMessage( + notificationConversationService.onMessageSent( sessionId = room.sessionId, roomId = roomInfo.id, roomName = roomInfo.name, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 8370ec516f4..4a9ed54bb1d 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -31,6 +31,8 @@ import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.mimetype.MimeTypes 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.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.GalleryItemInfo @@ -57,6 +59,7 @@ import io.element.android.libraries.mediaviewer.api.anImageMediaInfo import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.consumeItemsUntilPredicate @@ -112,11 +115,15 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { }, ) val onDoneListener = lambdaRecorder { } + val onMessageSent = lambdaRecorder { _, _, _, _, _ -> + } + val notificationConversationService = FakeNotificationConversationService(onMessageSentLambda = onMessageSent) val mediaPreProcessor = FakeMediaPreProcessor() val presenter = createAttachmentsPreviewPresenter( room = room, mediaPreProcessor = mediaPreProcessor, onDoneListener = { onDoneListener() }, + notificationConversationService = notificationConversationService, ) presenter.test { skipItems(1) @@ -130,6 +137,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() onDoneListener.assertions().isCalledOnce() + onMessageSent.assertions().isCalledOnce() assertThat(mediaPreProcessor.cleanUpCallCount).isEqualTo(1) } } @@ -977,6 +985,7 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { } }, videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(), + notificationConversationService: FakeNotificationConversationService = FakeNotificationConversationService(), ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( attachments = attachments.toImmutableList(), @@ -1001,6 +1010,8 @@ class AttachmentsPreviewPresenterTest : RobolectricTest() { timelineMode = timelineMode, inReplyToEventId = null, mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + room = room, + notificationConversationService = notificationConversationService, ) } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationChannelManager.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationChannelManager.kt index f1f29a46635..33c292916a1 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationChannelManager.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationChannelManager.kt @@ -12,17 +12,20 @@ import io.element.android.libraries.matrix.api.core.SessionId /** * Manages a per-room Android `NotificationChannel`, created automatically the first time a room - * produces a noisy notification. It is linked to the app's shared "noisy" channel via - * `setConversationId`, which is what makes the room eligible for Android's Conversations UI - * (grouped shade section, Priority Conversation toggle in system Settings) - without it, a - * room's notification is never listed there no matter how it's otherwise built (shortcut, - * `MessagingStyle`, etc). + * produces a noisy notification - or the first time a message is sent in a room whose + * notification mode is `ALL_MESSAGES`, so a room you've only ever sent messages in (e.g. a + * self-chat, which never notifies its own sender) still gets one. It is linked to the app's + * shared "noisy" channel via `setConversationId`, which is what makes the room eligible for + * Android's Conversations UI (grouped shade section, Priority Conversation toggle in system + * Settings) - without it, a room's notification is never listed there no matter how it's + * otherwise built (shortcut, `MessagingStyle`, etc). * - * A channel is only ever created from a *noisy* notification: some Matrix push rule modes (e.g. - * "mentions only") vary noisy per-event within the same room, and a channel's importance can - * never change after creation, so creating one from a silent event would permanently silence a - * room's future mentions. A silent notification for a room with no channel yet keeps using the - * app's plain shared silent channel exactly as before this existed. + * Either way, a channel is only ever created by passing [noisy][getChannelIdForRoom] `= true`: + * some Matrix push rule modes (e.g. "mentions only") vary noisy per-event within the same room, + * and a channel's importance can never change after creation, so creating one from a silent + * event (or a room whose mode isn't `ALL_MESSAGES`) would permanently silence a room's future + * mentions. A silent notification, or a send in a non-`ALL_MESSAGES` room, keeps using the app's + * plain shared channel exactly as before this existed. */ interface RoomNotificationChannelManager { /** diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt index 4ea80e4ce80..a315f303812 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt @@ -16,10 +16,29 @@ import io.element.android.libraries.matrix.api.core.SessionId */ interface NotificationConversationService { /** - * Called when a new message is received in a room. - * It should create a new conversation shortcut for this room. + * Called when the current user sends a message in [roomId]. Refreshes the room's conversation + * shortcut and, unlike [onMessageReceived], also ensures the room's per-room notification + * channel exists if its notification mode is + * [io.element.android.libraries.matrix.api.room.RoomNotificationMode.ALL_MESSAGES] - see + * [io.element.android.libraries.push.api.notifications.RoomNotificationChannelManager]. This is + * the only way some rooms (e.g. self-chats, which never generate an incoming notification for + * their own sender) ever get a channel. */ - suspend fun onSendMessage( + suspend fun onMessageSent( + sessionId: SessionId, + roomId: RoomId, + roomName: String?, + roomIsDirect: Boolean, + roomAvatarUrl: String?, + ) + + /** + * Called when a new, non-outgoing message is received in [roomId]. Only refreshes the room's + * conversation shortcut - the per-room notification channel, if any, is ensured separately by + * the notification-building pipeline itself, using that specific event's actual noisiness, so + * this must not duplicate that with the room's static notification mode. + */ + suspend fun onMessageReceived( sessionId: SessionId, roomId: RoomId, roomName: String?, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationChannelManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationChannelManager.kt index 1975520483e..3479c82770e 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationChannelManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationChannelManager.kt @@ -176,8 +176,8 @@ class DefaultRoomNotificationChannelManager( /** * Matches the app's shared noisy channel's defaults (default importance, system default - * sound, vibration and lights on), since a channel is only ever created from a notification - * that was already noisy - see the class doc on [RoomNotificationChannelManager]. + * sound, vibration and lights on), since a channel is only ever created when `noisy` is true + * - see the class doc on [RoomNotificationChannelManager]. */ private fun buildRoomChannel( id: String, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt index 06a12945761..a1d5646b25b 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt @@ -24,9 +24,11 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.libraries.push.api.notifications.RoomNotificationChannelManager @@ -43,6 +45,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import timber.log.Timber +import kotlin.jvm.optionals.getOrNull @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) @@ -81,23 +84,51 @@ class DefaultNotificationConversationService( .launchIn(coroutineScope) } - override suspend fun onSendMessage( + override suspend fun onMessageSent( sessionId: SessionId, roomId: RoomId, roomName: String?, roomIsDirect: Boolean, roomAvatarUrl: String?, ) { + val pushed = pushConversationShortcut(sessionId, roomId, roomName, roomIsDirect, roomAvatarUrl) ?: return + ensureRoomNotificationChannel(pushed.client, sessionId, roomId, pushed.roomDisplayName, roomIsDirect) + } + + override suspend fun onMessageReceived( + sessionId: SessionId, + roomId: RoomId, + roomName: String?, + roomIsDirect: Boolean, + roomAvatarUrl: String?, + ) { + // Deliberately does not call ensureRoomNotificationChannel: DefaultNotificationCreator + // already ensures the channel for a genuinely noisy incoming notification, using that + // event's actual noisiness rather than the room's static mode. Doing it again here would + // duplicate that work (extra SDK round-trips on the path to showing the notification) and + // could disagree with it. + pushConversationShortcut(sessionId, roomId, roomName, roomIsDirect, roomAvatarUrl) + } + + private class PushedShortcut(val client: MatrixClient, val roomDisplayName: String) + + private suspend fun pushConversationShortcut( + sessionId: SessionId, + roomId: RoomId, + roomName: String?, + roomIsDirect: Boolean, + roomAvatarUrl: String?, + ): PushedShortcut? { if (lockScreenService.isPinSetup().first()) { // We don't create shortcuts when a pin code is set for privacy reasons - return + return null } val categories = setOfNotNull( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION else null ) - val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null val imageLoader = imageLoaderHolder.get(client) val defaultShortcutIconSize = ShortcutManagerCompat.getIconMaxWidth(context) @@ -131,6 +162,58 @@ class DefaultNotificationConversationService( .onFailure { Timber.e(it, "Failed to create shortcut for room $roomId in session $sessionId") } + + return PushedShortcut(client, name) + } + + /** + * A room's per-room channel is otherwise only created from a genuinely noisy incoming + * notification (see [RoomNotificationChannelManager]), so a room you've only ever sent + * messages in - e.g. a self-chat, which never generates a notification for its own sender - + * would never get one. Mirror that same "genuinely noisy" gate here using the room's actual + * notification mode, rather than assuming every send should promote the room. + */ + private suspend fun ensureRoomNotificationChannel( + client: MatrixClient, + sessionId: SessionId, + roomId: RoomId, + roomDisplayName: String, + isDm: Boolean, + ) { + runCatchingExceptions { + val isEncrypted = client.getRoomInfoFlow(roomId).first().getOrNull()?.isEncrypted + if (isEncrypted == null) { + // The room's (or specifically its encryption) state hasn't synced yet. Guessing + // unencrypted could resolve against the wrong default push rule bucket, so skip + // rather than promote on a guess - a later send or receive in this room will + // retry once it's known. + Timber.d("Skipping notification channel for room $roomId in session $sessionId: encryption state not yet known") + return@runCatchingExceptions + } + val mode = client.notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isDm).getOrNull()?.mode + roomNotificationChannelManager.getChannelIdForRoom( + sessionId = sessionId, + roomId = roomId, + roomDisplayName = roomDisplayName, + isDm = isDm, + noisy = mode == RoomNotificationMode.ALL_MESSAGES, + ) + }.onFailure { + Timber.e(it, "Failed to ensure notification channel for room $roomId in session $sessionId") + } + } + + private suspend fun clearAllRoomChannelsForAllSessions() { + runCatchingExceptions { sessionStore.getAllSessions() } + .onFailure { Timber.e(it, "Failed to list sessions to clear notification channels after enabling PIN lock") } + .getOrElse { emptyList() } + .forEach { sessionData -> + runCatchingExceptions { + roomNotificationChannelManager.clearAllChannelsForSession(SessionId(sessionData.userId)) + }.onFailure { + Timber.e(it, "Failed to clear notification channels for session ${sessionData.userId} after enabling PIN lock") + } + } } override suspend fun onLeftRoom(sessionId: SessionId, roomId: RoomId) { @@ -200,19 +283,6 @@ class DefaultNotificationConversationService( } } - private suspend fun clearAllRoomChannelsForAllSessions() { - runCatchingExceptions { sessionStore.getAllSessions() } - .onFailure { Timber.e(it, "Failed to list sessions to clear notification channels after enabling PIN lock") } - .getOrElse { emptyList() } - .forEach { sessionData -> - runCatchingExceptions { - roomNotificationChannelManager.clearAllChannelsForSession(SessionId(sessionData.userId)) - }.onFailure { - Timber.e(it, "Failed to clear notification channels for session ${sessionData.userId} after enabling PIN lock") - } - } - } - private suspend fun onSessionLogOut(sessionId: SessionId) { runCatchingExceptions { val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt index 9d5d28f18a4..bd349ab8acf 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt @@ -39,10 +39,10 @@ class DefaultOnNotifiableEventReceived( } /** - * Otherwise, [NotificationConversationService.onSendMessage] (despite the name, it just - * creates/refreshes a room's conversation shortcut) is only called from the message composer, so - * a room you've only ever received messages in never gets a shortcut - and without one, its - * per-room notification channel has no icon/avatar to show in system Settings until you reply. + * Otherwise, a room's conversation shortcut would only ever be refreshed from the message + * composer (see [NotificationConversationService.onMessageSent]), so a room you've only ever + * received messages in never gets one - and without one, its per-room notification channel has no + * icon/avatar to show in system Settings until you reply. */ internal suspend fun NotificationConversationService.pushShortcutsForIncomingMessages(notifiableEvents: List) { notifiableEvents @@ -51,7 +51,7 @@ internal suspend fun NotificationConversationService.pushShortcutsForIncomingMes .filter { !it.outGoingMessage && it.threadId == null } .distinctBy { it.sessionId to it.roomId } .forEach { event -> - onSendMessage( + onMessageReceived( sessionId = event.sessionId, roomId = event.roomId, roomName = event.roomName ?: event.roomId.value, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt index 6d6d2245aae..ec3c89085ee 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt @@ -16,11 +16,15 @@ import androidx.core.content.pm.ShortcutManagerCompat import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import io.element.android.features.lockscreen.test.FakeLockScreenService +import io.element.android.libraries.matrix.api.room.RoomNotificationMode 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.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder import io.element.android.libraries.push.impl.notifications.factories.FakeIntentProvider import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId @@ -31,19 +35,21 @@ import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test import org.robolectric.annotation.Config +import java.util.Optional @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) class DefaultNotificationConversationServiceTest : RobolectricTest() { @Test - fun `onSendMessage adds a shortcut`() = runTest { + fun `onMessageSent adds a shortcut`() = runTest { val context = InstrumentationRegistry.getInstrumentation().context val service = createService(context) - service.onSendMessage( + service.onMessageSent( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, roomName = "Room title", @@ -55,6 +61,137 @@ class DefaultNotificationConversationServiceTest : RobolectricTest() { assertThat(shortcuts).isNotEmpty() } + @Test + fun `onMessageReceived adds a shortcut`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val service = createService(context) + + service.onMessageReceived( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + roomName = "Room title", + roomIsDirect = false, + roomAvatarUrl = null, + ) + + val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) + assertThat(shortcuts).isNotEmpty() + } + + @Test + fun `onMessageReceived does not ensure a notification channel`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + // Default FakeRoomNotificationChannelManager() throws if getChannelIdForRoom is ever + // called - proving the channel manager is never asked to do anything on receive. + val service = createService(context, roomNotificationChannelManager = FakeRoomNotificationChannelManager()) + + service.onMessageReceived( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + roomName = "Room title", + roomIsDirect = false, + roomAvatarUrl = null, + ) + + // No exception means getChannelIdForRoom was never invoked; the shortcut is still pushed. + assertThat(ShortcutManagerCompat.getDynamicShortcuts(context)).isNotEmpty() + } + + @Test + fun `onMessageSent ensures a noisy channel when the room's notification mode is ALL_MESSAGES`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + var noisyPassed: Boolean? = null + val roomNotificationChannelManager = FakeRoomNotificationChannelManager( + getChannelIdForRoomLambda = { _, _, _, _, noisy -> + noisyPassed = noisy + "a-channel-id" + }, + ) + val matrixClient = FakeMatrixClient( + notificationSettingsService = FakeNotificationSettingsService( + initialRoomMode = RoomNotificationMode.ALL_MESSAGES, + initialRoomModeIsDefault = false, + ), + ).apply { + getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo(isEncrypted = false))) } + } + val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + val service = createService( + context, + roomNotificationChannelManager = roomNotificationChannelManager, + matrixClientProvider = matrixClientProvider, + ) + + service.onMessageSent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + roomName = "Room title", + roomIsDirect = false, + roomAvatarUrl = null, + ) + + assertThat(noisyPassed).isTrue() + } + + @Test + fun `onMessageSent does not promote a channel when the room's notification mode is not ALL_MESSAGES`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + var noisyPassed: Boolean? = null + val roomNotificationChannelManager = FakeRoomNotificationChannelManager( + getChannelIdForRoomLambda = { _, _, _, _, noisy -> + noisyPassed = noisy + "a-channel-id" + }, + ) + val matrixClient = FakeMatrixClient( + notificationSettingsService = FakeNotificationSettingsService( + initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + initialRoomModeIsDefault = false, + ), + ).apply { + getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo(isEncrypted = false))) } + } + val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + val service = createService( + context, + roomNotificationChannelManager = roomNotificationChannelManager, + matrixClientProvider = matrixClientProvider, + ) + + service.onMessageSent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + roomName = "Room title", + roomIsDirect = false, + roomAvatarUrl = null, + ) + + assertThat(noisyPassed).isFalse() + } + + @Test + fun `onMessageSent skips ensuring a channel when the room's encryption state is not yet known`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + // Default FakeMatrixClient().getRoomInfoFlowLambda returns Optional.empty(), i.e. unknown. + // Default FakeRoomNotificationChannelManager() throws if getChannelIdForRoom is ever called. + val service = createService( + context, + roomNotificationChannelManager = FakeRoomNotificationChannelManager(), + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }), + ) + + service.onMessageSent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + roomName = "Room title", + roomIsDirect = false, + roomAvatarUrl = null, + ) + + // No exception means getChannelIdForRoom was never invoked; the shortcut is still pushed. + assertThat(ShortcutManagerCompat.getDynamicShortcuts(context)).isNotEmpty() + } + @Test fun `onLeftRoom removes a shortcut`() = runTest { val context = InstrumentationRegistry.getInstrumentation().context @@ -254,7 +391,9 @@ class DefaultNotificationConversationServiceTest : RobolectricTest() { context: Context = InstrumentationRegistry.getInstrumentation().context, sessionObserver: FakeSessionObserver = FakeSessionObserver(), lockScreenService: FakeLockScreenService = FakeLockScreenService(), + matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), roomNotificationChannelManager: FakeRoomNotificationChannelManager = FakeRoomNotificationChannelManager( + getChannelIdForRoomLambda = { _, _, _, _, _ -> "a-channel-id" }, clearRoomChannelLambda = { _, _ -> }, pruneChannelsForSessionLambda = { _, _ -> }, clearAllChannelsForSessionLambda = { }, @@ -265,7 +404,7 @@ class DefaultNotificationConversationServiceTest : RobolectricTest() { context = context, intentProvider = FakeIntentProvider(), bitmapLoader = FakeNotificationBitmapLoader(), - matrixClientProvider = FakeMatrixClientProvider(), + matrixClientProvider = matrixClientProvider, imageLoaderHolder = FakeImageLoaderHolder(), sessionObserver = sessionObserver, lockScreenService = lockScreenService, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceivedTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceivedTest.kt index 9c910d0a559..e832e5b54b4 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceivedTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceivedTest.kt @@ -24,12 +24,12 @@ import org.junit.Test class OnNotifiableEventReceivedTest { @Test fun `pushes a shortcut for a new incoming message`() = runTest { - val onSendMessage = lambdaRecorder { _, _, _, _, _ -> } - val service = FakeNotificationConversationService(onSendMessageLambda = onSendMessage) + val onMessageReceived = lambdaRecorder { _, _, _, _, _ -> } + val service = FakeNotificationConversationService(onMessageReceivedLambda = onMessageReceived) service.pushShortcutsForIncomingMessages(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) - onSendMessage.assertions().isCalledOnce().with( + onMessageReceived.assertions().isCalledOnce().with( value(A_SESSION_ID), value(A_ROOM_ID), value(A_ROOM_NAME), @@ -40,30 +40,30 @@ class OnNotifiableEventReceivedTest { @Test fun `skips outgoing messages`() = runTest { - val onSendMessage = lambdaRecorder { _, _, _, _, _ -> } - val service = FakeNotificationConversationService(onSendMessageLambda = onSendMessage) + val onMessageReceived = lambdaRecorder { _, _, _, _, _ -> } + val service = FakeNotificationConversationService(onMessageReceivedLambda = onMessageReceived) val outgoing = aNotifiableMessageEvent(roomId = A_ROOM_ID).copy(outGoingMessage = true) service.pushShortcutsForIncomingMessages(listOf(outgoing)) - onSendMessage.assertions().isNeverCalled() + onMessageReceived.assertions().isNeverCalled() } @Test fun `skips thread messages`() = runTest { - val onSendMessage = lambdaRecorder { _, _, _, _, _ -> } - val service = FakeNotificationConversationService(onSendMessageLambda = onSendMessage) + val onMessageReceived = lambdaRecorder { _, _, _, _, _ -> } + val service = FakeNotificationConversationService(onMessageReceivedLambda = onMessageReceived) val threadEvent = aNotifiableMessageEvent(roomId = A_ROOM_ID, threadId = ThreadId("\$a-thread-id")) service.pushShortcutsForIncomingMessages(listOf(threadEvent)) - onSendMessage.assertions().isNeverCalled() + onMessageReceived.assertions().isNeverCalled() } @Test fun `dedupes multiple events for the same room`() = runTest { - val onSendMessage = lambdaRecorder { _, _, _, _, _ -> } - val service = FakeNotificationConversationService(onSendMessageLambda = onSendMessage) + val onMessageReceived = lambdaRecorder { _, _, _, _, _ -> } + val service = FakeNotificationConversationService(onMessageReceivedLambda = onMessageReceived) service.pushShortcutsForIncomingMessages( listOf( @@ -73,6 +73,6 @@ class OnNotifiableEventReceivedTest { ) ) - onSendMessage.assertions().isCalledExactly(2) + onMessageReceived.assertions().isCalledExactly(2) } } diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt index 05dac93653b..598ed57ab96 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt @@ -15,15 +15,24 @@ import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder class FakeNotificationConversationService( - private val onSendMessageLambda: LambdaFiveParamsRecorder = lambdaRecorder { _, _, _, _, _ -> }, + private val onMessageSentLambda: LambdaFiveParamsRecorder = lambdaRecorder { _, _, _, _, _ -> }, + private val onMessageReceivedLambda: LambdaFiveParamsRecorder = lambdaRecorder { _, _, _, _, _ -> }, ) : NotificationConversationService { - override suspend fun onSendMessage( + override suspend fun onMessageSent( sessionId: SessionId, roomId: RoomId, roomName: String?, roomIsDirect: Boolean, roomAvatarUrl: String?, - ) = onSendMessageLambda(sessionId, roomId, roomName, roomIsDirect, roomAvatarUrl) + ) = onMessageSentLambda(sessionId, roomId, roomName, roomIsDirect, roomAvatarUrl) + + override suspend fun onMessageReceived( + sessionId: SessionId, + roomId: RoomId, + roomName: String?, + roomIsDirect: Boolean, + roomAvatarUrl: String?, + ) = onMessageReceivedLambda(sessionId, roomId, roomName, roomIsDirect, roomAvatarUrl) override suspend fun onLeftRoom(sessionId: SessionId, roomId: RoomId) = Unit From 4ba97cc059e9ef91798dd048187709201e687db7 Mon Sep 17 00:00:00 2001 From: Nick Hainke Date: Wed, 8 Jul 2026 14:44:44 +0200 Subject: [PATCH 5/6] Open room notification settings Add a room notification settings intent provider that opens the Android channel settings for a Matrix room. Use the conversation id on Android 11 and newer, fall back to channel settings on Android 8-10, and app settings before channels exist. Expose the entry point from the room notification settings screen and cover the API-level intent behavior with focused tests. --- .../RoomNotificationSettingsNode.kt | 29 +++++++ .../RoomNotificationSettingsView.kt | 22 +++++ ...UserDefinedRoomNotificationSettingsView.kt | 6 ++ .../impl/src/main/res/values/temporary.xml | 5 ++ .../RoomNotificationSettingsIntentProvider.kt | 21 +++++ ...tRoomNotificationSettingsIntentProvider.kt | 67 +++++++++++++++ ...mNotificationSettingsIntentProviderTest.kt | 84 +++++++++++++++++++ ...eRoomNotificationSettingsIntentProvider.kt | 22 +++++ 8 files changed, 256 insertions(+) create mode 100644 features/roomdetails/impl/src/main/res/values/temporary.xml create mode 100644 libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationSettingsIntentProvider.kt create mode 100644 libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProvider.kt create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProviderTest.kt create mode 100644 libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeRoomNotificationSettingsIntentProvider.kt diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt index fd3ce00a797..83e4eda8cd5 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt @@ -8,8 +8,11 @@ package io.element.android.features.roomdetails.impl.notificationsettings +import android.content.ActivityNotFoundException +import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node @@ -18,11 +21,16 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.annotations.ContributesNode +import io.element.android.libraries.androidutils.system.toast import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.push.api.notifications.RoomNotificationSettingsIntentProvider import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.launch @ContributesNode(RoomScope::class) @AssistedInject @@ -30,6 +38,9 @@ class RoomNotificationSettingsNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenterFactory: RoomNotificationSettingsPresenter.Factory, + @ApplicationContext private val context: Context, + private val room: JoinedRoom, + private val roomNotificationSettingsIntentProvider: RoomNotificationSettingsIntentProvider, private val analyticsService: AnalyticsService, ) : Node(buildContext, plugins = plugins) { data class RoomNotificationSettingInput( @@ -60,7 +71,25 @@ class RoomNotificationSettingsNode( state = state, modifier = modifier, onShowGlobalNotifications = callback::navigateToGlobalNotificationSettings, + onShowAndroidRoomNotificationSettings = ::openAndroidRoomNotificationSettings, onBackClick = ::navigateUp, ) } + + private fun openAndroidRoomNotificationSettings() { + lifecycleScope.launch { + val roomDisplayName = room.info().name?.takeIf { it.isNotBlank() } ?: room.roomId.value + val intent = roomNotificationSettingsIntentProvider.getIntent( + sessionId = room.sessionId, + roomId = room.roomId, + roomDisplayName = roomDisplayName, + isDm = room.isDm(), + ) + try { + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + context.toast(io.element.android.libraries.androidutils.R.string.error_no_compatible_app_found) + } + } + } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt index 12bc2589442..63a484d5839 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt @@ -31,7 +31,9 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.ui.strings.CommonStrings @@ -40,6 +42,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun RoomNotificationSettingsView( state: RoomNotificationSettingsState, onShowGlobalNotifications: () -> Unit, + onShowAndroidRoomNotificationSettings: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -47,6 +50,7 @@ fun RoomNotificationSettingsView( UserDefinedRoomNotificationSettingsView( state = state, modifier = modifier, + onShowAndroidRoomNotificationSettings = onShowAndroidRoomNotificationSettings, onBackClick = onBackClick, ) } else { @@ -54,6 +58,7 @@ fun RoomNotificationSettingsView( state = state, modifier = modifier, onShowGlobalNotifications = onShowGlobalNotifications, + onShowAndroidRoomNotificationSettings = onShowAndroidRoomNotificationSettings, onBackClick = onBackClick, ) } @@ -63,6 +68,7 @@ fun RoomNotificationSettingsView( private fun RoomSpecificNotificationSettingsView( state: RoomNotificationSettingsState, onShowGlobalNotifications: () -> Unit, + onShowAndroidRoomNotificationSettings: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -144,6 +150,10 @@ private fun RoomSpecificNotificationSettingsView( } } + AndroidRoomNotificationSettingsItem( + onClick = onShowAndroidRoomNotificationSettings, + ) + AsyncActionView( async = state.setNotificationSettingAction, onSuccess = {}, @@ -180,6 +190,18 @@ internal fun RoomNotificationSettingsViewPreview( RoomNotificationSettingsView( state = state, onShowGlobalNotifications = {}, + onShowAndroidRoomNotificationSettings = {}, onBackClick = {}, ) } + +@Composable +internal fun AndroidRoomNotificationSettingsItem( + onClick: () -> Unit, +) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_notification_settings_android_settings_title)) }, + supportingContent = { Text(stringResource(R.string.screen_room_notification_settings_android_settings_subtitle)) }, + onClick = onClick, + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt index 3e4e9e773e8..487e72e4230 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt @@ -34,6 +34,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar @Composable fun UserDefinedRoomNotificationSettingsView( state: RoomNotificationSettingsState, + onShowAndroidRoomNotificationSettings: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -65,6 +66,10 @@ fun UserDefinedRoomNotificationSettingsView( ) } + AndroidRoomNotificationSettingsItem( + onClick = onShowAndroidRoomNotificationSettings, + ) + ListItem( headlineContent = { Text(stringResource(R.string.screen_room_notification_settings_edit_remove_setting)) }, style = ListItemStyle.Destructive, @@ -109,6 +114,7 @@ internal fun UserDefinedRoomNotificationSettingsViewPreview( ) = ElementPreview { UserDefinedRoomNotificationSettingsView( state = state, + onShowAndroidRoomNotificationSettings = {}, onBackClick = {}, ) } diff --git a/features/roomdetails/impl/src/main/res/values/temporary.xml b/features/roomdetails/impl/src/main/res/values/temporary.xml new file mode 100644 index 00000000000..31434b08163 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values/temporary.xml @@ -0,0 +1,5 @@ + + + Change sound, vibration, priority and lock screen behaviour for this room in Android settings. + Android notification settings + diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationSettingsIntentProvider.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationSettingsIntentProvider.kt new file mode 100644 index 00000000000..9eba0a41814 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationSettingsIntentProvider.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.notifications + +import android.content.Intent +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +interface RoomNotificationSettingsIntentProvider { + suspend fun getIntent( + sessionId: SessionId, + roomId: RoomId, + roomDisplayName: String, + isDm: Boolean, + ): Intent +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProvider.kt new file mode 100644 index 00000000000..889e0b12d6c --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProvider.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.channels + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.notifications.RoomNotificationChannelManager +import io.element.android.libraries.push.api.notifications.RoomNotificationSettingsIntentProvider +import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultRoomNotificationSettingsIntentProvider( + @ApplicationContext private val context: Context, + private val roomNotificationChannelManager: RoomNotificationChannelManager, +) : RoomNotificationSettingsIntentProvider { + override suspend fun getIntent( + sessionId: SessionId, + roomId: RoomId, + roomDisplayName: String, + isDm: Boolean, + ): Intent { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channelId = roomNotificationChannelManager.getChannelIdForRoom( + sessionId = sessionId, + roomId = roomId, + roomDisplayName = roomDisplayName, + isDm = isDm, + noisy = true, + ) + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + .putExtra(Settings.EXTRA_CHANNEL_ID, channelId) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + putExtra(Settings.EXTRA_CONVERSATION_ID, createShortcutId(sessionId, roomId)) + } + addNewTaskFlagIfNeeded() + } + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", context.packageName, null)) + .apply { addNewTaskFlagIfNeeded() } + } + } + + private fun Intent.addNewTaskFlagIfNeeded() { + if (context !is Activity) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProviderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProviderTest.kt new file mode 100644 index 00000000000..9ebe4a7642f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProviderTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.channels + +import android.content.Intent +import android.os.Build +import android.provider.Settings +import com.google.common.truth.Truth.assertThat +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.notifications.shortcut.createShortcutId +import io.element.android.libraries.push.test.notifications.channels.FakeRoomNotificationChannelManager +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +class DefaultRoomNotificationSettingsIntentProviderTest : RobolectricTest() { + private val context = RuntimeEnvironment.getApplication() + private val sessionId = SessionId("@alice:example.org") + private val roomId = RoomId("!room:example.org") + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `getIntent opens conversation notification settings on Android 11 and above`() = runTest { + val provider = createProvider(channelId = "room-channel") + + val intent = provider.getIntent(sessionId, roomId, "Room", isDm = false) + + assertThat(intent.action).isEqualTo(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + assertThat(intent.getStringExtra(Settings.EXTRA_APP_PACKAGE)).isEqualTo(context.packageName) + assertThat(intent.getStringExtra(Settings.EXTRA_CHANNEL_ID)).isEqualTo("room-channel") + assertThat(intent.getStringExtra(Settings.EXTRA_CONVERSATION_ID)).isEqualTo(createShortcutId(sessionId, roomId)) + assertThat(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK).isEqualTo(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.P]) + fun `getIntent opens channel notification settings on Android 8 to 10`() = runTest { + val provider = createProvider(channelId = "room-channel") + + val intent = provider.getIntent(sessionId, roomId, "Room", isDm = false) + + assertThat(intent.action).isEqualTo(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + assertThat(intent.getStringExtra(Settings.EXTRA_APP_PACKAGE)).isEqualTo(context.packageName) + assertThat(intent.getStringExtra(Settings.EXTRA_CHANNEL_ID)).isEqualTo("room-channel") + assertThat(intent.hasExtra(Settings.EXTRA_CONVERSATION_ID)).isFalse() + assertThat(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK).isEqualTo(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun `getIntent falls back to app settings before notification channels`() = runTest { + val provider = createProvider(channelId = "room-channel") + + val intent = provider.getIntent(sessionId, roomId, "Room", isDm = false) + + assertThat(intent.action).isEqualTo(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + assertThat(intent.data.toString()).isEqualTo("package:${context.packageName}") + assertThat(intent.flags and Intent.FLAG_ACTIVITY_NEW_TASK).isEqualTo(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + private fun createProvider(channelId: String): DefaultRoomNotificationSettingsIntentProvider { + return DefaultRoomNotificationSettingsIntentProvider( + context = context, + roomNotificationChannelManager = FakeRoomNotificationChannelManager( + getChannelIdForRoomLambda = { sid, rid, roomDisplayName, isDm, noisy -> + assertThat(sid).isEqualTo(sessionId) + assertThat(rid).isEqualTo(roomId) + assertThat(roomDisplayName).isEqualTo("Room") + assertThat(isDm).isFalse() + assertThat(noisy).isTrue() + channelId + } + ), + ) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeRoomNotificationSettingsIntentProvider.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeRoomNotificationSettingsIntentProvider.kt new file mode 100644 index 00000000000..94f7cdcc555 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeRoomNotificationSettingsIntentProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test.notifications + +import android.content.Intent +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.notifications.RoomNotificationSettingsIntentProvider +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeRoomNotificationSettingsIntentProvider( + private val getIntentLambda: (SessionId, RoomId, String, Boolean) -> Intent = { _, _, _, _ -> lambdaError() }, +) : RoomNotificationSettingsIntentProvider { + override suspend fun getIntent(sessionId: SessionId, roomId: RoomId, roomDisplayName: String, isDm: Boolean): Intent { + return getIntentLambda(sessionId, roomId, roomDisplayName, isDm) + } +} From aade768f1b9493638e81ba2e9761af21a2c76988 Mon Sep 17 00:00:00 2001 From: Nick Hainke Date: Wed, 8 Jul 2026 15:25:20 +0200 Subject: [PATCH 6/6] Improve room notification metadata Publish the room shortcut before opening Android notification settings so the platform can resolve the conversation name and icon. Propagate room avatar metadata into shortcuts, add fallback icons, and keep shortcut and locus IDs aligned on room notifications. --- .../RoomNotificationSettingsNode.kt | 4 +- .../RoomNotificationSettingsIntentProvider.kt | 1 + .../NotificationConversationService.kt | 15 +++++ ...tRoomNotificationSettingsIntentProvider.kt | 14 +++++ .../DefaultNotificationConversationService.kt | 17 +++++- .../factories/NotificationCreator.kt | 17 +++--- ...mNotificationSettingsIntentProviderTest.kt | 21 ++++++- ...aultNotificationConversationServiceTest.kt | 58 ++++++++++++++++++- .../DefaultNotificationCreatorTest.kt | 3 + ...eRoomNotificationSettingsIntentProvider.kt | 6 +- .../FakeNotificationConversationService.kt | 9 +++ 11 files changed, 147 insertions(+), 18 deletions(-) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt index 83e4eda8cd5..6250409970c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt @@ -78,12 +78,14 @@ class RoomNotificationSettingsNode( private fun openAndroidRoomNotificationSettings() { lifecycleScope.launch { - val roomDisplayName = room.info().name?.takeIf { it.isNotBlank() } ?: room.roomId.value + val roomInfo = room.info() + val roomDisplayName = roomInfo.name?.takeIf { it.isNotBlank() } ?: room.roomId.value val intent = roomNotificationSettingsIntentProvider.getIntent( sessionId = room.sessionId, roomId = room.roomId, roomDisplayName = roomDisplayName, isDm = room.isDm(), + roomAvatarUrl = roomInfo.avatarUrl, ) try { context.startActivity(intent) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationSettingsIntentProvider.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationSettingsIntentProvider.kt index 9eba0a41814..d78267fb252 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationSettingsIntentProvider.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationSettingsIntentProvider.kt @@ -17,5 +17,6 @@ interface RoomNotificationSettingsIntentProvider { roomId: RoomId, roomDisplayName: String, isDm: Boolean, + roomAvatarUrl: String?, ): Intent } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt index a315f303812..8b9d3b898c6 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt @@ -15,6 +15,21 @@ import io.element.android.libraries.matrix.api.core.SessionId * Service to handle conversation-related notifications. */ interface NotificationConversationService { + /** + * Ensures Android has a long-lived conversation shortcut for [roomId]. + * + * Android resolves conversation notification settings and avatars through the shortcut id + * attached to the room's notification channel. This method deliberately only publishes the + * shortcut; it does not create a notification. + */ + suspend fun ensureRoomShortcut( + sessionId: SessionId, + roomId: RoomId, + roomName: String?, + roomIsDirect: Boolean, + roomAvatarUrl: String?, + ) + /** * Called when the current user sends a message in [roomId]. Refreshes the room's conversation * shortcut and, unlike [onMessageReceived], also ensures the room's per-room notification diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProvider.kt index 889e0b12d6c..ffc9479c819 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProvider.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProvider.kt @@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.push.api.notifications.RoomNotificationChannelManager import io.element.android.libraries.push.api.notifications.RoomNotificationSettingsIntentProvider +import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId @SingleIn(AppScope::class) @@ -28,14 +29,27 @@ import io.element.android.libraries.push.impl.notifications.shortcut.createShort class DefaultRoomNotificationSettingsIntentProvider( @ApplicationContext private val context: Context, private val roomNotificationChannelManager: RoomNotificationChannelManager, + private val notificationConversationService: NotificationConversationService, ) : RoomNotificationSettingsIntentProvider { override suspend fun getIntent( sessionId: SessionId, roomId: RoomId, roomDisplayName: String, isDm: Boolean, + roomAvatarUrl: String?, ): Intent { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Publish the shortcut before opening Settings so Android can resolve the room name + // and icon for this conversation channel. We intentionally do not post a synthetic + // notification here; Android may still only list the conversation in the app-wide + // notification settings after it has seen a real MessagingStyle notification. + notificationConversationService.ensureRoomShortcut( + sessionId = sessionId, + roomId = roomId, + roomName = roomDisplayName, + roomIsDirect = isDm, + roomAvatarUrl = roomAvatarUrl, + ) val channelId = roomNotificationChannelManager.getChannelIdForRoom( sessionId = sessionId, roomId = roomId, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt index a1d5646b25b..216b1d5112b 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt @@ -11,6 +11,7 @@ package io.element.android.libraries.push.impl.notifications.conversations import android.content.Context import android.content.pm.ShortcutInfo import android.os.Build +import androidx.core.content.LocusIdCompat import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat @@ -22,6 +23,7 @@ import io.element.android.libraries.core.coroutine.withPreviousValue import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.MatrixClient @@ -84,6 +86,16 @@ class DefaultNotificationConversationService( .launchIn(coroutineScope) } + override suspend fun ensureRoomShortcut( + sessionId: SessionId, + roomId: RoomId, + roomName: String?, + roomIsDirect: Boolean, + roomAvatarUrl: String?, + ) { + pushConversationShortcut(sessionId, roomId, roomName, roomIsDirect, roomAvatarUrl) + } + override suspend fun onMessageSent( sessionId: SessionId, roomId: RoomId, @@ -143,12 +155,15 @@ class DefaultNotificationConversationService( imageLoader = imageLoader, targetSize = defaultShortcutIconSize.toLong() )?.let(IconCompat::createWithBitmap) + ?: IconCompat.createWithResource(context, CommonDrawables.ic_notification) - val shortcutInfo = ShortcutInfoCompat.Builder(context, createShortcutId(sessionId, roomId)) + val shortcutId = createShortcutId(sessionId, roomId) + val shortcutInfo = ShortcutInfoCompat.Builder(context, shortcutId) .setShortLabel(name) .setIcon(icon) .setIntent(intentProvider.getViewRoomIntent(sessionId, roomId, threadId = null, eventId = null)) .setCategories(categories) + .setLocusId(LocusIdCompat(shortcutId)) .setLongLived(true) .let { when (roomIsDirect) { 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 b74552630ae..6b59a36b12f 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 @@ -16,6 +16,7 @@ import androidx.annotation.ColorInt import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.MessagingStyle import androidx.core.app.Person +import androidx.core.content.LocusIdCompat import coil3.ImageLoader import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -171,21 +172,13 @@ class DefaultNotificationCreator( } else { NotificationCompat.CATEGORY_MESSAGE } + val roomShortcutId = createShortcutId(roomInfo.sessionId, roomInfo.roomId) val builder = if (existingNotification != null) { NotificationCompat.Builder(context, existingNotification) // Clear existing actions .clearActions() } else { NotificationCompat.Builder(context, channelId) - // ID of the corresponding shortcut, for conversation features under API 30+ - // Must match those created in the ShortcutInfoCompat.Builder() - // for the notification to appear as a "Conversation": - // https://developer.android.com/develop/ui/views/notifications/conversations - .apply { - if (threadId == null) { - setShortcutId(createShortcutId(roomInfo.sessionId, roomInfo.roomId)) - } - } .setGroupSummary(false) // In order to avoid notification making sound twice (due to the summary notification) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) @@ -203,6 +196,12 @@ class DefaultNotificationCreator( ) messagingStyle.addMessagesFromEvents(events, imageLoader) return builder + .apply { + if (threadId == null) { + setShortcutId(roomShortcutId) + setLocusId(LocusIdCompat(roomShortcutId)) + } + } .setCategory(category) .setNumber(events.size) .setOnlyAlertOnce(roomInfo.isUpdated) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProviderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProviderTest.kt index 9ebe4a7642f..403e73b6132 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProviderTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProviderTest.kt @@ -15,6 +15,8 @@ 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.notifications.shortcut.createShortcutId import io.element.android.libraries.push.test.notifications.channels.FakeRoomNotificationChannelManager +import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.test.runTest import org.junit.Test @@ -31,7 +33,7 @@ class DefaultRoomNotificationSettingsIntentProviderTest : RobolectricTest() { fun `getIntent opens conversation notification settings on Android 11 and above`() = runTest { val provider = createProvider(channelId = "room-channel") - val intent = provider.getIntent(sessionId, roomId, "Room", isDm = false) + val intent = provider.getIntent(sessionId, roomId, "Room", isDm = false, roomAvatarUrl = "mxc://avatar") assertThat(intent.action).isEqualTo(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) assertThat(intent.getStringExtra(Settings.EXTRA_APP_PACKAGE)).isEqualTo(context.packageName) @@ -45,7 +47,7 @@ class DefaultRoomNotificationSettingsIntentProviderTest : RobolectricTest() { fun `getIntent opens channel notification settings on Android 8 to 10`() = runTest { val provider = createProvider(channelId = "room-channel") - val intent = provider.getIntent(sessionId, roomId, "Room", isDm = false) + val intent = provider.getIntent(sessionId, roomId, "Room", isDm = false, roomAvatarUrl = "mxc://avatar") assertThat(intent.action).isEqualTo(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) assertThat(intent.getStringExtra(Settings.EXTRA_APP_PACKAGE)).isEqualTo(context.packageName) @@ -59,7 +61,7 @@ class DefaultRoomNotificationSettingsIntentProviderTest : RobolectricTest() { fun `getIntent falls back to app settings before notification channels`() = runTest { val provider = createProvider(channelId = "room-channel") - val intent = provider.getIntent(sessionId, roomId, "Room", isDm = false) + val intent = provider.getIntent(sessionId, roomId, "Room", isDm = false, roomAvatarUrl = "mxc://avatar") assertThat(intent.action).isEqualTo(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) assertThat(intent.data.toString()).isEqualTo("package:${context.packageName}") @@ -67,10 +69,13 @@ class DefaultRoomNotificationSettingsIntentProviderTest : RobolectricTest() { } private fun createProvider(channelId: String): DefaultRoomNotificationSettingsIntentProvider { + val callOrder = mutableListOf() return DefaultRoomNotificationSettingsIntentProvider( context = context, roomNotificationChannelManager = FakeRoomNotificationChannelManager( getChannelIdForRoomLambda = { sid, rid, roomDisplayName, isDm, noisy -> + callOrder.add("channel") + assertThat(callOrder).containsExactly("shortcut", "channel").inOrder() assertThat(sid).isEqualTo(sessionId) assertThat(rid).isEqualTo(roomId) assertThat(roomDisplayName).isEqualTo("Room") @@ -79,6 +84,16 @@ class DefaultRoomNotificationSettingsIntentProviderTest : RobolectricTest() { channelId } ), + notificationConversationService = FakeNotificationConversationService( + ensureRoomShortcutLambda = lambdaRecorder { sid, rid, roomName, roomIsDirect, roomAvatarUrl -> + callOrder.add("shortcut") + assertThat(sid).isEqualTo(sessionId) + assertThat(rid).isEqualTo(roomId) + assertThat(roomName).isEqualTo("Room") + assertThat(roomIsDirect).isFalse() + assertThat(roomAvatarUrl).isEqualTo("mxc://avatar") + }, + ), ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt index ec3c89085ee..07468a19714 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt @@ -10,12 +10,15 @@ package io.element.android.libraries.push.impl.notifications.conversations import android.content.Context import android.content.Intent +import android.content.pm.ShortcutInfo +import android.graphics.Bitmap import android.os.Build import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import io.element.android.features.lockscreen.test.FakeLockScreenService +import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 @@ -78,6 +81,58 @@ class DefaultNotificationConversationServiceTest : RobolectricTest() { assertThat(shortcuts).isNotEmpty() } + @Test + fun `ensureRoomShortcut adds a stable long-lived conversation shortcut with room metadata`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + var avatarData: AvatarData? = null + val bitmapLoader = FakeNotificationBitmapLoader( + getRoomBitmapResult = { data, _, _ -> + avatarData = data + Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + }, + ) + val service = createService(context, bitmapLoader = bitmapLoader) + + service.ensureRoomShortcut( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + roomName = "Room title", + roomIsDirect = false, + roomAvatarUrl = "mxc://avatar", + ) + + val shortcut = ShortcutManagerCompat.getDynamicShortcuts(context).single() + assertThat(shortcut.id).isEqualTo(createShortcutId(A_SESSION_ID, A_ROOM_ID)) + assertThat(shortcut.shortLabel).isEqualTo("Room title") + assertThat(shortcut.categories).contains(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION) + assertThat(shortcut.intent.action).isEqualTo(Intent.ACTION_VIEW) + assertThat(avatarData?.id).isEqualTo(A_ROOM_ID.value) + assertThat(avatarData?.name).isEqualTo("Room title") + assertThat(avatarData?.url).isEqualTo("mxc://avatar") + } + + @Test + fun `ensureRoomShortcut uses a fallback icon when the room avatar cannot be loaded`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val service = createService( + context, + bitmapLoader = FakeNotificationBitmapLoader( + getRoomBitmapResult = { _, _, _ -> null }, + ), + ) + + service.ensureRoomShortcut( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + roomName = "Room title", + roomIsDirect = false, + roomAvatarUrl = null, + ) + + val shortcut = ShortcutManagerCompat.getDynamicShortcuts(context).single() + assertThat(shortcut.id).isEqualTo(createShortcutId(A_SESSION_ID, A_ROOM_ID)) + } + @Test fun `onMessageReceived does not ensure a notification channel`() = runTest { val context = InstrumentationRegistry.getInstrumentation().context @@ -392,6 +447,7 @@ class DefaultNotificationConversationServiceTest : RobolectricTest() { sessionObserver: FakeSessionObserver = FakeSessionObserver(), lockScreenService: FakeLockScreenService = FakeLockScreenService(), matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + bitmapLoader: FakeNotificationBitmapLoader = FakeNotificationBitmapLoader(), roomNotificationChannelManager: FakeRoomNotificationChannelManager = FakeRoomNotificationChannelManager( getChannelIdForRoomLambda = { _, _, _, _, _ -> "a-channel-id" }, clearRoomChannelLambda = { _, _ -> }, @@ -403,7 +459,7 @@ class DefaultNotificationConversationServiceTest : RobolectricTest() { ) = DefaultNotificationConversationService( context = context, intentProvider = FakeIntentProvider(), - bitmapLoader = FakeNotificationBitmapLoader(), + bitmapLoader = bitmapLoader, matrixClientProvider = matrixClientProvider, imageLoaderHolder = FakeImageLoaderHolder(), sessionObserver = sessionObserver, 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 f5be23746ad..7190e0904df 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 @@ -43,6 +43,7 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNo import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId import io.element.android.libraries.push.test.notifications.channels.FakeRoomNotificationChannelManager import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider @@ -274,6 +275,7 @@ class DefaultNotificationCreatorTest : RobolectricTest() { events = listOf(aNotifiableMessageEvent()), ) result.commonAssertions() + assertThat(result.shortcutId).isEqualTo(createShortcutId(A_SESSION_ID, A_ROOM_ID)) } @Test @@ -303,6 +305,7 @@ class DefaultNotificationCreatorTest : RobolectricTest() { events = listOf(aNotifiableMessageEvent()), ) result.commonAssertions() + assertThat(result.shortcutId).isNull() } @Test diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeRoomNotificationSettingsIntentProvider.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeRoomNotificationSettingsIntentProvider.kt index 94f7cdcc555..48a05abc50f 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeRoomNotificationSettingsIntentProvider.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeRoomNotificationSettingsIntentProvider.kt @@ -14,9 +14,9 @@ import io.element.android.libraries.push.api.notifications.RoomNotificationSetti import io.element.android.tests.testutils.lambda.lambdaError class FakeRoomNotificationSettingsIntentProvider( - private val getIntentLambda: (SessionId, RoomId, String, Boolean) -> Intent = { _, _, _, _ -> lambdaError() }, + private val getIntentLambda: (SessionId, RoomId, String, Boolean, String?) -> Intent = { _, _, _, _, _ -> lambdaError() }, ) : RoomNotificationSettingsIntentProvider { - override suspend fun getIntent(sessionId: SessionId, roomId: RoomId, roomDisplayName: String, isDm: Boolean): Intent { - return getIntentLambda(sessionId, roomId, roomDisplayName, isDm) + override suspend fun getIntent(sessionId: SessionId, roomId: RoomId, roomDisplayName: String, isDm: Boolean, roomAvatarUrl: String?): Intent { + return getIntentLambda(sessionId, roomId, roomDisplayName, isDm, roomAvatarUrl) } } diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt index 598ed57ab96..1caf656a81d 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt @@ -15,9 +15,18 @@ import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder class FakeNotificationConversationService( + private val ensureRoomShortcutLambda: LambdaFiveParamsRecorder = lambdaRecorder { _, _, _, _, _ -> }, private val onMessageSentLambda: LambdaFiveParamsRecorder = lambdaRecorder { _, _, _, _, _ -> }, private val onMessageReceivedLambda: LambdaFiveParamsRecorder = lambdaRecorder { _, _, _, _, _ -> }, ) : NotificationConversationService { + override suspend fun ensureRoomShortcut( + sessionId: SessionId, + roomId: RoomId, + roomName: String?, + roomIsDirect: Boolean, + roomAvatarUrl: String?, + ) = ensureRoomShortcutLambda(sessionId, roomId, roomName, roomIsDirect, roomAvatarUrl) + override suspend fun onMessageSent( sessionId: SessionId, roomId: RoomId,