Merge pull request #4274 from vector-im/feature/adm/notification-redesign

Notifications redesign
This commit is contained in:
Adam Brown 2021-10-26 20:42:00 +01:00 committed by GitHub
commit fe9dde5a14
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1778 additions and 651 deletions

1
changelog.d/1491.bugfix Normal file
View File

@ -0,0 +1 @@
Stops showing a dedicated redacted event notification, the message notifications will update accordingly

1
changelog.d/3395.bugfix Normal file
View File

@ -0,0 +1 @@
Fixes marking individual notifications as read causing other notifications to be dismissed

1
changelog.d/4152.bugfix Normal file
View File

@ -0,0 +1 @@
Tentatively fixing the doubled notifications by updating the group summary at specific points in the notification rendering cycle

1
changelog.d/582.feature Normal file
View File

@ -0,0 +1 @@
Adding the room name to the invitation notification (if the room summary is available)

View File

@ -22,6 +22,8 @@ import org.json.JSONObject
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.MatrixError
import org.matrix.android.sdk.api.session.crypto.MXCryptoError 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.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
@ -310,3 +312,6 @@ fun Event.isEdition(): Boolean {
fun Event.getPresenceContent(): PresenceContent? { fun Event.getPresenceContent(): PresenceContent? {
return content.toModel<PresenceContent>() return content.toModel<PresenceContent>()
} }
fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER &&
content?.toModel<RoomMemberContent>()?.membership == Membership.INVITE

View File

@ -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.pushrules.rest.PushRule
import org.matrix.android.sdk.api.session.events.model.EventType 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.api.session.sync.model.RoomsSyncResponse
import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.task.Task
@ -48,14 +49,18 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
} }
val newJoinEvents = params.syncResponse.join val newJoinEvents = params.syncResponse.join
.mapNotNull { (key, value) -> .mapNotNull { (key, value) ->
value.timeline?.events?.map { it.copy(roomId = key) } value.timeline?.events?.mapNotNull {
it.takeIf { !it.isInvitation() }?.copy(roomId = key)
}
} }
.flatten() .flatten()
val inviteEvents = params.syncResponse.invite val inviteEvents = params.syncResponse.invite
.mapNotNull { (key, value) -> .mapNotNull { (key, value) ->
value.inviteState?.events?.map { it.copy(roomId = key) } value.inviteState?.events?.map { it.copy(roomId = key) }
} }
.flatten() .flatten()
val allEvents = (newJoinEvents + inviteEvents).filter { event -> val allEvents = (newJoinEvents + inviteEvents).filter { event ->
when (event.type) { when (event.type) {
EventType.MESSAGE, EventType.MESSAGE,

View File

@ -160,7 +160,7 @@ Formatter\.formatShortFileSize===1
# android\.text\.TextUtils # 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 ### 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 ### Do not import temporary legacy classes
import org.matrix.android.sdk.internal.legacy.riot===3 import org.matrix.android.sdk.internal.legacy.riot===3

View File

@ -29,16 +29,13 @@ import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.network.WifiDetector import im.vector.app.core.network.WifiDetector
import im.vector.app.core.pushers.PushersManager import im.vector.app.core.pushers.PushersManager
import im.vector.app.features.badge.BadgeProxy import im.vector.app.features.badge.BadgeProxy
import im.vector.app.features.notifications.NotifiableEventResolver 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.NotificationDrawerManager
import im.vector.app.features.notifications.NotificationUtils 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.VectorDataStore
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import im.vector.app.push.fcm.FcmHelper import im.vector.app.push.fcm.FcmHelper
@ -48,9 +45,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.logger.LoggerTag 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.Session
import org.matrix.android.sdk.api.session.events.model.Event
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -201,12 +196,11 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
Timber.tag(loggerTag.value).d("Fast lane: start request") Timber.tag(loggerTag.value).d("Fast lane: start request")
val event = tryOrNull { session.getEvent(roomId, eventId) } ?: return@launch val event = tryOrNull { session.getEvent(roomId, eventId) } ?: return@launch
val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event) val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event, canBeReplaced = true)
resolvedEvent resolvedEvent
?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") } ?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") }
?.let { ?.let {
it.isPushGatewayEvent = true
notificationDrawerManager.onNotifiableEventReceived(it) notificationDrawerManager.onNotifiableEventReceived(it)
notificationDrawerManager.refreshNotificationDrawer() notificationDrawerManager.refreshNotificationDrawer()
} }
@ -227,87 +221,4 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
} }
return false return false
} }
private fun handleNotificationWithoutSyncingMode(data: Map<String, String>, 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<String, String>, 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<String, String>?): 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()
)
}
} }

