diff --git a/changelog.d/1491.bugfix b/changelog.d/1491.bugfix new file mode 100644 index 0000000000..0ff6bd2c11 --- /dev/null +++ b/changelog.d/1491.bugfix @@ -0,0 +1 @@ +Stops showing a dedicated redacted event notification, the message notifications will update accordingly \ No newline at end of file diff --git a/changelog.d/3395.bugfix b/changelog.d/3395.bugfix new file mode 100644 index 0000000000..9482e1bc7e --- /dev/null +++ b/changelog.d/3395.bugfix @@ -0,0 +1 @@ +Fixes marking individual notifications as read causing other notifications to be dismissed \ No newline at end of file diff --git a/changelog.d/4152.bugfix b/changelog.d/4152.bugfix new file mode 100644 index 0000000000..1ff45609b5 --- /dev/null +++ b/changelog.d/4152.bugfix @@ -0,0 +1 @@ +Tentatively fixing the doubled notifications by updating the group summary at specific points in the notification rendering cycle \ No newline at end of file diff --git a/changelog.d/582.feature b/changelog.d/582.feature new file mode 100644 index 0000000000..5f82e1b82c --- /dev/null +++ b/changelog.d/582.feature @@ -0,0 +1 @@ +Adding the room name to the invitation notification (if the room summary is available) \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index 169f90dbca..aad5fce33e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt @@ -22,6 +22,8 @@ import org.json.JSONObject import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.session.crypto.MXCryptoError +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent @@ -310,3 +312,6 @@ fun Event.isEdition(): Boolean { fun Event.getPresenceContent(): PresenceContent? { return content.toModel() } + +fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER && + content?.toModel()?.membership == Membership.INVITE diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt index 1321f8dd62..3c74888eda 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.notification import org.matrix.android.sdk.api.pushrules.rest.PushRule import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.isInvitation import org.matrix.android.sdk.api.session.sync.model.RoomsSyncResponse import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.task.Task @@ -48,14 +49,18 @@ internal class DefaultProcessEventForPushTask @Inject constructor( } val newJoinEvents = params.syncResponse.join .mapNotNull { (key, value) -> - value.timeline?.events?.map { it.copy(roomId = key) } + value.timeline?.events?.mapNotNull { + it.takeIf { !it.isInvitation() }?.copy(roomId = key) + } } .flatten() + val inviteEvents = params.syncResponse.invite .mapNotNull { (key, value) -> value.inviteState?.events?.map { it.copy(roomId = key) } } .flatten() + val allEvents = (newJoinEvents + inviteEvents).filter { event -> when (event.type) { EventType.MESSAGE, diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt index 8f8625fe1c..29077c3a76 100644 --- a/tools/check/forbidden_strings_in_code.txt +++ b/tools/check/forbidden_strings_in_code.txt @@ -160,7 +160,7 @@ Formatter\.formatShortFileSize===1 # android\.text\.TextUtils ### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If the enum is not used as a Json class, change the value in file forbidden_strings_in_code.txt -enum class===106 +enum class===107 ### Do not import temporary legacy classes import org.matrix.android.sdk.internal.legacy.riot===3 diff --git a/vector/src/gplay/java/im/vector/app/gplay/push/fcm/VectorFirebaseMessagingService.kt b/vector/src/gplay/java/im/vector/app/gplay/push/fcm/VectorFirebaseMessagingService.kt index b1f0b43705..63d50d4f97 100755 --- a/vector/src/gplay/java/im/vector/app/gplay/push/fcm/VectorFirebaseMessagingService.kt +++ b/vector/src/gplay/java/im/vector/app/gplay/push/fcm/VectorFirebaseMessagingService.kt @@ -29,16 +29,13 @@ import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import dagger.hilt.android.AndroidEntryPoint import im.vector.app.BuildConfig -import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.network.WifiDetector import im.vector.app.core.pushers.PushersManager import im.vector.app.features.badge.BadgeProxy import im.vector.app.features.notifications.NotifiableEventResolver -import im.vector.app.features.notifications.NotifiableMessageEvent import im.vector.app.features.notifications.NotificationDrawerManager import im.vector.app.features.notifications.NotificationUtils -import im.vector.app.features.notifications.SimpleNotifiableEvent import im.vector.app.features.settings.VectorDataStore import im.vector.app.features.settings.VectorPreferences import im.vector.app.push.fcm.FcmHelper @@ -48,9 +45,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.logger.LoggerTag -import org.matrix.android.sdk.api.pushrules.Action import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.events.model.Event import timber.log.Timber import javax.inject.Inject @@ -201,12 +196,11 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { Timber.tag(loggerTag.value).d("Fast lane: start request") val event = tryOrNull { session.getEvent(roomId, eventId) } ?: return@launch - val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event) + val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event, canBeReplaced = true) resolvedEvent ?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") } ?.let { - it.isPushGatewayEvent = true notificationDrawerManager.onNotifiableEventReceived(it) notificationDrawerManager.refreshNotificationDrawer() } @@ -227,87 +221,4 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { } return false } - - private fun handleNotificationWithoutSyncingMode(data: Map, session: Session?) { - if (session == null) { - Timber.tag(loggerTag.value).e("## handleNotificationWithoutSyncingMode cannot find session") - return - } - - // The Matrix event ID of the event being notified about. - // This is required if the notification is about a particular Matrix event. - // It may be omitted for notifications that only contain updated badge counts. - // This ID can and should be used to detect duplicate notification requests. - val eventId = data["event_id"] ?: return // Just ignore - - val eventType = data["type"] - if (eventType == null) { - // Just add a generic unknown event - val simpleNotifiableEvent = SimpleNotifiableEvent( - session.myUserId, - eventId, - null, - true, // It's an issue in this case, all event will bing even if expected to be silent. - title = getString(R.string.notification_unknown_new_event), - description = "", - type = null, - timestamp = System.currentTimeMillis(), - soundName = Action.ACTION_OBJECT_VALUE_VALUE_DEFAULT, - isPushGatewayEvent = true - ) - notificationDrawerManager.onNotifiableEventReceived(simpleNotifiableEvent) - notificationDrawerManager.refreshNotificationDrawer() - } else { - val event = parseEvent(data) ?: return - - val notifiableEvent = notifiableEventResolver.resolveEvent(event, session) - - if (notifiableEvent == null) { - Timber.tag(loggerTag.value).e("Unsupported notifiable event $eventId") - if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { - Timber.tag(loggerTag.value).e("--> $event") - } - } else { - if (notifiableEvent is NotifiableMessageEvent) { - if (notifiableEvent.senderName.isNullOrEmpty()) { - notifiableEvent.senderName = data["sender_display_name"] ?: data["sender"] ?: "" - } - if (notifiableEvent.roomName.isNullOrEmpty()) { - notifiableEvent.roomName = findRoomNameBestEffort(data, session) ?: "" - } - } - - notifiableEvent.isPushGatewayEvent = true - notifiableEvent.matrixID = session.myUserId - notificationDrawerManager.onNotifiableEventReceived(notifiableEvent) - notificationDrawerManager.refreshNotificationDrawer() - } - } - } - - private fun findRoomNameBestEffort(data: Map, session: Session?): String? { - var roomName: String? = data["room_name"] - val roomId = data["room_id"] - if (null == roomName && null != roomId) { - // Try to get the room name from our store - roomName = session?.getRoom(roomId)?.roomSummary()?.displayName - } - return roomName - } - - /** - * Try to create an event from the FCM data - * - * @param data the FCM data - * @return the event or null if required data are missing - */ - private fun parseEvent(data: Map?): Event? { - return Event( - eventId = data?.get("event_id") ?: return null, - senderId = data["sender"], - roomId = data["room_id"] ?: return null, - type = data["type"] ?: return null, - originServerTs = System.currentTimeMillis() - ) - } } diff --git a/vector/src/main/java/im/vector/app/features/notifications/InviteNotifiableEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/InviteNotifiableEvent.kt index 61fd5c677a..832f97bc4e 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/InviteNotifiableEvent.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/InviteNotifiableEvent.kt @@ -15,22 +15,18 @@ */ package im.vector.app.features.notifications -import androidx.core.app.NotificationCompat - data class InviteNotifiableEvent( - override var matrixID: String?, + val matrixID: String?, override val eventId: String, override val editedEventId: String?, - var roomId: String, - override var noisy: Boolean, - override val title: String, - override val description: String, - override val type: String?, - override val timestamp: Long, - override var soundName: String?, - override var isPushGatewayEvent: Boolean = false) : NotifiableEvent { - - override var hasBeenDisplayed: Boolean = false - override var isRedacted: Boolean = false - override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC -} + override val canBeReplaced: Boolean, + val roomId: String, + val roomName: String?, + val noisy: Boolean, + val title: String, + val description: String, + val type: String?, + val timestamp: Long, + val soundName: String?, + override val isRedacted: Boolean = false +) : NotifiableEvent diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEvent.kt index a4f099b905..52d8119cbb 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEvent.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEvent.kt @@ -20,24 +20,11 @@ import java.io.Serializable /** * Parent interface for all events which can be displayed as a Notification */ -interface NotifiableEvent : Serializable { - var matrixID: String? +sealed interface NotifiableEvent : Serializable { val eventId: String val editedEventId: String? - var noisy: Boolean - val title: String - val description: String? - val type: String? - val timestamp: Long - - // NotificationCompat.VISIBILITY_PUBLIC , VISIBILITY_PRIVATE , VISIBILITY_SECRET - var lockScreenVisibility: Int - - // Compat: Only for android <7, for newer version the sound is defined in the channel - var soundName: String? - var hasBeenDisplayed: Boolean - var isRedacted: Boolean // Used to know if event should be replaced with the one coming from eventstream - var isPushGatewayEvent: Boolean + val canBeReplaced: Boolean + val isRedacted: Boolean } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt new file mode 100644 index 0000000000..3d10d74fe3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import im.vector.app.features.invite.AutoAcceptInvites +import im.vector.app.features.notifications.ProcessedEvent.Type.KEEP +import im.vector.app.features.notifications.ProcessedEvent.Type.REMOVE +import org.matrix.android.sdk.api.session.events.model.EventType +import javax.inject.Inject + +private typealias ProcessedEvents = List> + +class NotifiableEventProcessor @Inject constructor( + private val outdatedDetector: OutdatedEventDetector, + private val autoAcceptInvites: AutoAcceptInvites +) { + + fun process(queuedEvents: List, currentRoomId: String?, renderedEvents: ProcessedEvents): ProcessedEvents { + val processedEvents = queuedEvents.map { + val type = when (it) { + is InviteNotifiableEvent -> if (autoAcceptInvites.hideInvites) REMOVE else KEEP + is NotifiableMessageEvent -> if (shouldIgnoreMessageEventInRoom(currentRoomId, it.roomId) || outdatedDetector.isMessageOutdated(it)) { + REMOVE + } else KEEP + is SimpleNotifiableEvent -> when (it.type) { + EventType.REDACTION -> REMOVE + else -> KEEP + } + } + ProcessedEvent(type, it) + } + + val removedEventsDiff = renderedEvents.filter { renderedEvent -> + queuedEvents.none { it.eventId == renderedEvent.event.eventId } + }.map { ProcessedEvent(REMOVE, it.event) } + + return removedEventsDiff + processedEvents + } + + private fun shouldIgnoreMessageEventInRoom(currentRoomId: String?, roomId: String?): Boolean { + return currentRoomId != null && roomId == currentRoomId + } +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt index 63c296f418..d2db73af3d 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt @@ -15,7 +15,6 @@ */ package im.vector.app.features.notifications -import androidx.core.app.NotificationCompat import im.vector.app.BuildConfig import im.vector.app.R import im.vector.app.core.resources.StringProvider @@ -54,21 +53,19 @@ class NotifiableEventResolver @Inject constructor( // private val eventDisplay = RiotEventDisplay(context) - fun resolveEvent(event: Event/*, roomState: RoomState?, bingRule: PushRule?*/, session: Session): NotifiableEvent? { + fun resolveEvent(event: Event/*, roomState: RoomState?, bingRule: PushRule?*/, session: Session, isNoisy: Boolean): NotifiableEvent? { val roomID = event.roomId ?: return null val eventId = event.eventId ?: return null if (event.getClearType() == EventType.STATE_ROOM_MEMBER) { - return resolveStateRoomEvent(event, session) + return resolveStateRoomEvent(event, session, canBeReplaced = false, isNoisy = isNoisy) } val timelineEvent = session.getRoom(roomID)?.getTimeLineEvent(eventId) ?: return null when (event.getClearType()) { EventType.MESSAGE -> { - return resolveMessageEvent(timelineEvent, session) + return resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy) } EventType.ENCRYPTED -> { - val messageEvent = resolveMessageEvent(timelineEvent, session) - messageEvent?.lockScreenVisibility = NotificationCompat.VISIBILITY_PRIVATE - return messageEvent + return resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy) } else -> { // If the event can be displayed, display it as is @@ -85,12 +82,14 @@ class NotifiableEventResolver @Inject constructor( description = bodyPreview, title = stringProvider.getString(R.string.notification_unknown_new_event), soundName = null, - type = event.type) + type = event.type, + canBeReplaced = false + ) } } } - fun resolveInMemoryEvent(session: Session, event: Event): NotifiableEvent? { + fun resolveInMemoryEvent(session: Session, event: Event, canBeReplaced: Boolean): NotifiableEvent? { if (event.getClearType() != EventType.MESSAGE) return null // Ignore message edition @@ -114,24 +113,14 @@ class NotifiableEventResolver @Inject constructor( avatarUrl = user.avatarUrl ) ) - - val notifiableEvent = resolveMessageEvent(timelineEvent, session) - - if (notifiableEvent == null) { - Timber.d("## Failed to resolve event") - // TODO - null - } else { - notifiableEvent.noisy = !notificationAction.soundName.isNullOrBlank() - notifiableEvent - } + resolveMessageEvent(timelineEvent, session, canBeReplaced = canBeReplaced, isNoisy = !notificationAction.soundName.isNullOrBlank()) } else { Timber.d("Matched push rule is set to not notify") null } } - private fun resolveMessageEvent(event: TimelineEvent, session: Session): NotifiableEvent? { + private fun resolveMessageEvent(event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent { // The event only contains an eventId, and roomId (type is m.room.*) , we need to get the displayable content (names, avatar, text, etc...) val room = session.getRoom(event.root.roomId!! /*roomID cannot be null*/) @@ -142,19 +131,19 @@ class NotifiableEventResolver @Inject constructor( val roomName = stringProvider.getString(R.string.notification_unknown_room_name) val senderDisplayName = event.senderInfo.disambiguatedDisplayName - val notifiableEvent = NotifiableMessageEvent( + return NotifiableMessageEvent( eventId = event.root.eventId!!, editedEventId = event.getEditedEventId(), + canBeReplaced = canBeReplaced, timestamp = event.root.originServerTs ?: 0, - noisy = false, // will be updated + noisy = isNoisy, senderName = senderDisplayName, senderId = event.root.senderId, body = body.toString(), roomId = event.root.roomId!!, - roomName = roomName) - - notifiableEvent.matrixID = session.myUserId - return notifiableEvent + roomName = roomName, + matrixID = session.myUserId + ) } else { if (event.root.isEncrypted() && event.root.mxDecryptionResult == null) { // TODO use a global event decryptor? attache to session and that listen to new sessionId? @@ -175,57 +164,56 @@ class NotifiableEventResolver @Inject constructor( val roomName = room.roomSummary()?.displayName ?: "" val senderDisplayName = event.senderInfo.disambiguatedDisplayName - val notifiableEvent = NotifiableMessageEvent( + return NotifiableMessageEvent( eventId = event.root.eventId!!, editedEventId = event.getEditedEventId(), + canBeReplaced = canBeReplaced, timestamp = event.root.originServerTs ?: 0, - noisy = false, // will be updated + noisy = isNoisy, senderName = senderDisplayName, senderId = event.root.senderId, body = body, roomId = event.root.roomId!!, roomName = roomName, - roomIsDirect = room.roomSummary()?.isDirect ?: false) - - notifiableEvent.matrixID = session.myUserId - notifiableEvent.soundName = null - - // Get the avatars URL - notifiableEvent.roomAvatarPath = session.contentUrlResolver() - .resolveThumbnail(room.roomSummary()?.avatarUrl, - 250, - 250, - ContentUrlResolver.ThumbnailMethod.SCALE) - - notifiableEvent.senderAvatarPath = session.contentUrlResolver() - .resolveThumbnail(event.senderInfo.avatarUrl, - 250, - 250, - ContentUrlResolver.ThumbnailMethod.SCALE) - - return notifiableEvent + roomIsDirect = room.roomSummary()?.isDirect ?: false, + roomAvatarPath = session.contentUrlResolver() + .resolveThumbnail(room.roomSummary()?.avatarUrl, + 250, + 250, + ContentUrlResolver.ThumbnailMethod.SCALE), + senderAvatarPath = session.contentUrlResolver() + .resolveThumbnail(event.senderInfo.avatarUrl, + 250, + 250, + ContentUrlResolver.ThumbnailMethod.SCALE), + matrixID = session.myUserId, + soundName = null + ) } } - private fun resolveStateRoomEvent(event: Event, session: Session): NotifiableEvent? { + private fun resolveStateRoomEvent(event: Event, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent? { val content = event.content?.toModel() ?: return null val roomId = event.roomId ?: return null val dName = event.senderId?.let { session.getRoomMember(it, roomId)?.displayName } if (Membership.INVITE == content.membership) { - val body = noticeEventFormatter.format(event, dName, isDm = session.getRoomSummary(roomId)?.isDirect.orFalse()) + val roomSummary = session.getRoomSummary(roomId) + val body = noticeEventFormatter.format(event, dName, isDm = roomSummary?.isDirect.orFalse()) ?: stringProvider.getString(R.string.notification_new_invitation) return InviteNotifiableEvent( session.myUserId, eventId = event.eventId!!, editedEventId = null, + canBeReplaced = canBeReplaced, roomId = roomId, + roomName = roomSummary?.displayName, timestamp = event.originServerTs ?: 0, - noisy = false, // will be set later + noisy = isNoisy, title = stringProvider.getString(R.string.notification_new_invitation), description = body.toString(), soundName = null, // will be set later - type = event.getClearType(), - isPushGatewayEvent = false) + type = event.getClearType() + ) } else { Timber.e("## unsupported notifiable event for eventId [${event.eventId}]") if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt index fb9ca8d23c..161c9f74a6 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt @@ -15,43 +15,31 @@ */ package im.vector.app.features.notifications -import androidx.core.app.NotificationCompat import org.matrix.android.sdk.api.session.events.model.EventType data class NotifiableMessageEvent( override val eventId: String, override val editedEventId: String?, - override var noisy: Boolean, - override val timestamp: Long, - var senderName: String?, - var senderId: String?, - var body: String?, - var roomId: String, - var roomName: String?, - var roomIsDirect: Boolean = false + override val canBeReplaced: Boolean, + val noisy: Boolean, + val timestamp: Long, + val senderName: String?, + val senderId: String?, + val body: String?, + val roomId: String, + val roomName: String?, + val roomIsDirect: Boolean = false, + val roomAvatarPath: String? = null, + val senderAvatarPath: String? = null, + val matrixID: String? = null, + val soundName: String? = null, + // This is used for >N notification, as the result of a smart reply + val outGoingMessage: Boolean = false, + val outGoingMessageFailed: Boolean = false, + override val isRedacted: Boolean = false ) : NotifiableEvent { - override var matrixID: String? = null - override var soundName: String? = null - override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC - override var hasBeenDisplayed: Boolean = false - override var isRedacted: Boolean = false - - var roomAvatarPath: String? = null - var senderAvatarPath: String? = null - - override var isPushGatewayEvent: Boolean = false - - override val type: String - get() = EventType.MESSAGE - - override val description: String? - get() = body ?: "" - - override val title: String - get() = senderName ?: "" - - // This is used for >N notification, as the result of a smart reply - var outGoingMessage = false - var outGoingMessageFailed = false + val type: String = EventType.MESSAGE + val description: String = body ?: "" + val title: String = senderName ?: "" } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt index 6583db6f69..33e43cd7e4 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt @@ -130,19 +130,20 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { val notifiableMessageEvent = NotifiableMessageEvent( // Generate a Fake event id - UUID.randomUUID().toString(), - null, - false, - System.currentTimeMillis(), - session.getRoomMember(session.myUserId, room.roomId)?.displayName + eventId = UUID.randomUUID().toString(), + editedEventId = null, + noisy = false, + timestamp = System.currentTimeMillis(), + senderName = session.getRoomMember(session.myUserId, room.roomId)?.displayName ?: context?.getString(R.string.notification_sender_me), - session.myUserId, - message, - room.roomId, - room.roomSummary()?.displayName ?: room.roomId, - room.roomSummary()?.isDirect == true + senderId = session.myUserId, + body = message, + roomId = room.roomId, + roomName = room.roomSummary()?.displayName ?: room.roomId, + roomIsDirect = room.roomSummary()?.isDirect == true, + outGoingMessage = true, + canBeReplaced = false ) - notifiableMessageEvent.outGoingMessage = true notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent) notificationDrawerManager.refreshNotificationDrawer() diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationDisplayer.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationDisplayer.kt new file mode 100644 index 0000000000..680ff32a52 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationDisplayer.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import android.app.Notification +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import timber.log.Timber +import javax.inject.Inject + +class NotificationDisplayer @Inject constructor(context: Context) { + + private val notificationManager = NotificationManagerCompat.from(context) + + fun showNotificationMessage(tag: String?, id: Int, notification: Notification) { + notificationManager.notify(tag, id, notification) + } + + fun cancelNotificationMessage(tag: String?, id: Int) { + notificationManager.cancel(tag, id) + } + + fun cancelAllNotifications() { + // Keep this try catch (reported by GA) + try { + notificationManager.cancelAll() + } catch (e: Exception) { + Timber.e(e, "## cancelAllNotifications() failed") + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt index e4b2ead93d..c052de650e 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt @@ -16,26 +16,15 @@ package im.vector.app.features.notifications import android.content.Context -import android.graphics.Bitmap -import android.os.Build import android.os.Handler import android.os.HandlerThread import androidx.annotation.WorkerThread -import androidx.core.app.NotificationCompat -import androidx.core.app.Person -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat import im.vector.app.ActiveSessionDataSource import im.vector.app.BuildConfig import im.vector.app.R -import im.vector.app.core.resources.StringProvider import im.vector.app.core.utils.FirstThrottler import im.vector.app.features.displayname.getBestName -import im.vector.app.features.home.room.detail.RoomDetailActivity -import im.vector.app.features.invite.AutoAcceptInvites import im.vector.app.features.settings.VectorPreferences -import me.gujun.android.span.span import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.api.util.toMatrixItem @@ -52,14 +41,11 @@ import javax.inject.Singleton */ @Singleton class NotificationDrawerManager @Inject constructor(private val context: Context, - private val notificationUtils: NotificationUtils, + private val notificationDisplayer: NotificationDisplayer, private val vectorPreferences: VectorPreferences, - private val stringProvider: StringProvider, private val activeSessionDataSource: ActiveSessionDataSource, - private val iconLoader: IconLoader, - private val bitmapLoader: BitmapLoader, - private val outdatedDetector: OutdatedEventDetector?, - private val autoAcceptInvites: AutoAcceptInvites) { + private val notifiableEventProcessor: NotifiableEventProcessor, + private val notificationRenderer: NotificationRenderer) { private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY) private var backgroundHandler: Handler @@ -69,13 +55,23 @@ class NotificationDrawerManager @Inject constructor(private val context: Context backgroundHandler = Handler(handlerThread.looper) } - // The first time the notification drawer is refreshed, we force re-render of all notifications - private var firstTime = true - - private val eventList = loadEventInfo() + /** + * The notifiable events to render + * this is our source of truth for notifications, any changes to this list will be rendered as notifications + * when events are removed the previously rendered notifications will be cancelled + * when adding or updating, the notifications will be notified + * + * Events are unique by their properties, we should be careful not to insert multiple events with the same event-id + */ + private val queuedEvents = loadEventInfo() + /** + * The last known rendered notifiable events + * we keep track of them in order to know which events have been removed from the eventList + * allowing us to cancel any notifications previous displayed by now removed events + */ + private var renderedEvents = emptyList>() private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size) - private var currentRoomId: String? = null // TODO Multi-session: this will have to be improved @@ -107,12 +103,12 @@ class NotificationDrawerManager @Inject constructor(private val context: Context if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { Timber.d("onNotifiableEventReceived(): $notifiableEvent") } else { - Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.isPushGatewayEvent}") + Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}") } - synchronized(eventList) { - val existing = eventList.firstOrNull { it.eventId == notifiableEvent.eventId } + synchronized(queuedEvents) { + val existing = queuedEvents.firstOrNull { it.eventId == notifiableEvent.eventId } if (existing != null) { - if (existing.isPushGatewayEvent) { + if (existing.canBeReplaced) { // Use the event coming from the event stream as it may contains more info than // the fcm one (like type/content/clear text) (e.g when an encrypted message from // FCM should be update with clear text after a sync) @@ -121,9 +117,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context // Use setOnlyAlertOnce to ensure update notification does not interfere with sound // from first notify invocation as outlined in: // https://developer.android.com/training/notify-user/build-notification#Updating - notifiableEvent.hasBeenDisplayed = false - eventList.remove(existing) - eventList.add(notifiableEvent) + queuedEvents.remove(existing) + queuedEvents.add(notifiableEvent) } else { // keep the existing one, do not replace } @@ -131,7 +126,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context // Check if this is an edit if (notifiableEvent.editedEventId != null) { // This is an edition - val eventBeforeEdition = eventList.firstOrNull { + val eventBeforeEdition = queuedEvents.firstOrNull { // Edition of an event it.eventId == notifiableEvent.editedEventId || // or edition of an edition @@ -140,9 +135,9 @@ class NotificationDrawerManager @Inject constructor(private val context: Context if (eventBeforeEdition != null) { // Replace the existing notification with the new content - eventList.remove(eventBeforeEdition) + queuedEvents.remove(eventBeforeEdition) - eventList.add(notifiableEvent) + queuedEvents.add(notifiableEvent) } else { // Ignore an edit of a not displayed event in the notification drawer } @@ -153,7 +148,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context Timber.d("onNotifiableEventReceived(): skipping event, already seen") } else { seenEventIds.put(notifiableEvent.eventId) - eventList.add(notifiableEvent) + queuedEvents.add(notifiableEvent) } } } @@ -161,10 +156,13 @@ class NotificationDrawerManager @Inject constructor(private val context: Context } fun onEventRedacted(eventId: String) { - synchronized(eventList) { - eventList.find { it.eventId == eventId }?.apply { - isRedacted = true - hasBeenDisplayed = false + synchronized(queuedEvents) { + queuedEvents.replace(eventId) { + when (it) { + is InviteNotifiableEvent -> it.copy(isRedacted = true) + is NotifiableMessageEvent -> it.copy(isRedacted = true) + is SimpleNotifiableEvent -> it.copy(isRedacted = true) + } } } } @@ -173,8 +171,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context * Clear all known events and refresh the notification drawer */ fun clearAllEvents() { - synchronized(eventList) { - eventList.clear() + synchronized(queuedEvents) { + queuedEvents.clear() } refreshNotificationDrawer() } @@ -183,14 +181,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context fun clearMessageEventOfRoom(roomId: String?) { Timber.v("clearMessageEventOfRoom $roomId") if (roomId != null) { - var shouldUpdate = false - synchronized(eventList) { - shouldUpdate = eventList.removeAll { e -> - e is NotifiableMessageEvent && e.roomId == roomId - } - } + val shouldUpdate = removeAll { it is NotifiableMessageEvent && it.roomId == roomId } if (shouldUpdate) { - notificationUtils.cancelNotificationMessage(roomId, ROOM_MESSAGES_NOTIFICATION_ID) refreshNotificationDrawer() } } @@ -202,7 +194,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context */ fun setCurrentRoom(roomId: String?) { var hasChanged: Boolean - synchronized(eventList) { + synchronized(queuedEvents) { hasChanged = roomId != currentRoomId currentRoomId = roomId } @@ -212,12 +204,16 @@ class NotificationDrawerManager @Inject constructor(private val context: Context } fun clearMemberShipNotificationForRoom(roomId: String) { - synchronized(eventList) { - eventList.removeAll { e -> - e is InviteNotifiableEvent && e.roomId == roomId - } + val shouldUpdate = removeAll { it is InviteNotifiableEvent && it.roomId == roomId } + if (shouldUpdate) { + refreshNotificationDrawerBg() + } + } + + private fun removeAll(predicate: (NotifiableEvent) -> Boolean): Boolean { + return synchronized(queuedEvents) { + queuedEvents.removeAll(predicate) } - notificationUtils.cancelNotificationMessage(roomId, ROOM_INVITATION_NOTIFICATION_ID) } private var firstThrottler = FirstThrottler(200) @@ -244,359 +240,36 @@ class NotificationDrawerManager @Inject constructor(private val context: Context private fun refreshNotificationDrawerBg() { Timber.v("refreshNotificationDrawerBg()") - val session = currentSession ?: return - - val user = session.getUser(session.myUserId) - // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash - val myUserDisplayName = user?.toMatrixItem()?.getBestName() ?: session.myUserId - val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail(user?.avatarUrl, avatarSize, avatarSize, ContentUrlResolver.ThumbnailMethod.SCALE) - synchronized(eventList) { - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER ") - // TMP code - var hasNewEvent = false - var summaryIsNoisy = false - val summaryInboxStyle = NotificationCompat.InboxStyle() - - // group events by room to create a single MessagingStyle notif - val roomIdToEventMap: MutableMap> = LinkedHashMap() - val simpleEvents: MutableList = ArrayList() - val invitationEvents: MutableList = ArrayList() - - val eventIterator = eventList.listIterator() - while (eventIterator.hasNext()) { - when (val event = eventIterator.next()) { - is NotifiableMessageEvent -> { - val roomId = event.roomId - val roomEvents = roomIdToEventMap.getOrPut(roomId) { ArrayList() } - - if (shouldIgnoreMessageEventInRoom(roomId) || outdatedDetector?.isMessageOutdated(event) == true) { - // forget this event - eventIterator.remove() - } else { - roomEvents.add(event) - } - } - is InviteNotifiableEvent -> { - if (autoAcceptInvites.hideInvites) { - // Forget this event - eventIterator.remove() - } else { - invitationEvents.add(event) - } - } - is SimpleNotifiableEvent -> simpleEvents.add(event) - else -> Timber.w("Type not handled") - } - } - - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER ${roomIdToEventMap.size} room groups") - - var globalLastMessageTimestamp = 0L - - val newSettings = vectorPreferences.useCompleteNotificationFormat() - if (newSettings != useCompleteNotificationFormat) { - // Settings has changed, remove all current notifications - notificationUtils.cancelAllNotifications() - useCompleteNotificationFormat = newSettings - } - - var simpleNotificationRoomCounter = 0 - var simpleNotificationMessageCounter = 0 - - // events have been grouped by roomId - for ((roomId, events) in roomIdToEventMap) { - // Build the notification for the room - if (events.isEmpty() || events.all { it.isRedacted }) { - // Just clear this notification - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER $roomId has no more events") - notificationUtils.cancelNotificationMessage(roomId, ROOM_MESSAGES_NOTIFICATION_ID) - continue - } - - simpleNotificationRoomCounter++ - val roomName = events[0].roomName ?: events[0].senderName ?: "" - - val roomEventGroupInfo = RoomEventGroupInfo( - roomId = roomId, - isDirect = events[0].roomIsDirect, - roomDisplayName = roomName) - - val style = NotificationCompat.MessagingStyle(Person.Builder() - .setName(myUserDisplayName) - .setIcon(iconLoader.getUserIcon(myUserAvatarUrl)) - .setKey(events[0].matrixID) - .build()) - - style.isGroupConversation = !roomEventGroupInfo.isDirect - - if (!roomEventGroupInfo.isDirect) { - style.conversationTitle = roomEventGroupInfo.roomDisplayName - } - - val largeBitmap = getRoomBitmap(events) - - for (event in events) { - // if all events in this room have already been displayed there is no need to update it - if (!event.hasBeenDisplayed && !event.isRedacted) { - roomEventGroupInfo.shouldBing = roomEventGroupInfo.shouldBing || event.noisy - roomEventGroupInfo.customSound = event.soundName - } - roomEventGroupInfo.hasNewEvent = roomEventGroupInfo.hasNewEvent || !event.hasBeenDisplayed - - val senderPerson = if (event.outGoingMessage) { - null - } else { - Person.Builder() - .setName(event.senderName) - .setIcon(iconLoader.getUserIcon(event.senderAvatarPath)) - .setKey(event.senderId) - .build() - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val openRoomIntent = RoomDetailActivity.shortcutIntent(context, roomId) - - val shortcut = ShortcutInfoCompat.Builder(context, roomId) - .setLongLived(true) - .setIntent(openRoomIntent) - .setShortLabel(roomName) - .setIcon(largeBitmap?.let { IconCompat.createWithAdaptiveBitmap(it) } ?: iconLoader.getUserIcon(event.senderAvatarPath)) - .build() - - ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) - } - - if (event.outGoingMessage && event.outGoingMessageFailed) { - style.addMessage(stringProvider.getString(R.string.notification_inline_reply_failed), event.timestamp, senderPerson) - roomEventGroupInfo.hasSmartReplyError = true - } else { - if (!event.isRedacted) { - simpleNotificationMessageCounter++ - style.addMessage(event.body, event.timestamp, senderPerson) - } - } - event.hasBeenDisplayed = true // we can consider it as displayed - - // It is possible that this event was previously shown as an 'anonymous' simple notif. - // And now it will be merged in a single MessageStyle notif, so we can clean to be sure - notificationUtils.cancelNotificationMessage(event.eventId, ROOM_EVENT_NOTIFICATION_ID) - } - - try { - if (events.size == 1) { - val event = events[0] - if (roomEventGroupInfo.isDirect) { - val line = span { - span { - textStyle = "bold" - +String.format("%s: ", event.senderName) - } - +(event.description ?: "") - } - summaryInboxStyle.addLine(line) - } else { - val line = span { - span { - textStyle = "bold" - +String.format("%s: %s ", roomName, event.senderName) - } - +(event.description ?: "") - } - summaryInboxStyle.addLine(line) - } - } else { - val summaryLine = stringProvider.getQuantityString( - R.plurals.notification_compat_summary_line_for_room, events.size, roomName, events.size) - summaryInboxStyle.addLine(summaryLine) - } - } catch (e: Throwable) { - // String not found or bad format - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER failed to resolve string") - summaryInboxStyle.addLine(roomName) - } - - if (firstTime || roomEventGroupInfo.hasNewEvent) { - // Should update displayed notification - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER $roomId need refresh") - val lastMessageTimestamp = events.last().timestamp - - if (globalLastMessageTimestamp < lastMessageTimestamp) { - globalLastMessageTimestamp = lastMessageTimestamp - } - - val tickerText = if (roomEventGroupInfo.isDirect) { - stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderName, events.last().description) - } else { - stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description) - } - - if (useCompleteNotificationFormat) { - val notification = notificationUtils.buildMessagesListNotification( - style, - roomEventGroupInfo, - largeBitmap, - lastMessageTimestamp, - myUserDisplayName, - tickerText) - - // is there an id for this room? - notificationUtils.showNotificationMessage(roomId, ROOM_MESSAGES_NOTIFICATION_ID, notification) - } - - hasNewEvent = true - summaryIsNoisy = summaryIsNoisy || roomEventGroupInfo.shouldBing - } else { - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER $roomId is up to date") - } - } - - // Handle invitation events - for (event in invitationEvents) { - // We build a invitation notification - if (firstTime || !event.hasBeenDisplayed) { - if (useCompleteNotificationFormat) { - val notification = notificationUtils.buildRoomInvitationNotification(event, session.myUserId) - notificationUtils.showNotificationMessage(event.roomId, ROOM_INVITATION_NOTIFICATION_ID, notification) - } - event.hasBeenDisplayed = true // we can consider it as displayed - hasNewEvent = true - summaryIsNoisy = summaryIsNoisy || event.noisy - summaryInboxStyle.addLine(event.description) - } - } - - // Handle simple events - for (event in simpleEvents) { - // We build a simple notification - if (firstTime || !event.hasBeenDisplayed) { - if (useCompleteNotificationFormat) { - val notification = notificationUtils.buildSimpleEventNotification(event, session.myUserId) - notificationUtils.showNotificationMessage(event.eventId, ROOM_EVENT_NOTIFICATION_ID, notification) - } - event.hasBeenDisplayed = true // we can consider it as displayed - hasNewEvent = true - summaryIsNoisy = summaryIsNoisy || event.noisy - summaryInboxStyle.addLine(event.description) - } - } - - // ======== Build summary notification ========= - // On Android 7.0 (API level 24) and higher, the system automatically builds a summary for - // your group using snippets of text from each notification. The user can expand this - // notification to see each separate notification. - // To support older versions, which cannot show a nested group of notifications, - // you must create an extra notification that acts as the summary. - // This appears as the only notification and the system hides all the others. - // So this summary should include a snippet from all the other notifications, - // which the user can tap to open your app. - // The behavior of the group summary may vary on some device types such as wearables. - // To ensure the best experience on all devices and versions, always include a group summary when you create a group - // https://developer.android.com/training/notify-user/group - - if (eventList.isEmpty() || eventList.all { it.isRedacted }) { - notificationUtils.cancelNotificationMessage(null, SUMMARY_NOTIFICATION_ID) - } else if (hasNewEvent) { - // FIXME roomIdToEventMap.size is not correct, this is the number of rooms - val nbEvents = roomIdToEventMap.size + simpleEvents.size - val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents) - summaryInboxStyle.setBigContentTitle(sumTitle) - // TODO get latest event? - .setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents)) - - if (useCompleteNotificationFormat) { - val notification = notificationUtils.buildSummaryListNotification( - summaryInboxStyle, - sumTitle, - noisy = hasNewEvent && summaryIsNoisy, - lastMessageTimestamp = globalLastMessageTimestamp) - - notificationUtils.showNotificationMessage(null, SUMMARY_NOTIFICATION_ID, notification) - } else { - // Add the simple events as message (?) - simpleNotificationMessageCounter += simpleEvents.size - val numberOfInvitations = invitationEvents.size - - val privacyTitle = if (numberOfInvitations > 0) { - val invitationsStr = stringProvider.getQuantityString(R.plurals.notification_invitations, numberOfInvitations, numberOfInvitations) - if (simpleNotificationMessageCounter > 0) { - // Invitation and message - val messageStr = stringProvider.getQuantityString(R.plurals.room_new_messages_notification, - simpleNotificationMessageCounter, simpleNotificationMessageCounter) - if (simpleNotificationRoomCounter > 1) { - // In several rooms - val roomStr = stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages_in_room_rooms, - simpleNotificationRoomCounter, simpleNotificationRoomCounter) - stringProvider.getString( - R.string.notification_unread_notified_messages_in_room_and_invitation, - messageStr, - roomStr, - invitationsStr - ) - } else { - // In one room - stringProvider.getString( - R.string.notification_unread_notified_messages_and_invitation, - messageStr, - invitationsStr - ) - } - } else { - // Only invitation - invitationsStr - } - } else { - // No invitation, only messages - val messageStr = stringProvider.getQuantityString(R.plurals.room_new_messages_notification, - simpleNotificationMessageCounter, simpleNotificationMessageCounter) - if (simpleNotificationRoomCounter > 1) { - // In several rooms - val roomStr = stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages_in_room_rooms, - simpleNotificationRoomCounter, simpleNotificationRoomCounter) - stringProvider.getString(R.string.notification_unread_notified_messages_in_room, messageStr, roomStr) - } else { - // In one room - messageStr - } - } - val notification = notificationUtils.buildSummaryListNotification( - style = null, - compatSummary = privacyTitle, - noisy = hasNewEvent && summaryIsNoisy, - lastMessageTimestamp = globalLastMessageTimestamp) - - notificationUtils.showNotificationMessage(null, SUMMARY_NOTIFICATION_ID, notification) - } - - if (hasNewEvent && summaryIsNoisy) { - try { - // turn the screen on for 3 seconds - /* - TODO - if (Matrix.getInstance(VectorApp.getInstance())!!.pushManager.isScreenTurnedOn) { - val pm = VectorApp.getInstance().getSystemService()!! - val wl = pm.newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or PowerManager.ACQUIRE_CAUSES_WAKEUP, - NotificationDrawerManager::class.java.name) - wl.acquire(3000) - wl.release() - } - */ - } catch (e: Throwable) { - Timber.e(e, "## Failed to turn screen on") - } - } - } - // notice that we can get bit out of sync with actual display but not a big issue - firstTime = false + val newSettings = vectorPreferences.useCompleteNotificationFormat() + if (newSettings != useCompleteNotificationFormat) { + // Settings has changed, remove all current notifications + notificationDisplayer.cancelAllNotifications() + useCompleteNotificationFormat = newSettings } - } - private fun getRoomBitmap(events: List): Bitmap? { - if (events.isEmpty()) return null + val eventsToRender = synchronized(queuedEvents) { + notifiableEventProcessor.process(queuedEvents, currentRoomId, renderedEvents).also { + queuedEvents.clear() + queuedEvents.addAll(it.onlyKeptEvents()) + } + } - // Use the last event (most recent?) - val roomAvatarPath = events.last().roomAvatarPath ?: events.last().senderAvatarPath - - return bitmapLoader.getRoomBitmap(roomAvatarPath) + if (renderedEvents == eventsToRender) { + Timber.d("Skipping notification update due to event list not changing") + } else { + renderedEvents = eventsToRender + val session = currentSession ?: return + val user = session.getUser(session.myUserId) + // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash + val myUserDisplayName = user?.toMatrixItem()?.getBestName() ?: session.myUserId + val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail( + contentUrl = user?.avatarUrl, + width = avatarSize, + height = avatarSize, + method = ContentUrlResolver.ThumbnailMethod.SCALE + ) + notificationRenderer.render(session.myUserId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, eventsToRender) + } } fun shouldIgnoreMessageEventInRoom(roomId: String?): Boolean { @@ -604,8 +277,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context } fun persistInfo() { - synchronized(eventList) { - if (eventList.isEmpty()) { + synchronized(queuedEvents) { + if (queuedEvents.isEmpty()) { deleteCachedRoomNotifications() return } @@ -613,7 +286,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) if (!file.exists()) file.createNewFile() FileOutputStream(file).use { - currentSession?.securelyStoreObject(eventList, KEY_ALIAS_SECRET_STORAGE, it) + currentSession?.securelyStoreObject(queuedEvents, KEY_ALIAS_SECRET_STORAGE, it) } } catch (e: Throwable) { Timber.e(e, "## Failed to save cached notification info") @@ -645,15 +318,11 @@ class NotificationDrawerManager @Inject constructor(private val context: Context } } - fun displayDiagnosticNotification() { - notificationUtils.displayDiagnosticNotification() - } - companion object { - private const val SUMMARY_NOTIFICATION_ID = 0 - private const val ROOM_MESSAGES_NOTIFICATION_ID = 1 - private const val ROOM_EVENT_NOTIFICATION_ID = 2 - private const val ROOM_INVITATION_NOTIFICATION_ID = 3 + const val SUMMARY_NOTIFICATION_ID = 0 + const val ROOM_MESSAGES_NOTIFICATION_ID = 1 + const val ROOM_EVENT_NOTIFICATION_ID = 2 + const val ROOM_INVITATION_NOTIFICATION_ID = 3 // TODO Mutliaccount private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache" @@ -661,3 +330,11 @@ class NotificationDrawerManager @Inject constructor(private val context: Context private const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr" } } + +private fun MutableList.replace(eventId: String, block: (NotifiableEvent) -> NotifiableEvent) { + val indexToReplace = indexOfFirst { it.eventId == eventId } + if (indexToReplace == -1) { + return + } + set(indexToReplace, block(get(indexToReplace))) +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationFactory.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationFactory.kt new file mode 100644 index 0000000000..adc4e44bcc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationFactory.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import android.app.Notification +import androidx.core.content.pm.ShortcutInfoCompat +import javax.inject.Inject + +private typealias ProcessedMessageEvents = List> + +class NotificationFactory @Inject constructor( + private val notificationUtils: NotificationUtils, + private val roomGroupMessageCreator: RoomGroupMessageCreator, + private val summaryGroupMessageCreator: SummaryGroupMessageCreator +) { + + fun Map.toNotifications(myUserDisplayName: String, myUserAvatarUrl: String?): List { + return map { (roomId, events) -> + when { + events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId) + else -> { + val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted } + roomGroupMessageCreator.createRoomMessage(messageEvents, roomId, myUserDisplayName, myUserAvatarUrl) + } + } + } + } + + private fun ProcessedMessageEvents.hasNoEventsToDisplay() = isEmpty() || all { + it.type == ProcessedEvent.Type.REMOVE || it.event.canNotBeDisplayed() + } + + private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted + + @JvmName("toNotificationsInviteNotifiableEvent") + fun List>.toNotifications(myUserId: String): List { + return map { (processed, event) -> + when (processed) { + ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId) + ProcessedEvent.Type.KEEP -> OneShotNotification.Append( + notificationUtils.buildRoomInvitationNotification(event, myUserId), + OneShotNotification.Append.Meta( + key = event.roomId, + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + ) + } + } + } + + @JvmName("toNotificationsSimpleNotifiableEvent") + fun List>.toNotifications(myUserId: String): List { + return map { (processed, event) -> + when (processed) { + ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId) + ProcessedEvent.Type.KEEP -> OneShotNotification.Append( + notificationUtils.buildSimpleEventNotification(event, myUserId), + OneShotNotification.Append.Meta( + key = event.eventId, + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + ) + } + } + } + + fun createSummaryNotification(roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + useCompleteNotificationFormat: Boolean): SummaryNotification { + val roomMeta = roomNotifications.filterIsInstance().map { it.meta } + val invitationMeta = invitationNotifications.filterIsInstance().map { it.meta } + val simpleMeta = simpleNotifications.filterIsInstance().map { it.meta } + return when { + roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed + else -> SummaryNotification.Update( + summaryGroupMessageCreator.createSummaryNotification( + roomNotifications = roomMeta, + invitationNotifications = invitationMeta, + simpleNotifications = simpleMeta, + useCompleteNotificationFormat = useCompleteNotificationFormat + )) + } + } +} + +sealed interface RoomNotification { + data class Removed(val roomId: String) : RoomNotification + data class Message(val notification: Notification, val shortcutInfo: ShortcutInfoCompat?, val meta: Meta) : RoomNotification { + data class Meta( + val summaryLine: CharSequence, + val messageCount: Int, + val latestTimestamp: Long, + val roomId: String, + val shouldBing: Boolean + ) + } +} + +sealed interface OneShotNotification { + data class Removed(val key: String) : OneShotNotification + data class Append(val notification: Notification, val meta: Meta) : OneShotNotification { + data class Meta( + val key: String, + val summaryLine: CharSequence, + val isNoisy: Boolean, + val timestamp: Long, + ) + } +} + +sealed interface SummaryNotification { + object Removed : SummaryNotification + data class Update(val notification: Notification) : SummaryNotification +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationRenderer.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationRenderer.kt new file mode 100644 index 0000000000..5afff89402 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationRenderer.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.app.features.notifications + +import android.content.Context +import androidx.annotation.WorkerThread +import androidx.core.content.pm.ShortcutManagerCompat +import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_EVENT_NOTIFICATION_ID +import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_INVITATION_NOTIFICATION_ID +import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_MESSAGES_NOTIFICATION_ID +import im.vector.app.features.notifications.NotificationDrawerManager.Companion.SUMMARY_NOTIFICATION_ID +import timber.log.Timber +import javax.inject.Inject + +class NotificationRenderer @Inject constructor(private val notificationDisplayer: NotificationDisplayer, + private val notificationFactory: NotificationFactory, + private val appContext: Context) { + + @WorkerThread + fun render(myUserId: String, + myUserDisplayName: String, + myUserAvatarUrl: String?, + useCompleteNotificationFormat: Boolean, + eventsToProcess: List>) { + val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType() + with(notificationFactory) { + val roomNotifications = roomEvents.toNotifications(myUserDisplayName, myUserAvatarUrl) + val invitationNotifications = invitationEvents.toNotifications(myUserId) + val simpleNotifications = simpleEvents.toNotifications(myUserId) + val summaryNotification = createSummaryNotification( + roomNotifications = roomNotifications, + invitationNotifications = invitationNotifications, + simpleNotifications = simpleNotifications, + useCompleteNotificationFormat = useCompleteNotificationFormat + ) + + // Remove summary first to avoid briefly displaying it after dismissing the last notification + when (summaryNotification) { + SummaryNotification.Removed -> { + Timber.d("Removing summary notification") + notificationDisplayer.cancelNotificationMessage(null, SUMMARY_NOTIFICATION_ID) + } + } + + roomNotifications.forEach { wrapper -> + when (wrapper) { + is RoomNotification.Removed -> { + Timber.d("Removing room messages notification ${wrapper.roomId}") + notificationDisplayer.cancelNotificationMessage(wrapper.roomId, ROOM_MESSAGES_NOTIFICATION_ID) + } + is RoomNotification.Message -> if (useCompleteNotificationFormat) { + Timber.d("Updating room messages notification ${wrapper.meta.roomId}") + wrapper.shortcutInfo?.let { + ShortcutManagerCompat.pushDynamicShortcut(appContext, it) + } + notificationDisplayer.showNotificationMessage(wrapper.meta.roomId, ROOM_MESSAGES_NOTIFICATION_ID, wrapper.notification) + } + } + } + + invitationNotifications.forEach { wrapper -> + when (wrapper) { + is OneShotNotification.Removed -> { + Timber.d("Removing invitation notification ${wrapper.key}") + notificationDisplayer.cancelNotificationMessage(wrapper.key, ROOM_INVITATION_NOTIFICATION_ID) + } + is OneShotNotification.Append -> if (useCompleteNotificationFormat) { + Timber.d("Updating invitation notification ${wrapper.meta.key}") + notificationDisplayer.showNotificationMessage(wrapper.meta.key, ROOM_INVITATION_NOTIFICATION_ID, wrapper.notification) + } + } + } + + simpleNotifications.forEach { wrapper -> + when (wrapper) { + is OneShotNotification.Removed -> { + Timber.d("Removing simple notification ${wrapper.key}") + notificationDisplayer.cancelNotificationMessage(wrapper.key, ROOM_EVENT_NOTIFICATION_ID) + } + is OneShotNotification.Append -> if (useCompleteNotificationFormat) { + Timber.d("Updating simple notification ${wrapper.meta.key}") + notificationDisplayer.showNotificationMessage(wrapper.meta.key, ROOM_EVENT_NOTIFICATION_ID, wrapper.notification) + } + } + } + + // Update summary last to avoid briefly displaying it before other notifications + when (summaryNotification) { + is SummaryNotification.Update -> { + Timber.d("Updating summary notification") + notificationDisplayer.showNotificationMessage(null, SUMMARY_NOTIFICATION_ID, summaryNotification.notification) + } + } + } + } +} + +private fun List>.groupByType(): GroupedNotificationEvents { + val roomIdToEventMap: MutableMap>> = LinkedHashMap() + val simpleEvents: MutableList> = ArrayList() + val invitationEvents: MutableList> = ArrayList() + forEach { + when (val event = it.event) { + is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType()) + is NotifiableMessageEvent -> { + val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() } + roomEvents.add(it.castedToEventType()) + } + is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType()) + } + } + return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents) +} + +@Suppress("UNCHECKED_CAST") +private fun ProcessedEvent.castedToEventType(): ProcessedEvent = this as ProcessedEvent + +data class GroupedNotificationEvents( + val roomEvents: Map>>, + val simpleEvents: List>, + val invitationEvents: List> +) diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt index f3b34e1269..491302a225 100755 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt @@ -642,7 +642,7 @@ class NotificationUtils @Inject constructor(private val context: Context, return NotificationCompat.Builder(context, channelID) .setOnlyAlertOnce(true) - .setContentTitle(stringProvider.getString(R.string.app_name)) + .setContentTitle(inviteNotifiableEvent.roomName ?: stringProvider.getString(R.string.app_name)) .setContentText(inviteNotifiableEvent.description) .setGroup(stringProvider.getString(R.string.app_name)) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) diff --git a/vector/src/main/java/im/vector/app/features/notifications/ProcessedEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/ProcessedEvent.kt new file mode 100644 index 0000000000..8bd9819ca9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/ProcessedEvent.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +data class ProcessedEvent( + val type: Type, + val event: T +) { + + enum class Type { + KEEP, + REMOVE + } +} + +fun List>.onlyKeptEvents() = mapNotNull { processedEvent -> + processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP } +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/PushRuleTriggerListener.kt b/vector/src/main/java/im/vector/app/features/notifications/PushRuleTriggerListener.kt index 791803fa49..abbbd47f95 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/PushRuleTriggerListener.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/PushRuleTriggerListener.kt @@ -40,12 +40,11 @@ class PushRuleTriggerListener @Inject constructor( val notificationAction = actions.toNotificationAction() if (notificationAction.shouldNotify) { - val notifiableEvent = resolver.resolveEvent(event, safeSession) + val notifiableEvent = resolver.resolveEvent(event, safeSession, isNoisy = !notificationAction.soundName.isNullOrBlank()) if (notifiableEvent == null) { Timber.v("## Failed to resolve event") // TODO } else { - notifiableEvent.noisy = !notificationAction.soundName.isNullOrBlank() Timber.v("New event to notify") notificationDrawerManager.onNotifiableEventReceived(notifiableEvent) } diff --git a/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt b/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt new file mode 100644 index 0000000000..bdd7d026f9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.graphics.drawable.IconCompat +import im.vector.app.R +import im.vector.app.core.resources.StringProvider +import im.vector.app.features.home.room.detail.RoomDetailActivity +import me.gujun.android.span.Span +import me.gujun.android.span.span +import timber.log.Timber +import javax.inject.Inject + +class RoomGroupMessageCreator @Inject constructor( + private val iconLoader: IconLoader, + private val bitmapLoader: BitmapLoader, + private val stringProvider: StringProvider, + private val notificationUtils: NotificationUtils, + private val appContext: Context +) { + + fun createRoomMessage(events: List, roomId: String, userDisplayName: String, userAvatarUrl: String?): RoomNotification.Message { + val firstKnownRoomEvent = events[0] + val roomName = firstKnownRoomEvent.roomName ?: firstKnownRoomEvent.senderName ?: "" + val roomIsGroup = !firstKnownRoomEvent.roomIsDirect + val style = NotificationCompat.MessagingStyle(Person.Builder() + .setName(userDisplayName) + .setIcon(iconLoader.getUserIcon(userAvatarUrl)) + .setKey(firstKnownRoomEvent.matrixID) + .build() + ).also { + it.conversationTitle = roomName.takeIf { roomIsGroup } + it.isGroupConversation = roomIsGroup + it.addMessagesFromEvents(events) + } + + val tickerText = if (roomIsGroup) { + stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description) + } else { + stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderName, events.last().description) + } + + val largeBitmap = getRoomBitmap(events) + val shortcutInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val openRoomIntent = RoomDetailActivity.shortcutIntent(appContext, roomId) + ShortcutInfoCompat.Builder(appContext, roomId) + .setLongLived(true) + .setIntent(openRoomIntent) + .setShortLabel(roomName) + .setIcon(largeBitmap?.let { IconCompat.createWithAdaptiveBitmap(it) } ?: iconLoader.getUserIcon(events.last().senderAvatarPath)) + .build() + } else { + null + } + + val lastMessageTimestamp = events.last().timestamp + val smartReplyErrors = events.filter { it.isSmartReplyError() } + val messageCount = (events.size - smartReplyErrors.size) + val meta = RoomNotification.Message.Meta( + summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup), + messageCount = messageCount, + latestTimestamp = lastMessageTimestamp, + roomId = roomId, + shouldBing = events.any { it.noisy } + ) + return RoomNotification.Message( + notificationUtils.buildMessagesListNotification( + style, + RoomEventGroupInfo(roomId, roomName, isDirect = !roomIsGroup).also { + it.hasSmartReplyError = smartReplyErrors.isNotEmpty() + it.shouldBing = meta.shouldBing + it.customSound = events.last().soundName + }, + largeIcon = largeBitmap, + lastMessageTimestamp, + userDisplayName, + tickerText + ), + shortcutInfo, + meta + ) + } + + private fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List) { + events.forEach { event -> + val senderPerson = if (event.outGoingMessage) { + null + } else { + Person.Builder() + .setName(event.senderName) + .setIcon(iconLoader.getUserIcon(event.senderAvatarPath)) + .setKey(event.senderId) + .build() + } + when { + event.isSmartReplyError() -> addMessage(stringProvider.getString(R.string.notification_inline_reply_failed), event.timestamp, senderPerson) + else -> addMessage(event.body, event.timestamp, senderPerson) + } + } + } + + private fun createRoomMessagesGroupSummaryLine(events: List, roomName: String, roomIsDirect: Boolean): CharSequence { + return try { + when (events.size) { + 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect) + else -> { + stringProvider.getQuantityString( + R.plurals.notification_compat_summary_line_for_room, + events.size, + roomName, + events.size + ) + } + } + } catch (e: Throwable) { + // String not found or bad format + Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER failed to resolve string") + roomName + } + } + + private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): Span { + return if (roomIsDirect) { + span { + span { + textStyle = "bold" + +String.format("%s: ", event.senderName) + } + +(event.description) + } + } else { + span { + span { + textStyle = "bold" + +String.format("%s: %s ", roomName, event.senderName) + } + +(event.description) + } + } + } + + private fun getRoomBitmap(events: List): Bitmap? { + // Use the last event (most recent?) + return events.lastOrNull() + ?.roomAvatarPath + ?.let { bitmapLoader.getRoomBitmap(it) } + } +} + +private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed diff --git a/vector/src/main/java/im/vector/app/features/notifications/SimpleNotifiableEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/SimpleNotifiableEvent.kt index 2f74737ba2..8c72372204 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/SimpleNotifiableEvent.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/SimpleNotifiableEvent.kt @@ -15,21 +15,16 @@ */ package im.vector.app.features.notifications -import androidx.core.app.NotificationCompat - data class SimpleNotifiableEvent( - override var matrixID: String?, + val matrixID: String?, override val eventId: String, override val editedEventId: String?, - override var noisy: Boolean, - override val title: String, - override val description: String, - override val type: String?, - override val timestamp: Long, - override var soundName: String?, - override var isPushGatewayEvent: Boolean = false) : NotifiableEvent { - - override var hasBeenDisplayed: Boolean = false - override var isRedacted: Boolean = false - override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC -} + val noisy: Boolean, + val title: String, + val description: String, + val type: String?, + val timestamp: Long, + val soundName: String?, + override var canBeReplaced: Boolean, + override val isRedacted: Boolean = false +) : NotifiableEvent diff --git a/vector/src/main/java/im/vector/app/features/notifications/SummaryGroupMessageCreator.kt b/vector/src/main/java/im/vector/app/features/notifications/SummaryGroupMessageCreator.kt new file mode 100644 index 0000000000..91163434c2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/SummaryGroupMessageCreator.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import android.app.Notification +import androidx.core.app.NotificationCompat +import im.vector.app.R +import im.vector.app.core.resources.StringProvider +import javax.inject.Inject + +/** + * ======== Build summary notification ========= + * On Android 7.0 (API level 24) and higher, the system automatically builds a summary for + * your group using snippets of text from each notification. The user can expand this + * notification to see each separate notification. + * To support older versions, which cannot show a nested group of notifications, + * you must create an extra notification that acts as the summary. + * This appears as the only notification and the system hides all the others. + * So this summary should include a snippet from all the other notifications, + * which the user can tap to open your app. + * The behavior of the group summary may vary on some device types such as wearables. + * To ensure the best experience on all devices and versions, always include a group summary when you create a group + * https://developer.android.com/training/notify-user/group + */ +class SummaryGroupMessageCreator @Inject constructor( + private val stringProvider: StringProvider, + private val notificationUtils: NotificationUtils +) { + + fun createSummaryNotification(roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + useCompleteNotificationFormat: Boolean): Notification { + val summaryInboxStyle = NotificationCompat.InboxStyle().also { style -> + roomNotifications.forEach { style.addLine(it.summaryLine) } + invitationNotifications.forEach { style.addLine(it.summaryLine) } + simpleNotifications.forEach { style.addLine(it.summaryLine) } + } + + val summaryIsNoisy = roomNotifications.any { it.shouldBing } || + invitationNotifications.any { it.isNoisy } || + simpleNotifications.any { it.isNoisy } + + val messageCount = roomNotifications.fold(initial = 0) { acc, current -> acc + current.messageCount } + + val lastMessageTimestamp = roomNotifications.lastOrNull()?.latestTimestamp + ?: invitationNotifications.lastOrNull()?.timestamp + ?: simpleNotifications.last().timestamp + + // FIXME roomIdToEventMap.size is not correct, this is the number of rooms + val nbEvents = roomNotifications.size + simpleNotifications.size + val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents) + summaryInboxStyle.setBigContentTitle(sumTitle) + // TODO get latest event? + .setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents)) + return if (useCompleteNotificationFormat) { + notificationUtils.buildSummaryListNotification( + summaryInboxStyle, + sumTitle, + noisy = summaryIsNoisy, + lastMessageTimestamp = lastMessageTimestamp + ) + } else { + processSimpleGroupSummary( + summaryIsNoisy, + messageCount, + simpleNotifications.size, + invitationNotifications.size, + roomNotifications.size, + lastMessageTimestamp + ) + } + } + + private fun processSimpleGroupSummary(summaryIsNoisy: Boolean, + messageEventsCount: Int, + simpleEventsCount: Int, + invitationEventsCount: Int, + roomCount: Int, + lastMessageTimestamp: Long): Notification { + // Add the simple events as message (?) + val messageNotificationCount = messageEventsCount + simpleEventsCount + + val privacyTitle = if (invitationEventsCount > 0) { + val invitationsStr = stringProvider.getQuantityString(R.plurals.notification_invitations, invitationEventsCount, invitationEventsCount) + if (messageNotificationCount > 0) { + // Invitation and message + val messageStr = stringProvider.getQuantityString(R.plurals.room_new_messages_notification, + messageNotificationCount, messageNotificationCount) + if (roomCount > 1) { + // In several rooms + val roomStr = stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages_in_room_rooms, + roomCount, roomCount) + stringProvider.getString( + R.string.notification_unread_notified_messages_in_room_and_invitation, + messageStr, + roomStr, + invitationsStr + ) + } else { + // In one room + stringProvider.getString( + R.string.notification_unread_notified_messages_and_invitation, + messageStr, + invitationsStr + ) + } + } else { + // Only invitation + invitationsStr + } + } else { + // No invitation, only messages + val messageStr = stringProvider.getQuantityString(R.plurals.room_new_messages_notification, + messageNotificationCount, messageNotificationCount) + if (roomCount > 1) { + // In several rooms + val roomStr = stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages_in_room_rooms, roomCount, roomCount) + stringProvider.getString(R.string.notification_unread_notified_messages_in_room, messageStr, roomStr) + } else { + // In one room + messageStr + } + } + return notificationUtils.buildSummaryListNotification( + style = null, + compatSummary = privacyTitle, + noisy = summaryIsNoisy, + lastMessageTimestamp = lastMessageTimestamp + ) + } +} diff --git a/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt b/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt new file mode 100644 index 0000000000..229ab39d1d --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import im.vector.app.features.notifications.ProcessedEvent.Type +import im.vector.app.test.fakes.FakeAutoAcceptInvites +import im.vector.app.test.fakes.FakeOutdatedEventDetector +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.EventType + +private val NOT_VIEWING_A_ROOM: String? = null + +class NotifiableEventProcessorTest { + + private val outdatedDetector = FakeOutdatedEventDetector() + private val autoAcceptInvites = FakeAutoAcceptInvites() + + private val eventProcessor = NotifiableEventProcessor(outdatedDetector.instance, autoAcceptInvites) + + @Test + fun `given simple events when processing then keep simple events`() { + val events = listOf( + aSimpleNotifiableEvent(eventId = "event-1"), + aSimpleNotifiableEvent(eventId = "event-2") + ) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.KEEP to events[0], + Type.KEEP to events[1] + ) + } + + @Test + fun `given redacted simple event when processing then remove redaction event`() { + val events = listOf(aSimpleNotifiableEvent(eventId = "event-1", type = EventType.REDACTION)) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.REMOVE to events[0] + ) + } + + @Test + fun `given invites are auto accepted when processing then remove invitations`() { + autoAcceptInvites._isEnabled = true + val events = listOf( + anInviteNotifiableEvent(roomId = "room-1"), + anInviteNotifiableEvent(roomId = "room-2") + ) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.REMOVE to events[0], + Type.REMOVE to events[1] + ) + } + + @Test + fun `given invites are not auto accepted when processing then keep invitation events`() { + autoAcceptInvites._isEnabled = false + val events = listOf( + anInviteNotifiableEvent(roomId = "room-1"), + anInviteNotifiableEvent(roomId = "room-2") + ) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.KEEP to events[0], + Type.KEEP to events[1] + ) + } + + @Test + fun `given out of date message event when processing then removes message event`() { + val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1")) + outdatedDetector.givenEventIsOutOfDate(events[0]) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.REMOVE to events[0], + ) + } + + @Test + fun `given in date message event when processing then keep message event`() { + val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1")) + outdatedDetector.givenEventIsInDate(events[0]) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.KEEP to events[0], + ) + } + + @Test + fun `given viewing the same room as message event when processing then removes message`() { + val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1")) + + val result = eventProcessor.process(events, currentRoomId = "room-1", renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.REMOVE to events[0], + ) + } + + @Test + fun `given events are different to rendered events when processing then removes difference`() { + val events = listOf(aSimpleNotifiableEvent(eventId = "event-1")) + val renderedEvents = listOf>( + ProcessedEvent(Type.KEEP, events[0]), + ProcessedEvent(Type.KEEP, anInviteNotifiableEvent(roomId = "event-2")) + ) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = renderedEvents) + + result shouldBeEqualTo listOfProcessedEvents( + Type.REMOVE to renderedEvents[1].event, + Type.KEEP to renderedEvents[0].event + ) + } + + private fun listOfProcessedEvents(vararg event: Pair) = event.map { + ProcessedEvent(it.first, it.second) + } +} + +fun aSimpleNotifiableEvent(eventId: String, type: String? = null) = SimpleNotifiableEvent( + matrixID = null, + eventId = eventId, + editedEventId = null, + noisy = false, + title = "title", + description = "description", + type = type, + timestamp = 0, + soundName = null, + canBeReplaced = false, + isRedacted = false +) + +fun anInviteNotifiableEvent(roomId: String) = InviteNotifiableEvent( + matrixID = null, + eventId = "event-id", + roomId = roomId, + roomName = "a room name", + editedEventId = null, + noisy = false, + title = "title", + description = "description", + type = null, + timestamp = 0, + soundName = null, + canBeReplaced = false, + isRedacted = false +) + +fun aNotifiableMessageEvent(eventId: String, roomId: String) = NotifiableMessageEvent( + eventId = eventId, + editedEventId = null, + noisy = false, + timestamp = 0, + senderName = "sender-name", + senderId = "sending-id", + body = "message-body", + roomId = roomId, + roomName = "room-name", + roomIsDirect = false, + canBeReplaced = false, + isRedacted = false +) diff --git a/vector/src/test/java/im/vector/app/features/notifications/NotificationFactoryTest.kt b/vector/src/test/java/im/vector/app/features/notifications/NotificationFactoryTest.kt new file mode 100644 index 0000000000..d720881bac --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/notifications/NotificationFactoryTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import im.vector.app.features.notifications.ProcessedEvent.Type +import im.vector.app.test.fakes.FakeNotificationUtils +import im.vector.app.test.fakes.FakeRoomGroupMessageCreator +import im.vector.app.test.fakes.FakeSummaryGroupMessageCreator +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private const val MY_USER_ID = "user-id" +private const val A_ROOM_ID = "room-id" +private const val AN_EVENT_ID = "event-id" + +private val MY_AVATAR_URL: String? = null +private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID) +private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID) +private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) + +class NotificationFactoryTest { + + private val notificationUtils = FakeNotificationUtils() + private val roomGroupMessageCreator = FakeRoomGroupMessageCreator() + private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + + private val notificationFactory = NotificationFactory( + notificationUtils.instance, + roomGroupMessageCreator.instance, + summaryGroupMessageCreator.instance + ) + + @Test + fun `given a room invitation when mapping to notification then is Append`() = testWith(notificationFactory) { + val expectedNotification = notificationUtils.givenBuildRoomInvitationNotificationFor(AN_INVITATION_EVENT, MY_USER_ID) + val roomInvitation = listOf(ProcessedEvent(Type.KEEP, AN_INVITATION_EVENT)) + + val result = roomInvitation.toNotifications(MY_USER_ID) + + result shouldBeEqualTo listOf(OneShotNotification.Append( + notification = expectedNotification, + meta = OneShotNotification.Append.Meta( + key = A_ROOM_ID, + summaryLine = AN_INVITATION_EVENT.description, + isNoisy = AN_INVITATION_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + )) + ) + } + + @Test + fun `given a missing event in room invitation when mapping to notification then is Removed`() = testWith(notificationFactory) { + val missingEventRoomInvitation = listOf(ProcessedEvent(Type.REMOVE, AN_INVITATION_EVENT)) + + val result = missingEventRoomInvitation.toNotifications(MY_USER_ID) + + result shouldBeEqualTo listOf(OneShotNotification.Removed( + key = A_ROOM_ID + )) + } + + @Test + fun `given a simple event when mapping to notification then is Append`() = testWith(notificationFactory) { + val expectedNotification = notificationUtils.givenBuildSimpleInvitationNotificationFor(A_SIMPLE_EVENT, MY_USER_ID) + val roomInvitation = listOf(ProcessedEvent(Type.KEEP, A_SIMPLE_EVENT)) + + val result = roomInvitation.toNotifications(MY_USER_ID) + + result shouldBeEqualTo listOf(OneShotNotification.Append( + notification = expectedNotification, + meta = OneShotNotification.Append.Meta( + key = AN_EVENT_ID, + summaryLine = A_SIMPLE_EVENT.description, + isNoisy = A_SIMPLE_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + )) + ) + } + + @Test + fun `given a missing simple event when mapping to notification then is Removed`() = testWith(notificationFactory) { + val missingEventRoomInvitation = listOf(ProcessedEvent(Type.REMOVE, A_SIMPLE_EVENT)) + + val result = missingEventRoomInvitation.toNotifications(MY_USER_ID) + + result shouldBeEqualTo listOf(OneShotNotification.Removed( + key = AN_EVENT_ID + )) + } + + @Test + fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationFactory) { + val events = listOf(A_MESSAGE_EVENT) + val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor(events, A_ROOM_ID, MY_USER_ID, MY_AVATAR_URL) + val roomWithMessage = mapOf(A_ROOM_ID to listOf(ProcessedEvent(Type.KEEP, A_MESSAGE_EVENT))) + + val result = roomWithMessage.toNotifications(MY_USER_ID, MY_AVATAR_URL) + + result shouldBeEqualTo listOf(expectedNotification) + } + + @Test + fun `given a room with no events to display when mapping to notification then is Empty`() = testWith(notificationFactory) { + val events = listOf(ProcessedEvent(Type.REMOVE, A_MESSAGE_EVENT)) + val emptyRoom = mapOf(A_ROOM_ID to events) + + val result = emptyRoom.toNotifications(MY_USER_ID, MY_AVATAR_URL) + + result shouldBeEqualTo listOf(RoomNotification.Removed( + roomId = A_ROOM_ID + )) + } + + @Test + fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationFactory) { + val redactedRoom = mapOf(A_ROOM_ID to listOf(ProcessedEvent(Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)))) + + val result = redactedRoom.toNotifications(MY_USER_ID, MY_AVATAR_URL) + + result shouldBeEqualTo listOf(RoomNotification.Removed( + roomId = A_ROOM_ID + )) + } + + @Test + fun `given a room with redacted and non redacted message events when mapping to notification then redacted events are removed`() = testWith(notificationFactory) { + val roomWithRedactedMessage = mapOf(A_ROOM_ID to listOf( + ProcessedEvent(Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)), + ProcessedEvent(Type.KEEP, A_MESSAGE_EVENT.copy(eventId = "not-redacted")) + )) + val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = "not-redacted")) + val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor(withRedactedRemoved, A_ROOM_ID, MY_USER_ID, MY_AVATAR_URL) + + val result = roomWithRedactedMessage.toNotifications(MY_USER_ID, MY_AVATAR_URL) + + result shouldBeEqualTo listOf(expectedNotification) + } +} + +fun testWith(receiver: T, block: T.() -> Unit) { + receiver.block() +} diff --git a/vector/src/test/java/im/vector/app/features/notifications/NotificationRendererTest.kt b/vector/src/test/java/im/vector/app/features/notifications/NotificationRendererTest.kt new file mode 100644 index 0000000000..f726ff1b54 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/notifications/NotificationRendererTest.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import android.app.Notification +import im.vector.app.test.fakes.FakeContext +import im.vector.app.test.fakes.FakeNotificationDisplayer +import im.vector.app.test.fakes.FakeNotificationFactory +import io.mockk.mockk +import org.junit.Test + +private const val MY_USER_ID = "my-user-id" +private const val MY_USER_DISPLAY_NAME = "display-name" +private const val MY_USER_AVATAR_URL = "avatar-url" +private const val AN_EVENT_ID = "event-id" +private const val A_ROOM_ID = "room-id" +private const val USE_COMPLETE_NOTIFICATION_FORMAT = true + +private val AN_EVENT_LIST = listOf>() +private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList()) +private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk()) +private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed +private val A_NOTIFICATION = mockk() +private val MESSAGE_META = RoomNotification.Message.Meta( + summaryLine = "ignored", messageCount = 1, latestTimestamp = -1, roomId = A_ROOM_ID, shouldBing = false +) +private val ONE_SHOT_META = OneShotNotification.Append.Meta(key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) + +class NotificationRendererTest { + + private val context = FakeContext() + private val notificationDisplayer = FakeNotificationDisplayer() + private val notificationFactory = FakeNotificationFactory() + + private val notificationRenderer = NotificationRenderer( + notificationDisplayer = notificationDisplayer.instance, + notificationFactory = notificationFactory.instance, + appContext = context.instance + ) + + @Test + fun `given no notifications when rendering then cancels summary notification`() { + givenNoNotifications() + + renderEventsAsNotifications() + + notificationDisplayer.verifySummaryCancelled() + notificationDisplayer.verifyNoOtherInteractions() + } + + @Test + fun `given last room message group notification is removed when rendering then remove the summary and then remove message notification`() { + givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID) + cancelNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_MESSAGES_NOTIFICATION_ID) + } + } + + @Test + fun `given a room message group notification is removed when rendering then remove the message notification and update summary`() { + givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_MESSAGES_NOTIFICATION_ID) + showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given a room message group notification is added when rendering then show the message notification and update summary`() { + givenNotifications(roomNotifications = listOf(RoomNotification.Message( + A_NOTIFICATION, + shortcutInfo = null, + MESSAGE_META + ))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + showNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_MESSAGES_NOTIFICATION_ID, A_NOTIFICATION) + showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given last simple notification is removed when rendering then remove the summary and then remove simple notification`() { + givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID) + cancelNotificationMessage(tag = AN_EVENT_ID, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID) + } + } + + @Test + fun `given a simple notification is removed when rendering then remove the simple notification and update summary`() { + givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = AN_EVENT_ID, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID) + showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given a simple notification is added when rendering then show the simple notification and update summary`() { + givenNotifications(simpleNotifications = listOf(OneShotNotification.Append( + A_NOTIFICATION, + ONE_SHOT_META.copy(key = AN_EVENT_ID) + ))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + showNotificationMessage(tag = AN_EVENT_ID, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID, A_NOTIFICATION) + showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given last invitation notification is removed when rendering then remove the summary and then remove invitation notification`() { + givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID) + cancelNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_INVITATION_NOTIFICATION_ID) + } + } + + @Test + fun `given an invitation notification is removed when rendering then remove the invitation notification and update summary`() { + givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_INVITATION_NOTIFICATION_ID) + showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() { + givenNotifications(simpleNotifications = listOf(OneShotNotification.Append( + A_NOTIFICATION, + ONE_SHOT_META.copy(key = A_ROOM_ID) + ))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + showNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID, A_NOTIFICATION) + showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION.notification) + } + } + + private fun renderEventsAsNotifications() { + notificationRenderer.render( + myUserId = MY_USER_ID, + myUserDisplayName = MY_USER_DISPLAY_NAME, + myUserAvatarUrl = MY_USER_AVATAR_URL, + useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT, + eventsToProcess = AN_EVENT_LIST + ) + } + + private fun givenNoNotifications() { + givenNotifications(emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION) + } + + private fun givenNotifications(roomNotifications: List = emptyList(), + invitationNotifications: List = emptyList(), + simpleNotifications: List = emptyList(), + useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT, + summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION) { + notificationFactory.givenNotificationsFor( + groupedEvents = A_PROCESSED_EVENTS, + myUserId = MY_USER_ID, + myUserDisplayName = MY_USER_DISPLAY_NAME, + myUserAvatarUrl = MY_USER_AVATAR_URL, + useCompleteNotificationFormat = useCompleteNotificationFormat, + roomNotifications = roomNotifications, + invitationNotifications = invitationNotifications, + simpleNotifications = simpleNotifications, + summaryNotification = summaryNotification + ) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeAutoAcceptInvites.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeAutoAcceptInvites.kt new file mode 100644 index 0000000000..778c2f113d --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeAutoAcceptInvites.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.invite.AutoAcceptInvites + +class FakeAutoAcceptInvites : AutoAcceptInvites { + + var _isEnabled: Boolean = false + + override val isEnabled: Boolean + get() = _isEnabled +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationDisplayer.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationDisplayer.kt new file mode 100644 index 0000000000..2856b0f49c --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationDisplayer.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.notifications.NotificationDisplayer +import im.vector.app.features.notifications.NotificationDrawerManager +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder + +class FakeNotificationDisplayer { + + val instance = mockk(relaxed = true) + + fun verifySummaryCancelled() { + verify { instance.cancelNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID) } + } + + fun verifyNoOtherInteractions() { + confirmVerified(instance) + } + + fun verifyInOrder(verifyBlock: NotificationDisplayer.() -> Unit) { + verifyOrder { verifyBlock(instance) } + verifyNoOtherInteractions() + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationFactory.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationFactory.kt new file mode 100644 index 0000000000..a6e7d1a078 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationFactory.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.notifications.GroupedNotificationEvents +import im.vector.app.features.notifications.NotificationFactory +import im.vector.app.features.notifications.OneShotNotification +import im.vector.app.features.notifications.RoomNotification +import im.vector.app.features.notifications.SummaryNotification +import io.mockk.every +import io.mockk.mockk + +class FakeNotificationFactory { + + val instance = mockk() + + fun givenNotificationsFor(groupedEvents: GroupedNotificationEvents, + myUserId: String, + myUserDisplayName: String, + myUserAvatarUrl: String?, + useCompleteNotificationFormat: Boolean, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + summaryNotification: SummaryNotification) { + with(instance) { + every { groupedEvents.roomEvents.toNotifications(myUserDisplayName, myUserAvatarUrl) } returns roomNotifications + every { groupedEvents.invitationEvents.toNotifications(myUserId) } returns invitationNotifications + every { groupedEvents.simpleEvents.toNotifications(myUserId) } returns simpleNotifications + + every { + createSummaryNotification( + roomNotifications, + invitationNotifications, + simpleNotifications, + useCompleteNotificationFormat + ) + } returns summaryNotification + } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationUtils.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationUtils.kt new file mode 100644 index 0000000000..39f2ad59ff --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationUtils.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import android.app.Notification +import im.vector.app.features.notifications.InviteNotifiableEvent +import im.vector.app.features.notifications.NotificationUtils +import im.vector.app.features.notifications.SimpleNotifiableEvent +import io.mockk.every +import io.mockk.mockk + +class FakeNotificationUtils { + + val instance = mockk() + + fun givenBuildRoomInvitationNotificationFor(event: InviteNotifiableEvent, myUserId: String): Notification { + val mockNotification = mockk() + every { instance.buildRoomInvitationNotification(event, myUserId) } returns mockNotification + return mockNotification + } + + fun givenBuildSimpleInvitationNotificationFor(event: SimpleNotifiableEvent, myUserId: String): Notification { + val mockNotification = mockk() + every { instance.buildSimpleEventNotification(event, myUserId) } returns mockNotification + return mockNotification + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeOutdatedEventDetector.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeOutdatedEventDetector.kt new file mode 100644 index 0000000000..0e1d617ca2 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeOutdatedEventDetector.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.notifications.NotifiableEvent +import im.vector.app.features.notifications.OutdatedEventDetector +import io.mockk.every +import io.mockk.mockk + +class FakeOutdatedEventDetector { + val instance = mockk() + + fun givenEventIsOutOfDate(notifiableEvent: NotifiableEvent) { + every { instance.isMessageOutdated(notifiableEvent) } returns true + } + + fun givenEventIsInDate(notifiableEvent: NotifiableEvent) { + every { instance.isMessageOutdated(notifiableEvent) } returns false + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeRoomGroupMessageCreator.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeRoomGroupMessageCreator.kt new file mode 100644 index 0000000000..c164b9a661 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeRoomGroupMessageCreator.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.notifications.NotifiableMessageEvent +import im.vector.app.features.notifications.RoomGroupMessageCreator +import im.vector.app.features.notifications.RoomNotification +import io.mockk.every +import io.mockk.mockk + +class FakeRoomGroupMessageCreator { + + val instance = mockk() + + fun givenCreatesRoomMessageFor(events: List, + roomId: String, + userDisplayName: String, + userAvatarUrl: String?): RoomNotification.Message { + val mockMessage = mockk() + every { instance.createRoomMessage(events, roomId, userDisplayName, userAvatarUrl) } returns mockMessage + return mockMessage + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSummaryGroupMessageCreator.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSummaryGroupMessageCreator.kt new file mode 100644 index 0000000000..eef77298a0 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSummaryGroupMessageCreator.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.notifications.SummaryGroupMessageCreator +import io.mockk.mockk + +class FakeSummaryGroupMessageCreator { + + val instance = mockk() +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt new file mode 100644 index 0000000000..eb8f9ac413 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.settings.VectorPreferences +import io.mockk.every +import io.mockk.mockk + +class FakeVectorPreferences { + + val instance = mockk() + + fun givenUseCompleteNotificationFormat(value: Boolean) { + every { instance.useCompleteNotificationFormat() } returns value + } +}