Merge pull request #4430 from vector-im/feature/adm/feature-notification-images

Notification images
This commit is contained in:
Benoit Marty 2021-11-15 12:46:51 +01:00 committed by GitHub
commit df60b0c2b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 608 additions and 308 deletions

1
changelog.d/4401.removal Normal file
View file

@ -0,0 +1 @@
Breaking SDK API change to PushRuleListener, the separated callbacks have been merged into one with a data class which includes all the previously separated push information

1
changelog.d/4402.feature Normal file
View file

@ -0,0 +1 @@
Adds support for images inside message notifications

View file

@ -0,0 +1,26 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.pushrules
import org.matrix.android.sdk.api.pushrules.rest.PushRule
import org.matrix.android.sdk.api.session.events.model.Event
data class PushEvents(
val matchedEvents: List<Pair<Event, PushRule>>,
val roomsJoined: Collection<String>,
val roomsLeft: Collection<String>,
val redactedEventIds: List<String>
)

View file

@ -51,11 +51,7 @@ interface PushRuleService {
// fun fulfilledBingRule(event: Event, rules: List<PushRule>): PushRule?
interface PushRuleListener {
fun onMatchRule(event: Event, actions: List<Action>)
fun onRoomJoined(roomId: String)
fun onRoomLeft(roomId: String)
fun onEventRedacted(redactedEventId: String)
fun batchFinish()
fun onEvents(pushEvents: PushEvents)
}
fun getKeywords(): LiveData<Set<String>>

View file

@ -19,6 +19,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.pushrules.Action
import org.matrix.android.sdk.api.pushrules.PushEvents
import org.matrix.android.sdk.api.pushrules.PushRuleService
import org.matrix.android.sdk.api.pushrules.RuleKind
import org.matrix.android.sdk.api.pushrules.RuleScope
@ -142,79 +143,6 @@ internal class DefaultPushRuleService @Inject constructor(
return pushRuleFinder.fulfilledBingRule(event, rules)?.getActions().orEmpty()
}
// fun processEvents(events: List<Event>) {
// var hasDoneSomething = false
// events.forEach { event ->
// fulfilledBingRule(event)?.let {
// hasDoneSomething = true
// dispatchBing(event, it)
// }
// }
// if (hasDoneSomething)
// dispatchFinish()
// }
fun dispatchBing(event: Event, rule: PushRule) {
synchronized(listeners) {
val actionsList = rule.getActions()
listeners.forEach {
try {
it.onMatchRule(event, actionsList)
} catch (e: Throwable) {
Timber.e(e, "Error while dispatching bing")
}
}
}
}
fun dispatchRoomJoined(roomId: String) {
synchronized(listeners) {
listeners.forEach {
try {
it.onRoomJoined(roomId)
} catch (e: Throwable) {
Timber.e(e, "Error while dispatching room joined")
}
}
}
}
fun dispatchRoomLeft(roomId: String) {
synchronized(listeners) {
listeners.forEach {
try {
it.onRoomLeft(roomId)
} catch (e: Throwable) {
Timber.e(e, "Error while dispatching room left")
}
}
}
}
fun dispatchRedactedEventId(redactedEventId: String) {
synchronized(listeners) {
listeners.forEach {
try {
it.onEventRedacted(redactedEventId)
} catch (e: Throwable) {
Timber.e(e, "Error while dispatching redacted event")
}
}
}
}
fun dispatchFinish() {
synchronized(listeners) {
listeners.forEach {
try {
it.batchFinish()
} catch (e: Throwable) {
Timber.e(e, "Error while dispatching finish")
}
}
}
}
override fun getKeywords(): LiveData<Set<String>> {
// Keywords are all content rules that don't start with '.'
val liveData = monarchy.findAllMappedWithChanges(
@ -229,4 +157,16 @@ internal class DefaultPushRuleService @Inject constructor(
results.firstOrNull().orEmpty().toSet()
}
}
fun dispatchEvents(pushEvents: PushEvents) {
synchronized(listeners) {
listeners.forEach {
try {
it.onEvents(pushEvents)
} catch (e: Throwable) {
Timber.e(e, "Error while dispatching push events")
}
}
}
}
}

View file

@ -16,6 +16,7 @@
package org.matrix.android.sdk.internal.session.notification
import org.matrix.android.sdk.api.pushrules.PushEvents
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
@ -39,14 +40,6 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
) : ProcessEventForPushTask {
override suspend fun execute(params: ProcessEventForPushTask.Params) {
// Handle left rooms
params.syncResponse.leave.keys.forEach {
defaultPushRuleService.dispatchRoomLeft(it)
}
// Handle joined rooms
params.syncResponse.join.keys.forEach {
defaultPushRuleService.dispatchRoomJoined(it)
}
val newJoinEvents = params.syncResponse.join
.mapNotNull { (key, value) ->
value.timeline?.events?.mapNotNull {
@ -74,10 +67,10 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
}
Timber.v("[PushRules] Found ${allEvents.size} out of ${(newJoinEvents + inviteEvents).size}" +
" to check for push rules with ${params.rules.size} rules")
allEvents.forEach { event ->
val matchedEvents = allEvents.mapNotNull { event ->
pushRuleFinder.fulfilledBingRule(event, params.rules)?.let {
Timber.v("[PushRules] Rule $it match for event ${event.eventId}")
defaultPushRuleService.dispatchBing(event, it)
event to it
}
}
@ -91,10 +84,13 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
Timber.v("[PushRules] Found ${allRedactedEvents.size} redacted events")
allRedactedEvents.forEach { redactedEventId ->
defaultPushRuleService.dispatchRedactedEventId(redactedEventId)
}
defaultPushRuleService.dispatchFinish()
defaultPushRuleService.dispatchEvents(
PushEvents(
matchedEvents = matchedEvents,
roomsJoined = params.syncResponse.join.keys,
roomsLeft = params.syncResponse.leave.keys,
redactedEventIds = allRedactedEvents
)
)
}
}

View file

@ -201,8 +201,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
resolvedEvent
?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") }
?.let {
notificationDrawerManager.onNotifiableEventReceived(it)
notificationDrawerManager.refreshNotificationDrawer()
notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(resolvedEvent) }
}
}
}