View File

@ -15,22 +15,18 @@
*/ */
package im.vector.app.features.notifications package im.vector.app.features.notifications
import androidx.core.app.NotificationCompat
data class InviteNotifiableEvent( data class InviteNotifiableEvent(
override var matrixID: String?, val matrixID: String?,
override val eventId: String, override val eventId: String,
override val editedEventId: String?, override val editedEventId: String?,
var roomId: String, override val canBeReplaced: Boolean,
override var noisy: Boolean, val roomId: String,
override val title: String, val roomName: String?,
override val description: String, val noisy: Boolean,
override val type: String?, val title: String,
override val timestamp: Long, val description: String,
override var soundName: String?, val type: String?,
override var isPushGatewayEvent: Boolean = false) : NotifiableEvent { val timestamp: Long,
val soundName: String?,
override var hasBeenDisplayed: Boolean = false override val isRedacted: Boolean = false
override var isRedacted: Boolean = false ) : NotifiableEvent
override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
}

View File

@ -20,24 +20,11 @@ import java.io.Serializable
/** /**
* Parent interface for all events which can be displayed as a Notification * Parent interface for all events which can be displayed as a Notification
*/ */
interface NotifiableEvent : Serializable { sealed interface NotifiableEvent : Serializable {
var matrixID: String?
val eventId: String val eventId: String
val editedEventId: 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 // Used to know if event should be replaced with the one coming from eventstream
var isPushGatewayEvent: Boolean val canBeReplaced: Boolean
val isRedacted: Boolean
} }

View File

@ -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<ProcessedEvent<NotifiableEvent>>
class NotifiableEventProcessor @Inject constructor(
private val outdatedDetector: OutdatedEventDetector,
private val autoAcceptInvites: AutoAcceptInvites
) {
fun process(queuedEvents: List<NotifiableEvent>, 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
}
}

View File

