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/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..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 @@ -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,27 @@ class RoomNotificationSettingsNode( state = state, modifier = modifier, onShowGlobalNotifications = callback::navigateToGlobalNotificationSettings, + onShowAndroidRoomNotificationSettings = ::openAndroidRoomNotificationSettings, onBackClick = ::navigateUp, ) } + + private fun openAndroidRoomNotificationSettings() { + lifecycleScope.launch { + 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) + } 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/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..33c292916a1 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationChannelManager.kt @@ -0,0 +1,64 @@ +/* + * 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 - 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). + * + * 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 { + /** + * 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/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..d78267fb252 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/RoomNotificationSettingsIntentProvider.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.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, + 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 4ea80e4ce80..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 @@ -16,10 +16,44 @@ 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. + * 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 onSendMessage( + 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 + * 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 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 new file mode 100644 index 00000000000..3479c82770e --- /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 when `noisy` is true + * - 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/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..ffc9479c819 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProvider.kt @@ -0,0 +1,81 @@ +/* + * 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.api.notifications.conversations.NotificationConversationService +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, + 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, + 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/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/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..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,17 +23,22 @@ 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 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 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 @@ -41,6 +47,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) @@ -51,6 +58,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,29 +76,71 @@ 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) } - override suspend fun onSendMessage( + 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, + 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) @@ -104,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) { @@ -123,6 +177,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) { @@ -139,6 +245,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 +278,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 +298,7 @@ class DefaultNotificationConversationService( } } - private fun onSessionLogOut(sessionId: SessionId) { + private suspend fun onSessionLogOut(sessionId: SessionId) { runCatchingExceptions { val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) val shortcutIdsToRemove = shortcuts.filterBySession(sessionId).map { it.id } @@ -193,5 +314,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..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 @@ -31,6 +32,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 +116,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 +155,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, ) } @@ -166,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) @@ -198,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/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..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 @@ -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, 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 + .asSequence() + .filterIsInstance() + .filter { !it.outGoingMessage && it.threadId == null } + .distinctBy { it.sessionId to it.roomId } + .forEach { event -> + onMessageReceived( + 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/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/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..403e73b6132 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/DefaultRoomNotificationSettingsIntentProviderTest.kt @@ -0,0 +1,99 @@ +/* + * 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.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 +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, roomAvatarUrl = "mxc://avatar") + + 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, roomAvatarUrl = "mxc://avatar") + + 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, roomAvatarUrl = "mxc://avatar") + + 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 { + 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") + assertThat(isDm).isFalse() + assertThat(noisy).isTrue() + 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/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/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..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,37 +10,49 @@ 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 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 +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 +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", @@ -52,6 +64,189 @@ 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 `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 + // 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 @@ -141,6 +336,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,18 +395,77 @@ 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(), + matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + bitmapLoader: FakeNotificationBitmapLoader = FakeNotificationBitmapLoader(), + roomNotificationChannelManager: FakeRoomNotificationChannelManager = FakeRoomNotificationChannelManager( + getChannelIdForRoomLambda = { _, _, _, _, _ -> "a-channel-id" }, + clearRoomChannelLambda = { _, _ -> }, + pruneChannelsForSessionLambda = { _, _ -> }, + clearAllChannelsForSessionLambda = { }, + pruneInactiveChannelsLambda = { }, + ), + sessionStore: InMemorySessionStore = InMemorySessionStore(), ) = DefaultNotificationConversationService( context = context, intentProvider = FakeIntentProvider(), - bitmapLoader = FakeNotificationBitmapLoader(), - matrixClientProvider = FakeMatrixClientProvider(), + bitmapLoader = bitmapLoader, + matrixClientProvider = matrixClientProvider, 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..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 @@ -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,8 @@ 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 import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP @@ -271,6 +275,7 @@ class DefaultNotificationCreatorTest : RobolectricTest() { events = listOf(aNotifiableMessageEvent()), ) result.commonAssertions() + assertThat(result.shortcutId).isEqualTo(createShortcutId(A_SESSION_ID, A_ROOM_ID)) } @Test @@ -300,6 +305,51 @@ class DefaultNotificationCreatorTest : RobolectricTest() { events = listOf(aNotifiableMessageEvent()), ) result.commonAssertions() + assertThat(result.shortcutId).isNull() + } + + @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( @@ -322,6 +372,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 +384,7 @@ fun createNotificationCreator( return DefaultNotificationCreator( context = context, notificationChannels = notificationChannels, + roomNotificationChannelManager = roomNotificationChannelManager, stringProvider = FakeStringProvider("test"), buildMeta = buildMeta, pendingIntentFactory = PendingIntentFactory( 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..e832e5b54b4 --- /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 onMessageReceived = lambdaRecorder { _, _, _, _, _ -> } + val service = FakeNotificationConversationService(onMessageReceivedLambda = onMessageReceived) + + service.pushShortcutsForIncomingMessages(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) + + onMessageReceived.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 onMessageReceived = lambdaRecorder { _, _, _, _, _ -> } + val service = FakeNotificationConversationService(onMessageReceivedLambda = onMessageReceived) + val outgoing = aNotifiableMessageEvent(roomId = A_ROOM_ID).copy(outGoingMessage = true) + + service.pushShortcutsForIncomingMessages(listOf(outgoing)) + + onMessageReceived.assertions().isNeverCalled() + } + + @Test + fun `skips thread messages`() = runTest { + val onMessageReceived = lambdaRecorder { _, _, _, _, _ -> } + val service = FakeNotificationConversationService(onMessageReceivedLambda = onMessageReceived) + val threadEvent = aNotifiableMessageEvent(roomId = A_ROOM_ID, threadId = ThreadId("\$a-thread-id")) + + service.pushShortcutsForIncomingMessages(listOf(threadEvent)) + + onMessageReceived.assertions().isNeverCalled() + } + + @Test + fun `dedupes multiple events for the same room`() = runTest { + val onMessageReceived = lambdaRecorder { _, _, _, _, _ -> } + val service = FakeNotificationConversationService(onMessageReceivedLambda = onMessageReceived) + + service.pushShortcutsForIncomingMessages( + listOf( + aNotifiableMessageEvent(roomId = A_ROOM_ID), + aNotifiableMessageEvent(roomId = A_ROOM_ID), + aNotifiableMessageEvent(roomId = A_ROOM_ID_2), + ) + ) + + onMessageReceived.assertions().isCalledExactly(2) + } +} 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..48a05abc50f --- /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, String?) -> Intent = { _, _, _, _, _ -> lambdaError() }, +) : RoomNotificationSettingsIntentProvider { + 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/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) + } +} 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..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 @@ -11,15 +11,37 @@ 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 { - override suspend fun onSendMessage( +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?, - ) = Unit + ) = ensureRoomShortcutLambda(sessionId, roomId, roomName, roomIsDirect, roomAvatarUrl) + + override suspend fun onMessageSent( + sessionId: SessionId, + roomId: RoomId, + roomName: String?, + roomIsDirect: Boolean, + roomAvatarUrl: String?, + ) = 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