View file

@ -66,3 +66,7 @@ fun String?.insertBeforeLast(insert: String, delimiter: String = "."): String {
replaceRange(idx, idx, insert)
}
}
inline fun <reified R> Any?.takeAs(): R? {
return takeIf { it is R } as R?
}

View file

@ -2102,12 +2102,12 @@ class RoomDetailFragment @Inject constructor(
// VectorInviteView.Callback
override fun onAcceptInvite() {
notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId)
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) }
roomDetailViewModel.handle(RoomDetailAction.AcceptInvite)
}
override fun onRejectInvite() {
notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId)
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) }
roomDetailViewModel.handle(RoomDetailAction.RejectInvite)
}

View file

@ -482,7 +482,7 @@ class RoomListFragment @Inject constructor(
}
override fun onAcceptRoomInvitation(room: RoomSummary) {
notificationDrawerManager.clearMemberShipNotificationForRoom(room.roomId)
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(room.roomId) }
roomListViewModel.handle(RoomListAction.AcceptInvitation(room))
}
@ -495,7 +495,7 @@ class RoomListFragment @Inject constructor(
}
override fun onRejectRoomInvitation(room: RoomSummary) {
notificationDrawerManager.clearMemberShipNotificationForRoom(room.roomId)
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(room.roomId) }
roomListViewModel.handle(RoomListAction.RejectInvitation(room))
}
}

View file