@ -15,7 +15,6 @@
*/ */
package im.vector.app.features.notifications package im.vector.app.features.notifications
import androidx.core.app.NotificationCompat
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
@ -54,21 +53,19 @@ class NotifiableEventResolver @Inject constructor(
// private val eventDisplay = RiotEventDisplay(context) // 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 roomID = event.roomId ?: return null
val eventId = event.eventId ?: return null val eventId = event.eventId ?: return null
if (event.getClearType() == EventType.STATE_ROOM_MEMBER) { 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 val timelineEvent = session.getRoom(roomID)?.getTimeLineEvent(eventId) ?: return null
when (event.getClearType()) { when (event.getClearType()) {
EventType.MESSAGE -> { EventType.MESSAGE -> {
return resolveMessageEvent(timelineEvent, session) return resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy)
} }
EventType.ENCRYPTED -> { EventType.ENCRYPTED -> {
val messageEvent = resolveMessageEvent(timelineEvent, session) return resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy)
messageEvent?.lockScreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
return messageEvent
} }
else -> { else -> {
// If the event can be displayed, display it as is // If the event can be displayed, display it as is
@ -85,12 +82,14 @@ class NotifiableEventResolver @Inject constructor(
description = bodyPreview, description = bodyPreview,
title = stringProvider.getString(R.string.notification_unknown_new_event), title = stringProvider.getString(R.string.notification_unknown_new_event),
soundName = null, 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 if (event.getClearType() != EventType.MESSAGE) return null
// Ignore message edition // Ignore message edition
@ -114,24 +113,14 @@ class NotifiableEventResolver @Inject constructor(
avatarUrl = user.avatarUrl avatarUrl = user.avatarUrl
) )
) )
resolveMessageEvent(timelineEvent, session, canBeReplaced = canBeReplaced, isNoisy = !notificationAction.soundName.isNullOrBlank())
val notifiableEvent = resolveMessageEvent(timelineEvent, session)
if (notifiableEvent == null) {
Timber.d("## Failed to resolve event")
// TODO
null
} else {
notifiableEvent.noisy = !notificationAction.soundName.isNullOrBlank()
notifiableEvent
}
} else { } else {
Timber.d("Matched push rule is set to not notify") Timber.d("Matched push rule is set to not notify")
null 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...) // 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*/) 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 roomName = stringProvider.getString(R.string.notification_unknown_room_name)
val senderDisplayName = event.senderInfo.disambiguatedDisplayName val senderDisplayName = event.senderInfo.disambiguatedDisplayName
val notifiableEvent = NotifiableMessageEvent( return NotifiableMessageEvent(
eventId = event.root.eventId!!, eventId = event.root.eventId!!,
editedEventId = event.getEditedEventId(), editedEventId = event.getEditedEventId(),
canBeReplaced = canBeReplaced,
timestamp = event.root.originServerTs ?: 0, timestamp = event.root.originServerTs ?: 0,
noisy = false, // will be updated noisy = isNoisy,
senderName = senderDisplayName, senderName = senderDisplayName,
senderId = event.root.senderId, senderId = event.root.senderId,
body = body.toString(), body = body.toString(),
roomId = event.root.roomId!!, roomId = event.root.roomId!!,
roomName = roomName) roomName = roomName,
matrixID = session.myUserId
notifiableEvent.matrixID = session.myUserId )
return notifiableEvent
} else { } else {
if (event.root.isEncrypted() && event.root.mxDecryptionResult == null) { if (event.root.isEncrypted() && event.root.mxDecryptionResult == null) {
// TODO use a global event decryptor? attache to session and that listen to new sessionId? // 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 roomName = room.roomSummary()?.displayName ?: ""
val senderDisplayName = event.senderInfo.disambiguatedDisplayName val senderDisplayName = event.senderInfo.disambiguatedDisplayName
val notifiableEvent = NotifiableMessageEvent( return NotifiableMessageEvent(
eventId = event.root.eventId!!, eventId = event.root.eventId!!,
editedEventId = event.getEditedEventId(), editedEventId = event.getEditedEventId(),
canBeReplaced = canBeReplaced,
timestamp = event.root.originServerTs ?: 0, timestamp = event.root.originServerTs ?: 0,
noisy = false, // will be updated noisy = isNoisy,
senderName = senderDisplayName, senderName = senderDisplayName,
senderId = event.root.senderId, senderId = event.root.senderId,
body = body, body = body,
roomId = event.root.roomId!!, roomId = event.root.roomId!!,
roomName = roomName, roomName = roomName,
roomIsDirect = room.roomSummary()?.isDirect ?: false) roomIsDirect = room.roomSummary()?.isDirect ?: false,
roomAvatarPath = session.contentUrlResolver()
notifiableEvent.matrixID = session.myUserId .resolveThumbnail(room.roomSummary()?.avatarUrl,
notifiableEvent.soundName = null 250,
250,
// Get the avatars URL ContentUrlResolver.ThumbnailMethod.SCALE),
notifiableEvent.roomAvatarPath = session.contentUrlResolver() senderAvatarPath = session.contentUrlResolver()
.resolveThumbnail(room.roomSummary()?.avatarUrl, .resolveThumbnail(event.senderInfo.avatarUrl,
250, 250,
250, 250,
ContentUrlResolver.ThumbnailMethod.SCALE) ContentUrlResolver.ThumbnailMethod.SCALE),
matrixID = session.myUserId,
notifiableEvent.senderAvatarPath = session.contentUrlResolver() soundName = null
.resolveThumbnail(event.senderInfo.avatarUrl, )
250,
250,
ContentUrlResolver.ThumbnailMethod.SCALE)
return notifiableEvent
} }
} }
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<RoomMemberContent>() ?: return null val content = event.content?.toModel<RoomMemberContent>() ?: return null
val roomId = event.roomId ?: return null val roomId = event.roomId ?: return null
val dName = event.senderId?.let { session.getRoomMember(it, roomId)?.displayName } val dName = event.senderId?.let { session.getRoomMember(it, roomId)?.displayName }
if (Membership.INVITE == content.membership) { 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) ?: stringProvider.getString(R.string.notification_new_invitation)
return InviteNotifiableEvent( return InviteNotifiableEvent(
session.myUserId, session.myUserId,
eventId = event.eventId!!, eventId = event.eventId!!,
editedEventId = null, editedEventId = null,
canBeReplaced = canBeReplaced,
roomId = roomId, roomId = roomId,
roomName = roomSummary?.displayName,
timestamp = event.originServerTs ?: 0, timestamp = event.originServerTs ?: 0,
noisy = false, // will be set later noisy = isNoisy,
title = stringProvider.getString(R.string.notification_new_invitation), title = stringProvider.getString(R.string.notification_new_invitation),
description = body.toString(), description = body.toString(),
soundName = null, // will be set later soundName = null, // will be set later
type = event.getClearType(), type = event.getClearType()
isPushGatewayEvent = false) )
} else { } else {
Timber.e("## unsupported notifiable event for eventId [${event.eventId}]") Timber.e("## unsupported notifiable event for eventId [${event.eventId}]")
if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) {

View File

@ -15,43 +15,31 @@
*/ */
package im.vector.app.features.notifications package im.vector.app.features.notifications
import androidx.core.app.NotificationCompat
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
data class NotifiableMessageEvent( data class NotifiableMessageEvent(
override val eventId: String, override val eventId: String,
override val editedEventId: String?, override val editedEventId: String?,
override var noisy: Boolean, override val canBeReplaced: Boolean,
override val timestamp: Long, val noisy: Boolean,
var senderName: String?, val timestamp: Long,
var senderId: String?, val senderName: String?,
var body: String?, val senderId: String?,
var roomId: String, val body: String?,
var roomName: String?, val roomId: String,
var roomIsDirect: Boolean = false 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 { ) : NotifiableEvent {
override var matrixID: String? = null val type: String = EventType.MESSAGE
override var soundName: String? = null val description: String = body ?: ""
override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC val title: String = senderName ?: ""
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
} }

View File

@ -130,19 +130,20 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
val notifiableMessageEvent = NotifiableMessageEvent( val notifiableMessageEvent = NotifiableMessageEvent(
// Generate a Fake event id // Generate a Fake event id
UUID.randomUUID().toString(), eventId = UUID.randomUUID().toString(),
null, editedEventId = null,
false, noisy = false,
System.currentTimeMillis(), timestamp = System.currentTimeMillis(),
session.getRoomMember(session.myUserId, room.roomId)?.displayName senderName = session.getRoomMember(session.myUserId, room.roomId)?.displayName
?: context?.getString(R.string.notification_sender_me), ?: context?.getString(R.string.notification_sender_me),
session.myUserId, senderId = session.myUserId,
message, body = message,
room.roomId, roomId = room.roomId,
room.roomSummary()?.displayName ?: room.roomId, roomName = room.roomSummary()?.displayName ?: room.roomId,
room.roomSummary()?.isDirect == true roomIsDirect = room.roomSummary()?.isDirect == true,
outGoingMessage = true,
canBeReplaced = false
) )
notifiableMessageEvent.outGoingMessage = true
notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent) notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent)
notificationDrawerManager.refreshNotificationDrawer() notificationDrawerManager.refreshNotificationDrawer()

