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.failure.MatrixError
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
@ -310,3 +312,6 @@ fun Event.isEdition(): Boolean {
fun Event.getPresenceContent(): PresenceContent? {
return content.toModel<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.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.isInvitation
import org.matrix.android.sdk.api.session.sync.model.RoomsSyncResponse
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.task.Task
@ -48,14 +49,18 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
}
val newJoinEvents = params.syncResponse.join
.mapNotNull { (key, value) ->
value.timeline?.events?.map { it.copy(roomId = key) }
value.timeline?.events?.mapNotNull {
it.takeIf { !it.isInvitation() }?.copy(roomId = key)
}
}
.flatten()
val inviteEvents = params.syncResponse.invite
.mapNotNull { (key, value) ->
value.inviteState?.events?.map { it.copy(roomId = key) }
}
.flatten()
val allEvents = (newJoinEvents + inviteEvents).filter { event ->
when (event.type) {
EventType.MESSAGE,

View file

@ -160,7 +160,7 @@ Formatter\.formatShortFileSize===1
# android\.text\.TextUtils
### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If the enum is not used as a Json class, change the value in file forbidden_strings_in_code.txt
enum class===106
enum class===107
### Do not import temporary legacy classes
import org.matrix.android.sdk.internal.legacy.riot===3

View file

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

View file

@ -20,24 +20,11 @@ import java.io.Serializable
/**
* Parent interface for all events which can be displayed as a Notification
*/
interface NotifiableEvent : Serializable {
var matrixID: String?
sealed interface NotifiableEvent : Serializable {
val eventId: String
val editedEventId: String?
var noisy: Boolean
val title: String
val description: String?
val type: String?
val timestamp: Long
// NotificationCompat.VISIBILITY_PUBLIC , VISIBILITY_PRIVATE , VISIBILITY_SECRET
var lockScreenVisibility: Int
// Compat: Only for android <7, for newer version the sound is defined in the channel
var soundName: String?
var hasBeenDisplayed: Boolean
var isRedacted: Boolean
// Used to know if event should be replaced with the one coming from eventstream
var isPushGatewayEvent: Boolean
val canBeReplaced: Boolean
val isRedacted: Boolean
}

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

View file

@ -15,43 +15,31 @@
*/
package im.vector.app.features.notifications
import androidx.core.app.NotificationCompat
import org.matrix.android.sdk.api.session.events.model.EventType
data class NotifiableMessageEvent(
override val eventId: String,
override val editedEventId: String?,
override var noisy: Boolean,
override val timestamp: Long,
var senderName: String?,
var senderId: String?,
var body: String?,
var roomId: String,
var roomName: String?,
var roomIsDirect: Boolean = false
override val canBeReplaced: Boolean,
val noisy: Boolean,
val timestamp: Long,
val senderName: String?,
val senderId: String?,
val body: String?,
val roomId: String,
val roomName: String?,
val roomIsDirect: Boolean = false,
val roomAvatarPath: String? = null,
val senderAvatarPath: String? = null,
val matrixID: String? = null,
val soundName: String? = null,
// This is used for >N notification, as the result of a smart reply
val outGoingMessage: Boolean = false,
val outGoingMessageFailed: Boolean = false,
override val isRedacted: Boolean = false
) : NotifiableEvent {
override var matrixID: String? = null
override var soundName: String? = null
override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
override var hasBeenDisplayed: Boolean = false
override var isRedacted: Boolean = false
var roomAvatarPath: String? = null
var senderAvatarPath: String? = null
override var isPushGatewayEvent: Boolean = false
override val type: String
get() = EventType.MESSAGE
override val description: String?
get() = body ?: ""
override val title: String
get() = senderName ?: ""
// This is used for >N notification, as the result of a smart reply
var outGoingMessage = false
var outGoingMessageFailed = false
val type: String = EventType.MESSAGE
val description: String = body ?: ""
val title: String = senderName ?: ""
}

View file

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

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
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import androidx.annotation.WorkerThread
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import im.vector.app.ActiveSessionDataSource
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.FirstThrottler
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.room.detail.RoomDetailActivity
import im.vector.app.features.invite.AutoAcceptInvites
import im.vector.app.features.settings.VectorPreferences
import me.gujun.android.span.span
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.content.ContentUrlResolver
import org.matrix.android.sdk.api.util.toMatrixItem
@ -52,14 +41,11 @@ import javax.inject.Singleton
*/
@Singleton
class NotificationDrawerManager @Inject constructor(private val context: Context,
private val notificationUtils: NotificationUtils,
private val notificationDisplayer: NotificationDisplayer,
private val vectorPreferences: VectorPreferences,
private val stringProvider: StringProvider,
private val activeSessionDataSource: ActiveSessionDataSource,
private val iconLoader: IconLoader,
private val bitmapLoader: BitmapLoader,
private val outdatedDetector: OutdatedEventDetector?,
private val autoAcceptInvites: AutoAcceptInvites) {
private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationRenderer: NotificationRenderer) {
private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY)
private var backgroundHandler: Handler
@ -69,13 +55,23 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
backgroundHandler = Handler(handlerThread.looper)
}
// The first time the notification drawer is refreshed, we force re-render of all notifications
private var firstTime = true
private val eventList = loadEventInfo()
/**
* The notifiable events to render
* this is our source of truth for notifications, any changes to this list will be rendered as notifications
* when events are removed the previously rendered notifications will be cancelled
* when adding or updating, the notifications will be notified
*
* Events are unique by their properties, we should be careful not to insert multiple events with the same event-id
*/
private val queuedEvents = loadEventInfo()
/**
* The last known rendered notifiable events
* we keep track of them in order to know which events have been removed from the eventList
* allowing us to cancel any notifications previous displayed by now removed events
*/
private var renderedEvents = emptyList<ProcessedEvent<NotifiableEvent>>()
private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
private var currentRoomId: String? = null
// TODO Multi-session: this will have to be improved
@ -107,12 +103,12 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) {
Timber.d("onNotifiableEventReceived(): $notifiableEvent")
} else {
Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.isPushGatewayEvent}")
Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
}
synchronized(eventList) {
val existing = eventList.firstOrNull { it.eventId == notifiableEvent.eventId }
synchronized(queuedEvents) {
val existing = queuedEvents.firstOrNull { it.eventId == notifiableEvent.eventId }
if (existing != null) {
if (existing.isPushGatewayEvent) {
if (existing.canBeReplaced) {
// Use the event coming from the event stream as it may contains more info than
// the fcm one (like type/content/clear text) (e.g when an encrypted message from
// FCM should be update with clear text after a sync)
@ -121,9 +117,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
// Use setOnlyAlertOnce to ensure update notification does not interfere with sound
// from first notify invocation as outlined in:
// https://developer.android.com/training/notify-user/build-notification#Updating
notifiableEvent.hasBeenDisplayed = false
eventList.remove(existing)
eventList.add(notifiableEvent)
queuedEvents.remove(existing)
queuedEvents.add(notifiableEvent)
} else {
// keep the existing one, do not replace
}
@ -131,7 +126,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
// Check if this is an edit
if (notifiableEvent.editedEventId != null) {
// This is an edition
val eventBeforeEdition = eventList.firstOrNull {
val eventBeforeEdition = queuedEvents.firstOrNull {
// Edition of an event
it.eventId == notifiableEvent.editedEventId ||
// or edition of an edition
@ -140,9 +135,9 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
if (eventBeforeEdition != null) {
// Replace the existing notification with the new content
eventList.remove(eventBeforeEdition)
queuedEvents.remove(eventBeforeEdition)
eventList.add(notifiableEvent)
queuedEvents.add(notifiableEvent)
} else {
// Ignore an edit of a not displayed event in the notification drawer
}
@ -153,7 +148,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
Timber.d("onNotifiableEventReceived(): skipping event, already seen")
} else {
seenEventIds.put(notifiableEvent.eventId)
eventList.add(notifiableEvent)
queuedEvents.add(notifiableEvent)
}
}
}
@ -161,10 +156,13 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
}
fun onEventRedacted(eventId: String) {
synchronized(eventList) {
eventList.find { it.eventId == eventId }?.apply {
isRedacted = true
hasBeenDisplayed = false
synchronized(queuedEvents) {
queuedEvents.replace(eventId) {
when (it) {
is InviteNotifiableEvent -> it.copy(isRedacted = true)
is NotifiableMessageEvent -> it.copy(isRedacted = true)
is SimpleNotifiableEvent -> it.copy(isRedacted = true)
}
}
}
}
@ -173,8 +171,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
* Clear all known events and refresh the notification drawer
*/
fun clearAllEvents() {
synchronized(eventList) {
eventList.clear()
synchronized(queuedEvents) {
queuedEvents.clear()
}
refreshNotificationDrawer()
}
@ -183,14 +181,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
fun clearMessageEventOfRoom(roomId: String?) {
Timber.v("clearMessageEventOfRoom $roomId")
if (roomId != null) {
var shouldUpdate = false
synchronized(eventList) {
shouldUpdate = eventList.removeAll { e ->
e is NotifiableMessageEvent && e.roomId == roomId
}
}
val shouldUpdate = removeAll { it is NotifiableMessageEvent && it.roomId == roomId }
if (shouldUpdate) {
notificationUtils.cancelNotificationMessage(roomId, ROOM_MESSAGES_NOTIFICATION_ID)
refreshNotificationDrawer()
}
}
@ -202,7 +194,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
*/
fun setCurrentRoom(roomId: String?) {
var hasChanged: Boolean
synchronized(eventList) {
synchronized(queuedEvents) {
hasChanged = roomId != currentRoomId
currentRoomId = roomId
}
@ -212,12 +204,16 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
}
fun clearMemberShipNotificationForRoom(roomId: String) {
synchronized(eventList) {
eventList.removeAll { e ->
e is InviteNotifiableEvent && e.roomId == roomId
val shouldUpdate = removeAll { it is InviteNotifiableEvent && it.roomId == roomId }
if (shouldUpdate) {
refreshNotificationDrawerBg()
}
}
notificationUtils.cancelNotificationMessage(roomId, ROOM_INVITATION_NOTIFICATION_ID)
private fun removeAll(predicate: (NotifiableEvent) -> Boolean): Boolean {
return synchronized(queuedEvents) {
queuedEvents.removeAll(predicate)
}
}
private var firstThrottler = FirstThrottler(200)
@ -244,359 +240,36 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
private fun refreshNotificationDrawerBg() {
Timber.v("refreshNotificationDrawerBg()")
val session = currentSession ?: return
val user = session.getUser(session.myUserId)
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
val myUserDisplayName = user?.toMatrixItem()?.getBestName() ?: session.myUserId
val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail(user?.avatarUrl, avatarSize, avatarSize, ContentUrlResolver.ThumbnailMethod.SCALE)
synchronized(eventList) {
Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER ")
// TMP code
var hasNewEvent = false
var summaryIsNoisy = false
val summaryInboxStyle = NotificationCompat.InboxStyle()
// group events by room to create a single MessagingStyle notif
val roomIdToEventMap: MutableMap<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()
notificationDisplayer.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
val eventsToRender = synchronized(queuedEvents) {
notifiableEventProcessor.process(queuedEvents, currentRoomId, renderedEvents).also {
queuedEvents.clear()
queuedEvents.addAll(it.onlyKeptEvents())
}
}
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
if (renderedEvents == eventsToRender) {
Timber.d("Skipping notification update due to event list not changing")
} 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
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)
}
} 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? {
if (events.isEmpty()) return null
// Use the last event (most recent?)
val roomAvatarPath = events.last().roomAvatarPath ?: events.last().senderAvatarPath
return bitmapLoader.getRoomBitmap(roomAvatarPath)
}
fun shouldIgnoreMessageEventInRoom(roomId: String?): Boolean {
@ -604,8 +277,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
}
fun persistInfo() {
synchronized(eventList) {
if (eventList.isEmpty()) {
synchronized(queuedEvents) {
if (queuedEvents.isEmpty()) {
deleteCachedRoomNotifications()
return
}
@ -613,7 +286,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (!file.exists()) file.createNewFile()
FileOutputStream(file).use {
currentSession?.securelyStoreObject(eventList, KEY_ALIAS_SECRET_STORAGE, it)
currentSession?.securelyStoreObject(queuedEvents, KEY_ALIAS_SECRET_STORAGE, it)
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to save cached notification info")
@ -645,15 +318,11 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
}
}
fun displayDiagnosticNotification() {
notificationUtils.displayDiagnosticNotification()
}
companion object {
private const val SUMMARY_NOTIFICATION_ID = 0
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1
private const val ROOM_EVENT_NOTIFICATION_ID = 2
private const val ROOM_INVITATION_NOTIFICATION_ID = 3
const val SUMMARY_NOTIFICATION_ID = 0
const val ROOM_MESSAGES_NOTIFICATION_ID = 1
const val ROOM_EVENT_NOTIFICATION_ID = 2
const val ROOM_INVITATION_NOTIFICATION_ID = 3
// TODO Mutliaccount
private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache"
@ -661,3 +330,11 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
private const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr"
}
}
private fun MutableList<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)
.setOnlyAlertOnce(true)
.setContentTitle(stringProvider.getString(R.string.app_name))
.setContentTitle(inviteNotifiableEvent.roomName ?: stringProvider.getString(R.string.app_name))
.setContentText(inviteNotifiableEvent.description)
.setGroup(stringProvider.getString(R.string.app_name))
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)

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()
if (notificationAction.shouldNotify) {
val notifiableEvent = resolver.resolveEvent(event, safeSession)
val notifiableEvent = resolver.resolveEvent(event, safeSession, isNoisy = !notificationAction.soundName.isNullOrBlank())
if (notifiableEvent == null) {
Timber.v("## Failed to resolve event")
// TODO
} else {
notifiableEvent.noisy = !notificationAction.soundName.isNullOrBlank()
Timber.v("New event to notify")
notificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
}

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
import androidx.core.app.NotificationCompat
data class SimpleNotifiableEvent(
override var matrixID: String?,
val matrixID: String?,
override val eventId: String,
override val editedEventId: String?,
override var noisy: Boolean,
override val title: String,
override val description: String,
override val type: String?,
override val timestamp: Long,
override var soundName: String?,
override var isPushGatewayEvent: Boolean = false) : NotifiableEvent {
override var hasBeenDisplayed: Boolean = false
override var isRedacted: Boolean = false
override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
}
val noisy: Boolean,
val title: String,
val description: String,
val type: String?,
val timestamp: Long,
val soundName: String?,
override var canBeReplaced: Boolean,
override val isRedacted: Boolean = false
) : NotifiableEvent

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