@ -15,8 +15,10 @@
*/
package im.vector.app.features.notifications
import android.net.Uri
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.extensions.takeAs
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter
@ -28,12 +30,15 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.isEdition
import org.matrix.android.sdk.api.session.events.model.isImageMessage
import org.matrix.android.sdk.api.session.events.model.toModel
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.MessageWithAttachmentContent
import org.matrix.android.sdk.api.session.room.sender.SenderInfo
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.getEditedEventId
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import timber.log.Timber
@ -49,11 +54,12 @@ import javax.inject.Inject
class NotifiableEventResolver @Inject constructor(
private val stringProvider: StringProvider,
private val noticeEventFormatter: NoticeEventFormatter,
private val displayableEventFormatter: DisplayableEventFormatter) {
private val displayableEventFormatter: DisplayableEventFormatter
) {
// private val eventDisplay = RiotEventDisplay(context)
fun resolveEvent(event: Event/*, roomState: RoomState?, bingRule: PushRule?*/, session: Session, isNoisy: Boolean): NotifiableEvent? {
suspend 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) {
@ -89,7 +95,7 @@ class NotifiableEventResolver @Inject constructor(
}
}
fun resolveInMemoryEvent(session: Session, event: Event, canBeReplaced: Boolean): NotifiableEvent? {
suspend fun resolveInMemoryEvent(session: Session, event: Event, canBeReplaced: Boolean): NotifiableEvent? {
if (event.getClearType() != EventType.MESSAGE) return null
// Ignore message edition
@ -120,7 +126,7 @@ class NotifiableEventResolver @Inject constructor(
}
}
private fun resolveMessageEvent(event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent {
private suspend 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*/)
@ -140,6 +146,7 @@ class NotifiableEventResolver @Inject constructor(
senderName = senderDisplayName,
senderId = event.root.senderId,
body = body.toString(),
imageUri = event.fetchImageIfPresent(session),
roomId = event.root.roomId!!,
roomName = roomName,
matrixID = session.myUserId
@ -173,6 +180,7 @@ class NotifiableEventResolver @Inject constructor(
senderName = senderDisplayName,
senderId = event.root.senderId,
body = body,
imageUri = event.fetchImageIfPresent(session),
roomId = event.root.roomId!!,
roomName = roomName,
roomIsDirect = room.roomSummary()?.isDirect ?: false,
@ -192,6 +200,26 @@ class NotifiableEventResolver @Inject constructor(
}
}
private suspend fun TimelineEvent.fetchImageIfPresent(session: Session): Uri? {
return when {
root.isEncrypted() && root.mxDecryptionResult == null -> null
root.isImageMessage() -> downloadAndExportImage(session)
else -> null
}
}
private suspend fun TimelineEvent.downloadAndExportImage(session: Session): Uri? {
return kotlin.runCatching {
getLastMessageContent()?.takeAs<MessageWithAttachmentContent>()?.let { imageMessage ->
val fileService = session.fileService()
fileService.downloadFile(imageMessage)
fileService.getTemporarySharableURI(imageMessage)
}
}.onFailure {
Timber.e(it, "Failed to download and export image for notification")
}.getOrNull()
}
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

View file

@ -15,6 +15,7 @@
*/
package im.vector.app.features.notifications
import android.net.Uri
import org.matrix.android.sdk.api.session.events.model.EventType
data class NotifiableMessageEvent(
@ -26,6 +27,7 @@ data class NotifiableMessageEvent(
val senderName: String?,
val senderId: String?,
val body: String?,
val imageUri: Uri?,
val roomId: String,
val roomName: String?,
val roomIsDirect: Boolean = false,

View file

@ -49,26 +49,26 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
NotificationUtils.SMART_REPLY_ACTION ->
handleSmartReply(intent, context)
NotificationUtils.DISMISS_ROOM_NOTIF_ACTION ->
intent.getStringExtra(KEY_ROOM_ID)?.let {
notificationDrawerManager.clearMessageEventOfRoom(it)
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) }
}
NotificationUtils.DISMISS_SUMMARY_ACTION ->
notificationDrawerManager.clearAllEvents()
NotificationUtils.MARK_ROOM_READ_ACTION ->
intent.getStringExtra(KEY_ROOM_ID)?.let {
notificationDrawerManager.clearMessageEventOfRoom(it)
handleMarkAsRead(it)
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) }
handleMarkAsRead(roomId)
}
NotificationUtils.JOIN_ACTION -> {
intent.getStringExtra(KEY_ROOM_ID)?.let {
notificationDrawerManager.clearMemberShipNotificationForRoom(it)
handleJoinRoom(it)
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) }
handleJoinRoom(roomId)
}
}
NotificationUtils.REJECT_ACTION -> {
intent.getStringExtra(KEY_ROOM_ID)?.let {
notificationDrawerManager.clearMemberShipNotificationForRoom(it)
handleRejectRoom(it)
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) }
handleRejectRoom(roomId)
}
}
}
@ -138,6 +138,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
?: context?.getString(R.string.notification_sender_me),
senderId = session.myUserId,
body = message,
imageUri = null,
roomId = room.roomId,
roomName = room.roomSummary()?.displayName ?: room.roomId,
roomIsDirect = room.roomSummary()?.isDirect == true,
@ -145,8 +146,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
canBeReplaced = false
)
notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent)
notificationDrawerManager.refreshNotificationDrawer()
notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(notifiableMessageEvent) }
/*
// TODO Error cannot be managed the same way than in Riot

View file

@ -93,7 +93,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
#refreshNotificationDrawer() is called.
Events might be grouped and there might not be one notification per event!
*/
fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
fun NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
if (!vectorPreferences.areNotificationEnabledForDevice()) {
Timber.i("Notification are disabled for this device")
return
@ -105,87 +105,15 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
} else {
Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
}
synchronized(queuedEvents) {
val existing = queuedEvents.firstOrNull { it.eventId == notifiableEvent.eventId }
if (existing != null) {
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)
// In this case the message has already been notified, and might have done some noise
// So we want the notification to be updated even if it has already been displayed
// 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
queuedEvents.remove(existing)
queuedEvents.add(notifiableEvent)
} else {
// keep the existing one, do not replace
}
} else {
// Check if this is an edit
if (notifiableEvent.editedEventId != null) {
// This is an edition
val eventBeforeEdition = queuedEvents.firstOrNull {
// Edition of an event
it.eventId == notifiableEvent.editedEventId ||
// or edition of an edition
it.editedEventId == notifiableEvent.editedEventId
}
if (eventBeforeEdition != null) {
// Replace the existing notification with the new content
queuedEvents.remove(eventBeforeEdition)
queuedEvents.add(notifiableEvent)
} else {
// Ignore an edit of a not displayed event in the notification drawer
}
} else {
// Not an edit
if (seenEventIds.contains(notifiableEvent.eventId)) {
// we've already seen the event, lets skip
Timber.d("onNotifiableEventReceived(): skipping event, already seen")
} else {
seenEventIds.put(notifiableEvent.eventId)
queuedEvents.add(notifiableEvent)
}
}
}
}
}
fun onEventRedacted(eventId: String) {
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)
}
}
}
add(notifiableEvent, seenEventIds)
}
/**
* Clear all known events and refresh the notification drawer
*/
fun clearAllEvents() {
synchronized(queuedEvents) {
queuedEvents.clear()
}
refreshNotificationDrawer()
}
/** Clear all known message events for this room */
fun clearMessageEventOfRoom(roomId: String?) {
Timber.v("clearMessageEventOfRoom $roomId")
if (roomId != null) {
val shouldUpdate = removeAll { it is NotifiableMessageEvent && it.roomId == roomId }
if (shouldUpdate) {
refreshNotificationDrawer()
}
}
updateEvents { it.clear() }
}
/**
@ -193,32 +121,36 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
* Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room.
*/
fun setCurrentRoom(roomId: String?) {
var hasChanged: Boolean
synchronized(queuedEvents) {
hasChanged = roomId != currentRoomId
updateEvents {
val hasChanged = roomId != currentRoomId
currentRoomId = roomId
}
if (hasChanged) {
clearMessageEventOfRoom(roomId)
if (hasChanged && roomId != null) {
it.clearMessagesForRoom(roomId)
}
}
}
fun clearMemberShipNotificationForRoom(roomId: String) {
val shouldUpdate = removeAll { it is InviteNotifiableEvent && it.roomId == roomId }
if (shouldUpdate) {
refreshNotificationDrawerBg()
fun notificationStyleChanged() {
updateEvents {
val newSettings = vectorPreferences.useCompleteNotificationFormat()
if (newSettings != useCompleteNotificationFormat) {
// Settings has changed, remove all current notifications
notificationDisplayer.cancelAllNotifications()
useCompleteNotificationFormat = newSettings
}
}
}
private fun removeAll(predicate: (NotifiableEvent) -> Boolean): Boolean {
return synchronized(queuedEvents) {
queuedEvents.removeAll(predicate)
fun updateEvents(action: NotificationDrawerManager.(NotificationEventQueue) -> Unit) {
synchronized(queuedEvents) {
action(this, queuedEvents)
}
refreshNotificationDrawer()
}
private var firstThrottler = FirstThrottler(200)
fun refreshNotificationDrawer() {
private fun refreshNotificationDrawer() {
// Implement last throttler
val canHandle = firstThrottler.canHandle()
Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms")
@ -239,18 +171,9 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
@WorkerThread
private fun refreshNotificationDrawerBg() {
Timber.v("refreshNotificationDrawerBg()")
val newSettings = vectorPreferences.useCompleteNotificationFormat()
if (newSettings != useCompleteNotificationFormat) {
// Settings has changed, remove all current notifications
notificationDisplayer.cancelAllNotifications()
useCompleteNotificationFormat = newSettings
}
val eventsToRender = synchronized(queuedEvents) {
notifiableEventProcessor.process(queuedEvents, currentRoomId, renderedEvents).also {
queuedEvents.clear()
queuedEvents.addAll(it.onlyKeptEvents())
notifiableEventProcessor.process(queuedEvents.rawEvents(), currentRoomId, renderedEvents).also {
queuedEvents.clearAndAdd(it.onlyKeptEvents())
}
}
@ -286,7 +209,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(queuedEvents, KEY_ALIAS_SECRET_STORAGE, it)
currentSession?.securelyStoreObject(queuedEvents.rawEvents(), KEY_ALIAS_SECRET_STORAGE, it)
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to save cached notification info")
@ -294,21 +217,21 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
}
}
private fun loadEventInfo(): MutableList<NotifiableEvent> {
private fun loadEventInfo(): NotificationEventQueue {
try {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (file.exists()) {
file.inputStream().use {
val events: ArrayList<NotifiableEvent>? = currentSession?.loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE)
if (events != null) {
return events.toMutableList()
return NotificationEventQueue(events.toMutableList())
}
}
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to load cached notification info")
}
return ArrayList()
return NotificationEventQueue()
}
private fun deleteCachedRoomNotifications() {
@ -330,11 +253,3 @@ 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,130 @@
/*
* 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 timber.log.Timber
class NotificationEventQueue(
private val queue: MutableList<NotifiableEvent> = mutableListOf()
) {
fun markRedacted(eventIds: List<String>) {
eventIds.forEach { redactedId ->
queue.replace(redactedId) {
when (it) {
is InviteNotifiableEvent -> it.copy(isRedacted = true)
is NotifiableMessageEvent -> it.copy(isRedacted = true)
is SimpleNotifiableEvent -> it.copy(isRedacted = true)
}
}
}
}
fun syncRoomEvents(roomsLeft: Collection<String>, roomsJoined: Collection<String>) {
if (roomsLeft.isNotEmpty() || roomsJoined.isNotEmpty()) {
queue.removeAll {
when (it) {
is NotifiableMessageEvent -> roomsLeft.contains(it.roomId)
is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId)
else -> false
}
}
}
}
fun isEmpty() = queue.isEmpty()
fun clearAndAdd(events: List<NotifiableEvent>) {
queue.clear()
queue.addAll(events)
}
fun clear() {
queue.clear()
}
fun add(notifiableEvent: NotifiableEvent, seenEventIds: CircularCache<String>) {
val existing = findExistingById(notifiableEvent)
val edited = findEdited(notifiableEvent)
when {
existing != null -> {
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)
// In this case the message has already been notified, and might have done some noise
// So we want the notification to be updated even if it has already been displayed
// 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
replace(replace = existing, with = notifiableEvent)
} else {
// keep the existing one, do not replace
}
}
edited != null -> {
// Replace the existing notification with the new content
replace(replace = edited, with = notifiableEvent)
}
seenEventIds.contains(notifiableEvent.eventId) -> {
// we've already seen the event, lets skip
Timber.d("onNotifiableEventReceived(): skipping event, already seen")
}
else -> {
seenEventIds.put(notifiableEvent.eventId)
queue.add(notifiableEvent)
}
}
}
private fun findExistingById(notifiableEvent: NotifiableEvent): NotifiableEvent? {
return queue.firstOrNull { it.eventId == notifiableEvent.eventId }
}
private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? {
return notifiableEvent.editedEventId?.let { editedId ->
queue.firstOrNull {
it.eventId == editedId || it.editedEventId == editedId
}
}
}
private fun replace(replace: NotifiableEvent, with: NotifiableEvent) {
queue.remove(replace)
queue.add(with)
}
fun clearMemberShipNotificationForRoom(roomId: String) {
Timber.v("clearMemberShipOfRoom $roomId")
queue.removeAll { it is InviteNotifiableEvent && it.roomId == roomId }
}
fun clearMessagesForRoom(roomId: String) {
Timber.v("clearMessageEventOfRoom $roomId")
queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId }
}
fun rawEvents(): List<NotifiableEvent> = queue
}
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

@ -16,10 +16,15 @@
package im.vector.app.features.notifications
import org.matrix.android.sdk.api.pushrules.Action
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.pushrules.PushEvents
import org.matrix.android.sdk.api.pushrules.PushRuleService
import org.matrix.android.sdk.api.pushrules.getActions
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
import javax.inject.Singleton
@ -31,45 +36,36 @@ class PushRuleTriggerListener @Inject constructor(
) : PushRuleService.PushRuleListener {
private var session: Session? = null
private val scope: CoroutineScope = CoroutineScope(SupervisorJob())
override fun onMatchRule(event: Event, actions: List<Action>) {
Timber.v("Push rule match for event ${event.eventId}")
val safeSession = session ?: return Unit.also {
Timber.e("Called without active session")
override fun onEvents(pushEvents: PushEvents) {
scope.launch {
session?.let { session ->
val notifiableEvents = createNotifiableEvents(pushEvents, session)
notificationDrawerManager.updateEvents { queuedEvents ->
notifiableEvents.forEach { notifiableEvent ->
queuedEvents.onNotifiableEventReceived(notifiableEvent)
}
queuedEvents.syncRoomEvents(roomsLeft = pushEvents.roomsLeft, roomsJoined = pushEvents.roomsJoined)
queuedEvents.markRedacted(pushEvents.redactedEventIds)
}
} ?: Timber.e("Called without active session")
}
}
val notificationAction = actions.toNotificationAction()
if (notificationAction.shouldNotify) {
val notifiableEvent = resolver.resolveEvent(event, safeSession, isNoisy = !notificationAction.soundName.isNullOrBlank())
if (notifiableEvent == null) {
Timber.v("## Failed to resolve event")
// TODO
private suspend fun createNotifiableEvents(pushEvents: PushEvents, session: Session): List<NotifiableEvent> {
return pushEvents.matchedEvents.mapNotNull { (event, pushRule) ->
Timber.v("Push rule match for event ${event.eventId}")
val action = pushRule.getActions().toNotificationAction()
if (action.shouldNotify) {
resolver.resolveEvent(event, session, isNoisy = !action.soundName.isNullOrBlank())
} else {
Timber.v("New event to notify")
notificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
Timber.v("Matched push rule is set to not notify")
null
}
} else {
Timber.v("Matched push rule is set to not notify")
}
}
override fun onRoomLeft(roomId: String) {
notificationDrawerManager.clearMessageEventOfRoom(roomId)
notificationDrawerManager.clearMemberShipNotificationForRoom(roomId)
}
override fun onRoomJoined(roomId: String) {
notificationDrawerManager.clearMemberShipNotificationForRoom(roomId)
}
override fun onEventRedacted(redactedEventId: String) {
notificationDrawerManager.onEventRedacted(redactedEventId)
}
override fun batchFinish() {
notificationDrawerManager.refreshNotificationDrawer()
}
fun startWithSession(session: Session) {
if (this.session != null) {
stop()
@ -79,6 +75,7 @@ class PushRuleTriggerListener @Inject constructor(
}
fun stop() {
scope.coroutineContext.cancelChildren(CancellationException("PushRuleTriggerListener stopping"))
session?.removePushRuleListener(this)
session = null
notificationDrawerManager.clearAllEvents()

View file

@ -98,7 +98,14 @@ class RoomGroupMessageCreator @Inject constructor(
}
when {
event.isSmartReplyError() -> addMessage(stringProvider.getString(R.string.notification_inline_reply_failed), event.timestamp, senderPerson)
else -> addMessage(event.body, event.timestamp, senderPerson)
else -> {
val message = NotificationCompat.MessagingStyle.Message(event.body, event.timestamp, senderPerson).also { message ->
event.imageUri?.let {
message.setData("image/", it)
}
}
addMessage(message)
}
}
}
}

View file

@ -55,7 +55,7 @@ class VectorSettingsPinFragment @Inject constructor(
useCompleteNotificationPref.setOnPreferenceChangeListener { _, _ ->
// Refresh the drawer for an immediate effect of this change
notificationDrawerManager.refreshNotificationDrawer()
notificationDrawerManager.notificationStyleChanged()
true
}
}

View file

@ -19,6 +19,9 @@ 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 im.vector.app.test.fixtures.aNotifiableMessageEvent
import im.vector.app.test.fixtures.aSimpleNotifiableEvent
import im.vector.app.test.fixtures.anInviteNotifiableEvent
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.EventType
@ -145,48 +148,3 @@ class NotifiableEventProcessorTest {
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,216 @@
/*
* 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.test.fixtures.aNotifiableMessageEvent
import im.vector.app.test.fixtures.aSimpleNotifiableEvent
import im.vector.app.test.fixtures.anInviteNotifiableEvent
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
class NotificationEventQueueTest {
private val seenIdsCache = CircularCache.create<String>(5)
@Test
fun `given events when redacting some then marks matching event ids as redacted`() {
val queue = givenQueue(listOf(
aSimpleNotifiableEvent(eventId = "redacted-id-1"),
aNotifiableMessageEvent(eventId = "redacted-id-2"),
anInviteNotifiableEvent(eventId = "redacted-id-3"),
aSimpleNotifiableEvent(eventId = "kept-id"),
))
queue.markRedacted(listOf("redacted-id-1", "redacted-id-2", "redacted-id-3"))
queue.rawEvents() shouldBeEqualTo listOf(
aSimpleNotifiableEvent(eventId = "redacted-id-1", isRedacted = true),
aNotifiableMessageEvent(eventId = "redacted-id-2", isRedacted = true),
anInviteNotifiableEvent(eventId = "redacted-id-3", isRedacted = true),
aSimpleNotifiableEvent(eventId = "kept-id", isRedacted = false),
)
}
@Test
fun `given invite event when leaving invited room and syncing then removes event`() {
val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = "a-room-id")))
val roomsLeft = listOf("a-room-id")
queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList())
queue.rawEvents() shouldBeEqualTo emptyList()
}
@Test
fun `given invite event when joining invited room and syncing then removes event`() {
val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = "a-room-id")))
val joinedRooms = listOf("a-room-id")
queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = joinedRooms)
queue.rawEvents() shouldBeEqualTo emptyList()
}
@Test
fun `given message event when leaving message room and syncing then removes event`() {
val queue = givenQueue(listOf(aNotifiableMessageEvent(roomId = "a-room-id")))
val roomsLeft = listOf("a-room-id")
queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList())
queue.rawEvents() shouldBeEqualTo emptyList()
}
@Test
fun `given events when syncing without rooms left or joined ids then does not change the events`() {
val queue = givenQueue(listOf(
aNotifiableMessageEvent(roomId = "a-room-id"),
anInviteNotifiableEvent(roomId = "a-room-id")
))
queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = emptyList())
queue.rawEvents() shouldBeEqualTo listOf(
aNotifiableMessageEvent(roomId = "a-room-id"),
anInviteNotifiableEvent(roomId = "a-room-id")
)
}
@Test
fun `given events then is not empty`() {
val queue = givenQueue(listOf(aSimpleNotifiableEvent()))
queue.isEmpty() shouldBeEqualTo false
}
@Test
fun `given no events then is empty`() {
val queue = givenQueue(emptyList())
queue.isEmpty() shouldBeEqualTo true
}
@Test
fun `given events when clearing and adding then removes previous events and adds only new events`() {
val queue = givenQueue(listOf(aSimpleNotifiableEvent()))
queue.clearAndAdd(listOf(anInviteNotifiableEvent()))
queue.rawEvents() shouldBeEqualTo listOf(anInviteNotifiableEvent())
}
@Test
fun `when clearing then is empty`() {
val queue = givenQueue(listOf(aSimpleNotifiableEvent()))
queue.clear()
queue.rawEvents() shouldBeEqualTo emptyList()
}
@Test
fun `given no events when adding then adds event`() {
val queue = givenQueue(listOf())
queue.add(aSimpleNotifiableEvent(), seenEventIds = seenIdsCache)
queue.rawEvents() shouldBeEqualTo listOf(aSimpleNotifiableEvent())
}
@Test
fun `given no events when adding already seen event then ignores event`() {
val queue = givenQueue(listOf())
val notifiableEvent = aSimpleNotifiableEvent()
seenIdsCache.put(notifiableEvent.eventId)
queue.add(notifiableEvent, seenEventIds = seenIdsCache)
queue.rawEvents() shouldBeEqualTo emptyList()
}
@Test
fun `given replaceable event when adding event with same id then updates existing event`() {
val replaceableEvent = aSimpleNotifiableEvent(canBeReplaced = true)
val updatedEvent = replaceableEvent.copy(title = "updated title")
val queue = givenQueue(listOf(replaceableEvent))
queue.add(updatedEvent, seenEventIds = seenIdsCache)
queue.rawEvents() shouldBeEqualTo listOf(updatedEvent)
}
@Test
fun `given non replaceable event when adding event with same id then ignores event`() {
val nonReplaceableEvent = aSimpleNotifiableEvent(canBeReplaced = false)
val updatedEvent = nonReplaceableEvent.copy(title = "updated title")
val queue = givenQueue(listOf(nonReplaceableEvent))
queue.add(updatedEvent, seenEventIds = seenIdsCache)
queue.rawEvents() shouldBeEqualTo listOf(nonReplaceableEvent)
}
@Test
fun `given event when adding new event with edited event id matching the existing event id then updates existing event`() {
val editedEvent = aSimpleNotifiableEvent(eventId = "id-to-edit")
val updatedEvent = editedEvent.copy(eventId = "1", editedEventId = "id-to-edit", title = "updated title")
val queue = givenQueue(listOf(editedEvent))
queue.add(updatedEvent, seenEventIds = seenIdsCache)
queue.rawEvents() shouldBeEqualTo listOf(updatedEvent)
}
@Test
fun `given event when adding new event with edited event id matching the existing event edited id then updates existing event`() {
val editedEvent = aSimpleNotifiableEvent(eventId = "0", editedEventId = "id-to-edit")
val updatedEvent = editedEvent.copy(eventId = "1", editedEventId = "id-to-edit", title = "updated title")
val queue = givenQueue(listOf(editedEvent))
queue.add(updatedEvent, seenEventIds = seenIdsCache)
queue.rawEvents() shouldBeEqualTo listOf(updatedEvent)
}
@Test
fun `when clearing membership notification then removes invite events with matching room id`() {
val roomId = "a-room-id"
val queue = givenQueue(listOf(
anInviteNotifiableEvent(roomId = roomId),
aNotifiableMessageEvent(roomId = roomId)
))
queue.clearMemberShipNotificationForRoom(roomId)
queue.rawEvents() shouldBeEqualTo listOf(aNotifiableMessageEvent(roomId = roomId))
}
@Test
fun `when clearing messages for room then removes message events with matching room id`() {
val roomId = "a-room-id"
val queue = givenQueue(listOf(
anInviteNotifiableEvent(roomId = roomId),
aNotifiableMessageEvent(roomId = roomId)
))
queue.clearMessagesForRoom(roomId)
queue.rawEvents() shouldBeEqualTo listOf(anInviteNotifiableEvent(roomId = roomId))
}
private fun givenQueue(events: List<NotifiableEvent>) = NotificationEventQueue(events.toMutableList())
}

View file

@ -20,6 +20,9 @@ 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 im.vector.app.test.fixtures.aNotifiableMessageEvent
import im.vector.app.test.fixtures.aSimpleNotifiableEvent
import im.vector.app.test.fixtures.anInviteNotifiableEvent
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test

View file

@ -0,0 +1,81 @@
/*
* 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.fixtures
import im.vector.app.features.notifications.InviteNotifiableEvent
import im.vector.app.features.notifications.NotifiableMessageEvent
import im.vector.app.features.notifications.SimpleNotifiableEvent
fun aSimpleNotifiableEvent(
eventId: String = "simple-event-id",
type: String? = null,
isRedacted: Boolean = false,
canBeReplaced: Boolean = false,
editedEventId: String? = null
) = SimpleNotifiableEvent(
matrixID = null,
eventId = eventId,
editedEventId = editedEventId,
noisy = false,
title = "title",
description = "description",
type = type,
timestamp = 0,
soundName = null,
canBeReplaced = canBeReplaced,
isRedacted = isRedacted
)
fun anInviteNotifiableEvent(
roomId: String = "an-invite-room-id",
eventId: String = "invite-event-id",
isRedacted: Boolean = false
) = InviteNotifiableEvent(
matrixID = null,
eventId = eventId,
roomId = roomId,
roomName = "a room name",
editedEventId = null,
noisy = false,
title = "title",
description = "description",
type = null,
timestamp = 0,
soundName = null,
canBeReplaced = false,
isRedacted = isRedacted
)
fun aNotifiableMessageEvent(
eventId: String = "a-message-event-id",
roomId: String = "a-message-room-id",
isRedacted: Boolean = false
) = 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 = isRedacted,
imageUri = null
)