View File

@ -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")
}
}
}

View File

@ -16,26 +16,15 @@
package im.vector.app.features.notifications package im.vector.app.features.notifications
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.HandlerThread import android.os.HandlerThread
import androidx.annotation.WorkerThread 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.ActiveSessionDataSource
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.FirstThrottler import im.vector.app.core.utils.FirstThrottler
import im.vector.app.features.displayname.getBestName 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 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.Session
import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.api.session.content.ContentUrlResolver
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
@ -52,14 +41,11 @@ import javax.inject.Singleton
*/ */
@Singleton @Singleton
class NotificationDrawerManager @Inject constructor(private val context: Context, class NotificationDrawerManager @Inject constructor(private val context: Context,
private val notificationUtils: NotificationUtils, private val notificationDisplayer: NotificationDisplayer,
private val vectorPreferences: VectorPreferences, private val vectorPreferences: VectorPreferences,
private val stringProvider: StringProvider,
private val activeSessionDataSource: ActiveSessionDataSource, private val activeSessionDataSource: ActiveSessionDataSource,
private val iconLoader: IconLoader, private val notifiableEventProcessor: NotifiableEventProcessor,
private val bitmapLoader: BitmapLoader, private val notificationRenderer: NotificationRenderer) {
private val outdatedDetector: OutdatedEventDetector?,
private val autoAcceptInvites: AutoAcceptInvites) {
private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY) private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY)
private var backgroundHandler: Handler private var backgroundHandler: Handler
@ -69,13 +55,23 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
backgroundHandler = Handler(handlerThread.looper) backgroundHandler = Handler(handlerThread.looper)
} }
// The first time the notification drawer is refreshed, we force re-render of all notifications /**
private var firstTime = true * The notifiable events to render
* this is our source of truth for notifications, any changes to this list will be rendered as notifications
private val eventList = loadEventInfo() * 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<ProcessedEvent<NotifiableEvent>>()
private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size) private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
private var currentRoomId: String? = null private var currentRoomId: String? = null
// TODO Multi-session: this will have to be improved // 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) { if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) {
Timber.d("onNotifiableEventReceived(): $notifiableEvent") Timber.d("onNotifiableEventReceived(): $notifiableEvent")
} else { } else {
Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.isPushGatewayEvent}") Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
} }
synchronized(eventList) { synchronized(queuedEvents) {
val existing = eventList.firstOrNull { it.eventId == notifiableEvent.eventId } val existing = queuedEvents.firstOrNull { it.eventId == notifiableEvent.eventId }
if (existing != null) { if (existing != null) {
if (existing.isPushGatewayEvent) { if (existing.canBeReplaced) {
// Use the event coming from the event stream as it may contains more info than // 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 // 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) // 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 // Use setOnlyAlertOnce to ensure update notification does not interfere with sound
// from first notify invocation as outlined in: // from first notify invocation as outlined in:
// https://developer.android.com/training/notify-user/build-notification#Updating // https://developer.android.com/training/notify-user/build-notification#Updating
notifiableEvent.hasBeenDisplayed = false queuedEvents.remove(existing)
eventList.remove(existing) queuedEvents.add(notifiableEvent)
eventList.add(notifiableEvent)
} else { } else {
// keep the existing one, do not replace // 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 // Check if this is an edit
if (notifiableEvent.editedEventId != null) { if (notifiableEvent.editedEventId != null) {
// This is an edition // This is an edition
val eventBeforeEdition = eventList.firstOrNull { val eventBeforeEdition = queuedEvents.firstOrNull {
// Edition of an event // Edition of an event
it.eventId == notifiableEvent.editedEventId || it.eventId == notifiableEvent.editedEventId ||
// or edition of an edition // or edition of an edition
@ -140,9 +135,9 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
if (eventBeforeEdition != null) { if (eventBeforeEdition != null) {
// Replace the existing notification with the new content // Replace the existing notification with the new content
eventList.remove(eventBeforeEdition) queuedEvents.remove(eventBeforeEdition)
eventList.add(notifiableEvent) queuedEvents.add(notifiableEvent)
} else { } else {
// Ignore an edit of a not displayed event in the notification drawer // 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") Timber.d("onNotifiableEventReceived(): skipping event, already seen")
} else { } else {
seenEventIds.put(notifiableEvent.eventId) 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) { fun onEventRedacted(eventId: String) {
synchronized(eventList) { synchronized(queuedEvents) {
eventList.find { it.eventId == eventId }?.apply { queuedEvents.replace(eventId) {
isRedacted = true when (it) {
hasBeenDisplayed = false 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 * Clear all known events and refresh the notification drawer
*/ */
fun clearAllEvents() { fun clearAllEvents() {
synchronized(eventList) { synchronized(queuedEvents) {
eventList.clear() queuedEvents.clear()
} }
refreshNotificationDrawer() refreshNotificationDrawer()
} }
@ -183,14 +181,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
fun clearMessageEventOfRoom(roomId: String?) { fun clearMessageEventOfRoom(roomId: String?) {
Timber.v("clearMessageEventOfRoom $roomId") Timber.v("clearMessageEventOfRoom $roomId")
if (roomId != null) { if (roomId != null) {
var shouldUpdate = false val shouldUpdate = removeAll { it is NotifiableMessageEvent && it.roomId == roomId }
synchronized(eventList) {
shouldUpdate = eventList.removeAll { e ->
e is NotifiableMessageEvent && e.roomId == roomId
}
}
if (shouldUpdate) { if (shouldUpdate) {
notificationUtils.cancelNotificationMessage(roomId, ROOM_MESSAGES_NOTIFICATION_ID)
refreshNotificationDrawer() refreshNotificationDrawer()
} }
} }
@ -202,7 +194,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
*/ */
fun setCurrentRoom(roomId: String?) { fun setCurrentRoom(roomId: String?) {
var hasChanged: Boolean var hasChanged: Boolean
synchronized(eventList) { synchronized(queuedEvents) {
hasChanged = roomId != currentRoomId hasChanged = roomId != currentRoomId
currentRoomId = roomId currentRoomId = roomId
} }
@ -212,12 +204,16 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
} }
fun clearMemberShipNotificationForRoom(roomId: String) { fun clearMemberShipNotificationForRoom(roomId: String) {
synchronized(eventList) { val shouldUpdate = removeAll { it is InviteNotifiableEvent && it.roomId == roomId }
eventList.removeAll { e -> if (shouldUpdate) {
e is InviteNotifiableEvent && e.roomId == roomId 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) private var firstThrottler = FirstThrottler(200)
@ -244,359 +240,36 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
private fun refreshNotificationDrawerBg() { private fun refreshNotificationDrawerBg() {
Timber.v("refreshNotificationDrawerBg()") Timber.v("refreshNotificationDrawerBg()")
val session = currentSession ?: return val newSettings = vectorPreferences.useCompleteNotificationFormat()
if (newSettings != useCompleteNotificationFormat) {
val user = session.getUser(session.myUserId) // Settings has changed, remove all current notifications
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash notificationDisplayer.cancelAllNotifications()
val myUserDisplayName = user?.toMatrixItem()?.getBestName() ?: session.myUserId useCompleteNotificationFormat = newSettings
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<String, MutableList<NotifiableMessageEvent>> = LinkedHashMap()
val simpleEvents: MutableList<SimpleNotifiableEvent> = ArrayList()
val invitationEvents: MutableList<InviteNotifiableEvent> = 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<PowerManager>()!!
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
} }
}
private fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? { val eventsToRender = synchronized(queuedEvents) {
if (events.isEmpty()) return null notifiableEventProcessor.process(queuedEvents, currentRoomId, renderedEvents).also {
queuedEvents.clear()
queuedEvents.addAll(it.onlyKeptEvents())
}
}
// Use the last event (most recent?) if (renderedEvents == eventsToRender) {
val roomAvatarPath = events.last().roomAvatarPath ?: events.last().senderAvatarPath Timber.d("Skipping notification update due to event list not changing")
} else {
return bitmapLoader.getRoomBitmap(roomAvatarPath) 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 { fun shouldIgnoreMessageEventInRoom(roomId: String?): Boolean {
@ -604,8 +277,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
} }
fun persistInfo() { fun persistInfo() {
synchronized(eventList) { synchronized(queuedEvents) {
if (eventList.isEmpty()) { if (queuedEvents.isEmpty()) {
deleteCachedRoomNotifications() deleteCachedRoomNotifications()
return return
} }
@ -613,7 +286,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (!file.exists()) file.createNewFile() if (!file.exists()) file.createNewFile()
FileOutputStream(file).use { FileOutputStream(file).use {
currentSession?.securelyStoreObject(eventList, KEY_ALIAS_SECRET_STORAGE, it) currentSession?.securelyStoreObject(queuedEvents, KEY_ALIAS_SECRET_STORAGE, it)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Timber.e(e, "## Failed to save cached notification info") 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 { companion object {
private const val SUMMARY_NOTIFICATION_ID = 0 const val SUMMARY_NOTIFICATION_ID = 0
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1 const val ROOM_MESSAGES_NOTIFICATION_ID = 1
private const val ROOM_EVENT_NOTIFICATION_ID = 2 const val ROOM_EVENT_NOTIFICATION_ID = 2
private const val ROOM_INVITATION_NOTIFICATION_ID = 3 const val ROOM_INVITATION_NOTIFICATION_ID = 3
// TODO Mutliaccount // TODO Mutliaccount
private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache" 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 const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr"
} }
} }
private fun MutableList<NotifiableEvent>.replace(eventId: String, block: (NotifiableEvent) -> NotifiableEvent) {
val indexToReplace = indexOfFirst { it.eventId == eventId }
if (indexToReplace == -1) {
return
}
set(indexToReplace, block(get(indexToReplace)))
}

View File

@ -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<ProcessedEvent<NotifiableMessageEvent>>
class NotificationFactory @Inject constructor(
private val notificationUtils: NotificationUtils,
private val roomGroupMessageCreator: RoomGroupMessageCreator,
private val summaryGroupMessageCreator: SummaryGroupMessageCreator
) {
fun Map<String, ProcessedMessageEvents>.toNotifications(myUserDisplayName: String, myUserAvatarUrl: String?): List<RoomNotification> {
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<ProcessedEvent<InviteNotifiableEvent>>.toNotifications(myUserId: String): List<OneShotNotification> {
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<ProcessedEvent<SimpleNotifiableEvent>>.toNotifications(myUserId: String): List<OneShotNotification> {
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<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean): SummaryNotification {
val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta }
val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val simpleMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().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
}

View File

@ -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<ProcessedEvent<NotifiableEvent>>) {
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<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotificationEvents {
val roomIdToEventMap: MutableMap<String, MutableList<ProcessedEvent<NotifiableMessageEvent>>> = LinkedHashMap()
val simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList()
val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = 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 <T : NotifiableEvent> ProcessedEvent<NotifiableEvent>.castedToEventType(): ProcessedEvent<T> = this as ProcessedEvent<T>
data class GroupedNotificationEvents(
val roomEvents: Map<String, List<ProcessedEvent<NotifiableMessageEvent>>>,
val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>,
val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>
)

View File

@ -642,7 +642,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
return NotificationCompat.Builder(context, channelID) return NotificationCompat.Builder(context, channelID)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentTitle(stringProvider.getString(R.string.app_name)) .setContentTitle(inviteNotifiableEvent.roomName ?: stringProvider.getString(R.string.app_name))
.setContentText(inviteNotifiableEvent.description) .setContentText(inviteNotifiableEvent.description)
.setGroup(stringProvider.getString(R.string.app_name)) .setGroup(stringProvider.getString(R.string.app_name))
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)

View File

@ -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<T>(
val type: Type,
val event: T
) {
enum class Type {
KEEP,
REMOVE
}
}
fun <T> List<ProcessedEvent<T>>.onlyKeptEvents() = mapNotNull { processedEvent ->
processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP }
}

View File

@ -40,12 +40,11 @@ class PushRuleTriggerListener @Inject constructor(
val notificationAction = actions.toNotificationAction() val notificationAction = actions.toNotificationAction()
if (notificationAction.shouldNotify) { if (notificationAction.shouldNotify) {
val notifiableEvent = resolver.resolveEvent(event, safeSession) val notifiableEvent = resolver.resolveEvent(event, safeSession, isNoisy = !notificationAction.soundName.isNullOrBlank())
if (notifiableEvent == null) { if (notifiableEvent == null) {
Timber.v("## Failed to resolve event") Timber.v("## Failed to resolve event")
// TODO // TODO
} else { } else {
notifiableEvent.noisy = !notificationAction.soundName.isNullOrBlank()
Timber.v("New event to notify") Timber.v("New event to notify")
notificationDrawerManager.onNotifiableEventReceived(notifiableEvent) notificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
} }

View File

@ -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<NotifiableMessageEvent>, 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<NotifiableMessageEvent>) {
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<NotifiableMessageEvent>, 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<NotifiableMessageEvent>): Bitmap? {
// Use the last event (most recent?)
return events.lastOrNull()
?.roomAvatarPath
?.let { bitmapLoader.getRoomBitmap(it) }
}
}
private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed

View File

@ -15,21 +15,16 @@
*/ */
package im.vector.app.features.notifications package im.vector.app.features.notifications
import androidx.core.app.NotificationCompat
data class SimpleNotifiableEvent( data class SimpleNotifiableEvent(
override var matrixID: String?, val matrixID: String?,
override val eventId: String, override val eventId: String,
override val editedEventId: String?, override val editedEventId: String?,
override var noisy: Boolean, val noisy: Boolean,
override val title: String, val title: String,
override val description: String, val description: String,
override val type: String?, val type: String?,
override val timestamp: Long, val timestamp: Long,
override var soundName: String?, val soundName: String?,
override var isPushGatewayEvent: Boolean = false) : NotifiableEvent { override var canBeReplaced: Boolean,
override val isRedacted: Boolean = false
override var hasBeenDisplayed: Boolean = false ) : NotifiableEvent
override var isRedacted: Boolean = false
override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
}

View File

@ -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<RoomNotification.Message.Meta>,
invitationNotifications: List<OneShotNotification.Append.Meta>,
simpleNotifications: List<OneShotNotification.Append.Meta>,
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
)
}
}

View File

@ -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<NotifiableEvent>(
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<NotifiableEvent>>(
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<Type, NotifiableEvent>) = 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
)

View File

@ -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 <T> testWith(receiver: T, block: T.() -> Unit) {
receiver.block()
}

View File

@ -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<ProcessedEvent<NotifiableEvent>>()
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<Notification>()
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<RoomNotification> = emptyList(),
invitationNotifications: List<OneShotNotification> = emptyList(),
simpleNotifications: List<OneShotNotification> = 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
)
}
}

View File

@ -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
}

View File

@ -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<NotificationDisplayer>(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()
}
}

View File

@ -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<NotificationFactory>()
fun givenNotificationsFor(groupedEvents: GroupedNotificationEvents,
myUserId: String,
myUserDisplayName: String,
myUserAvatarUrl: String?,
useCompleteNotificationFormat: Boolean,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
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
}
}
}

View File

@ -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<NotificationUtils>()
fun givenBuildRoomInvitationNotificationFor(event: InviteNotifiableEvent, myUserId: String): Notification {
val mockNotification = mockk<Notification>()
every { instance.buildRoomInvitationNotification(event, myUserId) } returns mockNotification
return mockNotification
}
fun givenBuildSimpleInvitationNotificationFor(event: SimpleNotifiableEvent, myUserId: String): Notification {
val mockNotification = mockk<Notification>()
every { instance.buildSimpleEventNotification(event, myUserId) } returns mockNotification
return mockNotification
}
}

View File

@ -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<OutdatedEventDetector>()
fun givenEventIsOutOfDate(notifiableEvent: NotifiableEvent) {
every { instance.isMessageOutdated(notifiableEvent) } returns true
}
fun givenEventIsInDate(notifiableEvent: NotifiableEvent) {
every { instance.isMessageOutdated(notifiableEvent) } returns false
}
}

View File

@ -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<RoomGroupMessageCreator>()
fun givenCreatesRoomMessageFor(events: List<NotifiableMessageEvent>,
roomId: String,
userDisplayName: String,
userAvatarUrl: String?): RoomNotification.Message {
val mockMessage = mockk<RoomNotification.Message>()
every { instance.createRoomMessage(events, roomId, userDisplayName, userAvatarUrl) } returns mockMessage
return mockMessage
}
}

View File

@ -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<SummaryGroupMessageCreator>()
}

View File

@ -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<VectorPreferences>()
fun givenUseCompleteNotificationFormat(value: Boolean) {
every { instance.useCompleteNotificationFormat() } returns value
}
}