Display room invitation notification (#735)
* Notifications: Add some extra mappings so we keep the original contents and can pass it later to an UI layer * Fix notifications not appearing for a room if the app was on that room when it went to background. * Modernize how we create spannable strings for notifications, remove unneeded dependency * Remove actions from invite notifications temporarily * Add `NotificationDrawerManager` interface to be able to clear membership notifications when accepting or rejecting a room invite * Fix tests * Add comment to clarify some weird behaviours * Address review comments * Set circle shape for `largeBitmap` in message notifications * Fix no avatar in DM rooms * Fix rebase issues * Add invite list pending intent: - Refactor pending intents. - Make `DeepLinkData` a sealed interface. - Fix and add tests. * Rename `navigate__` functions to `attach__` * Add an extra test case for the `InviteList` deep link * Address most review comments. * Fix rebase issue * Add fallback notification type, allow dismissing invite notifications. Fallback notifications have a different underlying type and can be dismissed at will. * Fix tests
This commit is contained in:
parent
0fbf799d15
commit
a0c1f2c18a
55 changed files with 905 additions and 327 deletions
|
|
@ -20,8 +20,7 @@ import com.squareup.anvil.annotations.ContributesBinding
|
|||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.push.api.PushService
|
||||
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager
|
||||
import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
|
||||
import io.element.android.libraries.pushproviders.api.Distributor
|
||||
import io.element.android.libraries.pushproviders.api.PushProvider
|
||||
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
||||
|
|
@ -29,13 +28,13 @@ import javax.inject.Inject
|
|||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPushService @Inject constructor(
|
||||
private val notificationDrawerManager: NotificationDrawerManager,
|
||||
private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
|
||||
private val pushersManager: PushersManager,
|
||||
private val userPushStoreFactory: UserPushStoreFactory,
|
||||
private val pushProviders: Set<@JvmSuppressWildcards PushProvider>,
|
||||
) : PushService {
|
||||
override fun notificationStyleChanged() {
|
||||
notificationDrawerManager.notificationStyleChanged()
|
||||
defaultNotificationDrawerManager.notificationStyleChanged()
|
||||
}
|
||||
|
||||
override fun getAvailablePushProviders(): List<PushProvider> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.di
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
|
||||
import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
|
||||
|
||||
@Module
|
||||
@ContributesTo(AppScope::class)
|
||||
abstract class PushBindsModule {
|
||||
@Binds
|
||||
abstract fun bindNotificationDrawerManager(
|
||||
defaultNotificationDrawerManager: DefaultNotificationDrawerManager
|
||||
): NotificationDrawerManager
|
||||
}
|
||||
|
|
@ -23,11 +23,16 @@ import io.element.android.libraries.matrix.api.core.ThreadId
|
|||
|
||||
interface IntentProvider {
|
||||
/**
|
||||
* Provide an intent to start the application.
|
||||
* Provide an intent to start the application on a room or thread.
|
||||
*/
|
||||
fun getViewIntent(
|
||||
fun getViewRoomIntent(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId?,
|
||||
threadId: ThreadId?,
|
||||
): Intent
|
||||
|
||||
/**
|
||||
* Provide an intent to start the application on the invite list.
|
||||
*/
|
||||
fun getInviteListIntent(sessionId: SessionId): Intent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,14 +24,16 @@ import io.element.android.libraries.core.meta.BuildMeta
|
|||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
|
||||
import io.element.android.libraries.push.api.store.PushDataStore
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom
|
||||
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
|
||||
import io.element.android.services.appnavstate.api.AppNavigationState
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -47,7 +49,7 @@ import javax.inject.Inject
|
|||
* Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning.
|
||||
*/
|
||||
@SingleIn(AppScope::class)
|
||||
class NotificationDrawerManager @Inject constructor(
|
||||
class DefaultNotificationDrawerManager @Inject constructor(
|
||||
private val pushDataStore: PushDataStore,
|
||||
private val notifiableEventProcessor: NotifiableEventProcessor,
|
||||
private val notificationRenderer: NotificationRenderer,
|
||||
|
|
@ -58,7 +60,7 @@ class NotificationDrawerManager @Inject constructor(
|
|||
private val dispatchers: CoroutineDispatchers,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val matrixAuthenticationService: MatrixAuthenticationService,
|
||||
) {
|
||||
) : NotificationDrawerManager {
|
||||
/**
|
||||
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
|
||||
*/
|
||||
|
|
@ -152,12 +154,27 @@ class NotificationDrawerManager @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
|
||||
updateEvents {
|
||||
it.clearMembershipNotificationForSession(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear invitation notification for the provided room.
|
||||
*/
|
||||
fun clearMemberShipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
|
||||
override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
|
||||
updateEvents {
|
||||
it.clearMemberShipNotificationForRoom(sessionId, roomId)
|
||||
it.clearMembershipNotificationForRoom(sessionId, roomId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the notifications for a single event.
|
||||
*/
|
||||
fun clearEvent(eventId: EventId) {
|
||||
updateEvents {
|
||||
it.clearEvent(eventId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +200,7 @@ class NotificationDrawerManager @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateEvents(action: NotificationDrawerManager.(NotificationEventQueue) -> Unit) {
|
||||
private fun updateEvents(action: DefaultNotificationDrawerManager.(NotificationEventQueue) -> Unit) {
|
||||
notificationState.updateQueuedEvents(this) { queuedEvents, _ ->
|
||||
action(queuedEvents)
|
||||
}
|
||||
|
|
@ -260,6 +277,6 @@ class NotificationDrawerManager @Inject constructor(
|
|||
}
|
||||
|
||||
fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean {
|
||||
return resolvedEvent.shouldIgnoreMessageEventInRoom(currentAppNavigationState)
|
||||
return resolvedEvent.shouldIgnoreEventInRoom(currentAppNavigationState)
|
||||
}
|
||||
}
|
||||
|
|
@ -17,11 +17,12 @@
|
|||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom
|
||||
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
|
||||
import io.element.android.services.appnavstate.api.AppNavigationState
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
|
@ -41,7 +42,7 @@ class NotifiableEventProcessor @Inject constructor(
|
|||
val type = when (it) {
|
||||
is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP
|
||||
is NotifiableMessageEvent -> when {
|
||||
it.shouldIgnoreMessageEventInRoom(appNavigationState) -> {
|
||||
it.shouldIgnoreEventInRoom(appNavigationState) -> {
|
||||
ProcessedEvent.Type.REMOVE
|
||||
.also { Timber.d("notification message removed due to currently viewing the same room or thread") }
|
||||
}
|
||||
|
|
@ -53,6 +54,13 @@ class NotifiableEventProcessor @Inject constructor(
|
|||
EventType.REDACTION -> ProcessedEvent.Type.REMOVE
|
||||
else -> ProcessedEvent.Type.KEEP
|
||||
}
|
||||
is FallbackNotifiableEvent -> when {
|
||||
it.shouldIgnoreEventInRoom(appNavigationState) -> {
|
||||
ProcessedEvent.Type.REMOVE
|
||||
.also { Timber.d("notification fallback removed due to currently viewing the same room or thread") }
|
||||
}
|
||||
else -> ProcessedEvent.Type.KEEP
|
||||
}
|
||||
}
|
||||
ProcessedEvent(type, it)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,26 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
|||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationContent
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationData
|
||||
import io.element.android.libraries.matrix.api.notification.NotificationEvent
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||
import io.element.android.libraries.push.impl.R
|
||||
import io.element.android.libraries.push.impl.log.pushLoggerTag
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import timber.log.Timber
|
||||
|
|
@ -53,73 +67,163 @@ class NotifiableEventResolver @Inject constructor(
|
|||
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
|
||||
// Restore session
|
||||
val session = matrixAuthenticationService.restoreSession(sessionId).getOrNull() ?: return null
|
||||
// TODO EAx, no need for a session?
|
||||
val notificationData = session.let {// TODO Use make the app crashes
|
||||
it.notificationService().getNotification(
|
||||
val notificationService = session.notificationService()
|
||||
val notificationData = notificationService.getNotification(
|
||||
userId = sessionId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
filterByPushRules = true,
|
||||
)
|
||||
}.fold(
|
||||
{
|
||||
it
|
||||
},
|
||||
{
|
||||
Timber.tag(loggerTag.value).e(it, "Unable to resolve event.")
|
||||
null
|
||||
// FIXME should be true in the future, but right now it's broken
|
||||
// (https://github.com/vector-im/element-x-android/issues/640#issuecomment-1612913658)
|
||||
filterByPushRules = false,
|
||||
).onFailure {
|
||||
Timber.tag(loggerTag.value).e(it, "Unable to resolve event: $eventId.")
|
||||
}.getOrNull()
|
||||
|
||||
// TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
|
||||
return notificationData?.asNotifiableEvent(sessionId)
|
||||
?: fallbackNotifiableEvent(sessionId, roomId, eventId)
|
||||
}
|
||||
|
||||
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent? {
|
||||
return when (val content = this.event.content) {
|
||||
is NotificationContent.MessageLike.RoomMessage -> {
|
||||
buildNotifiableMessageEvent(
|
||||
sessionId = userId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
noisy = isNoisy,
|
||||
timestamp = event.timestamp,
|
||||
senderName = senderDisplayName,
|
||||
senderId = senderId.value,
|
||||
body = descriptionFromMessageContent(content),
|
||||
imageUriString = event.contentUrl,
|
||||
roomName = roomDisplayName,
|
||||
roomIsDirect = isDirect,
|
||||
roomAvatarPath = roomAvatarUrl,
|
||||
senderAvatarPath = senderAvatarUrl,
|
||||
)
|
||||
}
|
||||
).orDefault(roomId, eventId)
|
||||
|
||||
return notificationData.asNotifiableEvent(sessionId)
|
||||
is NotificationContent.StateEvent.RoomMemberContent -> {
|
||||
if (content.membershipState == RoomMembershipState.INVITE) {
|
||||
InviteNotifiableEvent(
|
||||
sessionId = userId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
editedEventId = null,
|
||||
canBeReplaced = true,
|
||||
roomName = roomDisplayName,
|
||||
noisy = isNoisy,
|
||||
timestamp = event.timestamp,
|
||||
soundName = null,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
description = descriptionFromRoomMembershipContent(content, isDirect) ?: return null,
|
||||
type = null, // TODO check if type is needed anymore
|
||||
title = null, // TODO check if title is needed anymore
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent {
|
||||
return NotifiableMessageEvent(
|
||||
sessionId = userId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
editedEventId = null,
|
||||
canBeReplaced = true,
|
||||
noisy = isNoisy,
|
||||
timestamp = event.timestamp,
|
||||
senderName = senderDisplayName,
|
||||
senderId = senderId.value,
|
||||
body = event.content,
|
||||
imageUriString = event.contentUrl,
|
||||
threadId = null,
|
||||
roomName = roomDisplayName,
|
||||
roomIsDirect = isDirect,
|
||||
roomAvatarPath = roomAvatarUrl,
|
||||
senderAvatarPath = senderAvatarUrl,
|
||||
soundName = null,
|
||||
outGoingMessage = false,
|
||||
outGoingMessageFailed = false,
|
||||
isRedacted = false,
|
||||
isUpdated = false
|
||||
)
|
||||
private fun fallbackNotifiableEvent(
|
||||
userId: SessionId,
|
||||
roomId: RoomId,
|
||||
eventId: EventId
|
||||
) = FallbackNotifiableEvent(
|
||||
sessionId = userId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
editedEventId = null,
|
||||
canBeReplaced = true,
|
||||
isRedacted = false,
|
||||
isUpdated = false,
|
||||
timestamp = clock.epochMillis(),
|
||||
description = stringProvider.getString(R.string.notification_fallback_content),
|
||||
)
|
||||
|
||||
private fun descriptionFromMessageContent(
|
||||
content: NotificationContent.MessageLike.RoomMessage,
|
||||
): String {
|
||||
return when (val messageType = content.messageType) {
|
||||
is AudioMessageType -> messageType.body
|
||||
is EmoteMessageType -> messageType.body
|
||||
is FileMessageType -> messageType.body
|
||||
is ImageMessageType -> messageType.body
|
||||
is NoticeMessageType -> messageType.body
|
||||
is TextMessageType -> messageType.body
|
||||
is VideoMessageType -> messageType.body
|
||||
is LocationMessageType -> messageType.body
|
||||
is UnknownMessageType -> stringProvider.getString(CommonStrings.common_unsupported_event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO This is a temporary method for EAx.
|
||||
*/
|
||||
private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData {
|
||||
return this ?: NotificationData(
|
||||
eventId = eventId,
|
||||
senderId = UserId("@user:domain"),
|
||||
roomId = roomId,
|
||||
senderAvatarUrl = null,
|
||||
senderDisplayName = null,
|
||||
roomAvatarUrl = null,
|
||||
roomDisplayName = null,
|
||||
isNoisy = false,
|
||||
isEncrypted = false,
|
||||
isDirect = false,
|
||||
event = NotificationEvent(
|
||||
timestamp = clock.epochMillis(),
|
||||
content = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}…",
|
||||
contentUrl = null
|
||||
)
|
||||
)
|
||||
private fun descriptionFromRoomMembershipContent(
|
||||
content: NotificationContent.StateEvent.RoomMemberContent,
|
||||
isDirectRoom: Boolean
|
||||
): String? {
|
||||
return when (content.membershipState) {
|
||||
RoomMembershipState.INVITE -> {
|
||||
if (isDirectRoom) {
|
||||
stringProvider.getString(R.string.notification_invite_body)
|
||||
} else {
|
||||
stringProvider.getString(R.string.notification_room_invite_body)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun buildNotifiableMessageEvent(
|
||||
sessionId: SessionId,
|
||||
roomId: RoomId,
|
||||
eventId: EventId,
|
||||
editedEventId: EventId? = null,
|
||||
canBeReplaced: Boolean = false,
|
||||
noisy: Boolean,
|
||||
timestamp: Long,
|
||||
senderName: String?,
|
||||
senderId: String?,
|
||||
body: String?,
|
||||
// We cannot use Uri? type here, as that could trigger a
|
||||
// NotSerializableException when persisting this to storage
|
||||
imageUriString: String? = null,
|
||||
threadId: ThreadId? = null,
|
||||
roomName: String? = null,
|
||||
roomIsDirect: Boolean = false,
|
||||
roomAvatarPath: String? = null,
|
||||
senderAvatarPath: String? = null,
|
||||
soundName: String? = null,
|
||||
// This is used for >N notification, as the result of a smart reply
|
||||
outGoingMessage: Boolean = false,
|
||||
outGoingMessageFailed: Boolean = false,
|
||||
isRedacted: Boolean = false,
|
||||
isUpdated: Boolean = false
|
||||
) = NotifiableMessageEvent(
|
||||
sessionId = sessionId,
|
||||
roomId = roomId,
|
||||
eventId = eventId,
|
||||
editedEventId = editedEventId,
|
||||
canBeReplaced = canBeReplaced,
|
||||
noisy = noisy,
|
||||
timestamp = timestamp,
|
||||
senderName = senderName,
|
||||
senderId = senderId,
|
||||
body = body,
|
||||
imageUriString = imageUriString,
|
||||
threadId = threadId,
|
||||
roomName = roomName,
|
||||
roomIsDirect = roomIsDirect,
|
||||
roomAvatarPath = roomAvatarPath,
|
||||
senderAvatarPath = senderAvatarPath,
|
||||
soundName = soundName,
|
||||
outGoingMessage = outGoingMessage,
|
||||
outGoingMessageFailed = outGoingMessageFailed,
|
||||
isRedacted = isRedacted,
|
||||
isUpdated = isUpdated
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ data class NotificationActionIds @Inject constructor(
|
|||
val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION"
|
||||
val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION"
|
||||
val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION"
|
||||
val dismissInvite = "${buildMeta.applicationId}.NotificationActions.DISMISS_INVITE_NOTIF_ACTION"
|
||||
val dismissEvent = "${buildMeta.applicationId}.NotificationActions.DISMISS_EVENT_NOTIF_ACTION"
|
||||
val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC"
|
||||
val push = "${buildMeta.applicationId}.PUSH"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class NotificationBitmapLoader @Inject constructor(
|
|||
return try {
|
||||
val imageRequest = ImageRequest.Builder(context)
|
||||
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024)))
|
||||
.transformations(CircleCropTransformation())
|
||||
.build()
|
||||
val result = context.imageLoader.execute(imageRequest)
|
||||
result.drawable?.toBitmap()
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import android.content.Intent
|
|||
import androidx.core.app.RemoteInput
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
|
|
@ -37,7 +38,7 @@ private val loggerTag = LoggerTag("NotificationBroadcastReceiver", notificationL
|
|||
*/
|
||||
class NotificationBroadcastReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var notificationDrawerManager: NotificationDrawerManager
|
||||
@Inject lateinit var defaultNotificationDrawerManager: DefaultNotificationDrawerManager
|
||||
|
||||
//@Inject lateinit var activeSessionHolder: ActiveSessionHolder
|
||||
//@Inject lateinit var analyticsTracker: AnalyticsTracker
|
||||
|
|
@ -50,24 +51,31 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
|
|||
Timber.tag(loggerTag.value).v("NotificationBroadcastReceiver received : $intent")
|
||||
val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return
|
||||
val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId)
|
||||
val eventId = intent.getStringExtra(KEY_EVENT_ID)?.let(::EventId)
|
||||
when (intent.action) {
|
||||
actionIds.smartReply ->
|
||||
handleSmartReply(intent, context)
|
||||
actionIds.dismissRoom -> if (roomId != null) {
|
||||
notificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
|
||||
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
|
||||
}
|
||||
actionIds.dismissSummary ->
|
||||
notificationDrawerManager.clearAllEvents(sessionId)
|
||||
defaultNotificationDrawerManager.clearAllEvents(sessionId)
|
||||
actionIds.dismissInvite -> if (roomId != null) {
|
||||
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
|
||||
}
|
||||
actionIds.dismissEvent -> if (eventId != null) {
|
||||
defaultNotificationDrawerManager.clearEvent(eventId)
|
||||
}
|
||||
actionIds.markRoomRead -> if (roomId != null) {
|
||||
notificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
|
||||
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
|
||||
handleMarkAsRead(sessionId, roomId)
|
||||
}
|
||||
actionIds.join -> if (roomId != null) {
|
||||
notificationDrawerManager.clearMemberShipNotificationForRoom(sessionId, roomId)
|
||||
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
|
||||
handleJoinRoom(sessionId, roomId)
|
||||
}
|
||||
actionIds.reject -> if (roomId != null) {
|
||||
notificationDrawerManager.clearMemberShipNotificationForRoom(sessionId, roomId)
|
||||
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
|
||||
handleRejectRoom(sessionId, roomId)
|
||||
}
|
||||
}
|
||||
|
|
@ -240,6 +248,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
|
|||
const val KEY_SESSION_ID = "sessionID"
|
||||
const val KEY_ROOM_ID = "roomID"
|
||||
const val KEY_THREAD_ID = "threadID"
|
||||
const val KEY_EVENT_ID = "eventID"
|
||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
|
|
@ -45,6 +46,7 @@ data class NotificationEventQueue constructor(
|
|||
is InviteNotifiableEvent -> it.copy(isRedacted = true)
|
||||
is NotifiableMessageEvent -> it.copy(isRedacted = true)
|
||||
is SimpleNotifiableEvent -> it.copy(isRedacted = true)
|
||||
is FallbackNotifiableEvent -> it.copy(isRedacted = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +59,8 @@ data class NotificationEventQueue constructor(
|
|||
when (it) {
|
||||
is NotifiableMessageEvent -> roomsLeft.contains(it.roomId)
|
||||
is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId)
|
||||
else -> false
|
||||
is SimpleNotifiableEvent -> false
|
||||
is FallbackNotifiableEvent -> roomsLeft.contains(it.roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -127,11 +130,21 @@ data class NotificationEventQueue constructor(
|
|||
is InviteNotifiableEvent -> with.copy(isUpdated = true)
|
||||
is NotifiableMessageEvent -> with.copy(isUpdated = true)
|
||||
is SimpleNotifiableEvent -> with.copy(isUpdated = true)
|
||||
is FallbackNotifiableEvent -> with.copy(isUpdated = true)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun clearMemberShipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
|
||||
fun clearEvent(eventId: EventId) {
|
||||
queue.removeAll { it.eventId == eventId }
|
||||
}
|
||||
|
||||
fun clearMembershipNotificationForSession(sessionId: SessionId) {
|
||||
Timber.d("clearMemberShipOfSession $sessionId")
|
||||
queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId }
|
||||
}
|
||||
|
||||
fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
|
||||
Timber.d("clearMemberShipOfRoom $sessionId, $roomId")
|
||||
queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import android.app.Notification
|
|||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||
|
|
@ -94,16 +95,35 @@ class NotificationFactory @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun List<ProcessedEvent<FallbackNotifiableEvent>>.toNotifications(): List<OneShotNotification> {
|
||||
return map { (processed, event) ->
|
||||
when (processed) {
|
||||
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value)
|
||||
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
|
||||
notificationFactory.createFallbackNotification(event),
|
||||
OneShotNotification.Append.Meta(
|
||||
key = event.eventId.value,
|
||||
summaryLine = event.description.orEmpty(),
|
||||
isNoisy = false,
|
||||
timestamp = event.timestamp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createSummaryNotification(
|
||||
currentUser: MatrixUser,
|
||||
roomNotifications: List<RoomNotification>,
|
||||
invitationNotifications: List<OneShotNotification>,
|
||||
simpleNotifications: List<OneShotNotification>,
|
||||
fallbackNotifications: 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 }
|
||||
val fallbackMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
|
||||
return when {
|
||||
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
|
||||
else -> SummaryNotification.Update(
|
||||
|
|
@ -112,6 +132,7 @@ class NotificationFactory @Inject constructor(
|
|||
roomNotifications = roomMeta,
|
||||
invitationNotifications = invitationMeta,
|
||||
simpleNotifications = simpleMeta,
|
||||
fallbackNotifications = fallbackMeta,
|
||||
useCompleteNotificationFormat = useCompleteNotificationFormat
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,12 +37,17 @@ class NotificationIdProvider @Inject constructor() {
|
|||
return getOffset(sessionId) + ROOM_INVITATION_NOTIFICATION_ID
|
||||
}
|
||||
|
||||
fun getFallbackNotificationId(sessionId: SessionId): Int {
|
||||
return getOffset(sessionId) + FALLBACK_NOTIFICATION_ID
|
||||
}
|
||||
|
||||
private fun getOffset(sessionId: SessionId): Int {
|
||||
// Compute a int from a string with a low risk of collision.
|
||||
return abs(sessionId.value.hashCode() % 100_000) * 10
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FALLBACK_NOTIFICATION_ID = -1
|
||||
private const val SUMMARY_NOTIFICATION_ID = 0
|
||||
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1
|
||||
private const val ROOM_EVENT_NOTIFICATION_ID = 2
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package io.element.android.libraries.push.impl.notifications
|
|||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
|
|
@ -36,16 +37,18 @@ class NotificationRenderer @Inject constructor(
|
|||
useCompleteNotificationFormat: Boolean,
|
||||
eventsToProcess: List<ProcessedEvent<NotifiableEvent>>
|
||||
) {
|
||||
val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType()
|
||||
val groupedEvents = eventsToProcess.groupByType()
|
||||
with(notificationFactory) {
|
||||
val roomNotifications = roomEvents.toNotifications(currentUser)
|
||||
val invitationNotifications = invitationEvents.toNotifications()
|
||||
val simpleNotifications = simpleEvents.toNotifications()
|
||||
val roomNotifications = groupedEvents.roomEvents.toNotifications(currentUser)
|
||||
val invitationNotifications = groupedEvents.invitationEvents.toNotifications()
|
||||
val simpleNotifications = groupedEvents.simpleEvents.toNotifications()
|
||||
val fallbackNotifications = groupedEvents.fallbackEvents.toNotifications()
|
||||
val summaryNotification = createSummaryNotification(
|
||||
currentUser = currentUser,
|
||||
roomNotifications = roomNotifications,
|
||||
invitationNotifications = invitationNotifications,
|
||||
simpleNotifications = simpleNotifications,
|
||||
fallbackNotifications = fallbackNotifications,
|
||||
useCompleteNotificationFormat = useCompleteNotificationFormat
|
||||
)
|
||||
|
||||
|
|
@ -118,6 +121,26 @@ class NotificationRenderer @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fallbackNotifications.forEach { wrapper ->
|
||||
when (wrapper) {
|
||||
is OneShotNotification.Removed -> {
|
||||
Timber.d("Removing fallback notification ${wrapper.key}")
|
||||
notificationDisplayer.cancelNotificationMessage(
|
||||
tag = wrapper.key,
|
||||
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId)
|
||||
)
|
||||
}
|
||||
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
|
||||
Timber.d("Updating fallback notification ${wrapper.meta.key}")
|
||||
notificationDisplayer.showNotificationMessage(
|
||||
tag = wrapper.meta.key,
|
||||
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId),
|
||||
notification = wrapper.notification
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update summary last to avoid briefly displaying it before other notifications
|
||||
if (summaryNotification is SummaryNotification.Update) {
|
||||
Timber.d("Updating summary notification")
|
||||
|
|
@ -139,6 +162,7 @@ private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotifica
|
|||
val roomIdToEventMap: MutableMap<RoomId, MutableList<ProcessedEvent<NotifiableMessageEvent>>> = LinkedHashMap()
|
||||
val simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList()
|
||||
val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = ArrayList()
|
||||
val fallbackEvents: MutableList<ProcessedEvent<FallbackNotifiableEvent>> = ArrayList()
|
||||
forEach {
|
||||
when (val event = it.event) {
|
||||
is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType())
|
||||
|
|
@ -147,9 +171,12 @@ private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotifica
|
|||
roomEvents.add(it.castedToEventType())
|
||||
}
|
||||
is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType())
|
||||
is FallbackNotifiableEvent -> {
|
||||
fallbackEvents.add(it.castedToEventType())
|
||||
}
|
||||
}
|
||||
}
|
||||
return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents)
|
||||
return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents, fallbackEvents)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
|
|
@ -158,5 +185,6 @@ private fun <T : NotifiableEvent> ProcessedEvent<NotifiableEvent>.castedToEventT
|
|||
data class GroupedNotificationEvents(
|
||||
val roomEvents: Map<RoomId, List<ProcessedEvent<NotifiableMessageEvent>>>,
|
||||
val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>,
|
||||
val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>
|
||||
val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>,
|
||||
val fallbackEvents: List<ProcessedEvent<FallbackNotifiableEvent>>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ class NotificationState(
|
|||
) {
|
||||
|
||||
fun <T> updateQueuedEvents(
|
||||
drawerManager: NotificationDrawerManager,
|
||||
action: NotificationDrawerManager.(NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T
|
||||
drawerManager: DefaultNotificationDrawerManager,
|
||||
action: DefaultNotificationDrawerManager.(NotificationEventQueue, List<ProcessedEvent<NotifiableEvent>>) -> T
|
||||
): T {
|
||||
return synchronized(queuedEvents) {
|
||||
action(drawerManager, queuedEvents, renderedEvents)
|
||||
|
|
|
|||
|
|
@ -17,8 +17,12 @@
|
|||
package io.element.android.libraries.push.impl.notifications
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Typeface
|
||||
import android.text.style.StyleSpan
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.push.impl.R
|
||||
|
|
@ -26,8 +30,6 @@ import io.element.android.libraries.push.impl.notifications.debug.annotateForDeb
|
|||
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
|
||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import me.gujun.android.span.Span
|
||||
import me.gujun.android.span.span
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -151,30 +153,31 @@ class RoomGroupMessageCreator @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): Span {
|
||||
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence {
|
||||
return if (roomIsDirect) {
|
||||
span {
|
||||
span {
|
||||
textStyle = "bold"
|
||||
+String.format("%s: ", event.senderName)
|
||||
buildSpannedString {
|
||||
inSpans(StyleSpan(Typeface.BOLD)) {
|
||||
append(event.senderName)
|
||||
append(": ")
|
||||
}
|
||||
+(event.description)
|
||||
append(event.description)
|
||||
}
|
||||
} else {
|
||||
span {
|
||||
span {
|
||||
textStyle = "bold"
|
||||
+String.format("%s: %s ", roomName, event.senderName)
|
||||
buildSpannedString {
|
||||
inSpans(StyleSpan(Typeface.BOLD)) {
|
||||
append(roomName)
|
||||
append(": ")
|
||||
event.senderName
|
||||
append(" ")
|
||||
}
|
||||
+(event.description)
|
||||
append(event.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? {
|
||||
// Use the last event (most recent?)
|
||||
return events.lastOrNull()
|
||||
?.roomAvatarPath
|
||||
return events.reversed().firstNotNullOfOrNull { it.roomAvatarPath }
|
||||
?.let { bitmapLoader.getRoomBitmap(it) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,12 +49,14 @@ class SummaryGroupMessageCreator @Inject constructor(
|
|||
roomNotifications: List<RoomNotification.Message.Meta>,
|
||||
invitationNotifications: List<OneShotNotification.Append.Meta>,
|
||||
simpleNotifications: List<OneShotNotification.Append.Meta>,
|
||||
fallbackNotifications: List<OneShotNotification.Append.Meta>,
|
||||
useCompleteNotificationFormat: Boolean
|
||||
): Notification {
|
||||
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->
|
||||
roomNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(40)) }
|
||||
invitationNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(41)) }
|
||||
simpleNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(42)) }
|
||||
fallbackNotifications.forEach { style.addLine(it.summaryLine) }
|
||||
}
|
||||
|
||||
val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
|
||||
|
|
|
|||
|
|
@ -32,10 +32,9 @@ import io.element.android.libraries.push.impl.R
|
|||
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
|
||||
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
|
||||
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
|
||||
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory
|
||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
|
|
@ -49,8 +48,6 @@ class NotificationFactory @Inject constructor(
|
|||
private val pendingIntentFactory: PendingIntentFactory,
|
||||
private val markAsReadActionFactory: MarkAsReadActionFactory,
|
||||
private val quickReplyActionFactory: QuickReplyActionFactory,
|
||||
private val rejectInvitationActionFactory: RejectInvitationActionFactory,
|
||||
private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
|
||||
) {
|
||||
/**
|
||||
* Create a notification for a Room.
|
||||
|
|
@ -154,22 +151,12 @@ class NotificationFactory @Inject constructor(
|
|||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
||||
.setSmallIcon(smallIcon)
|
||||
.setColor(accentColor)
|
||||
.addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
|
||||
.addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
|
||||
// TODO removed for now, will be added back later
|
||||
// .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
|
||||
// .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
|
||||
.apply {
|
||||
/*
|
||||
// Build the pending intent for when the notification is clicked
|
||||
val contentIntent = HomeActivity.newIntent(
|
||||
context,
|
||||
firstStartMainActivity = true,
|
||||
inviteNotificationRoomId = inviteNotifiableEvent.roomId
|
||||
)
|
||||
contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
|
||||
contentIntent.data = createIgnoredUri(inviteNotifiableEvent.eventId)
|
||||
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
|
||||
|
||||
*/
|
||||
setContentIntent(pendingIntentFactory.createInviteListPendingIntent(inviteNotifiableEvent.sessionId))
|
||||
|
||||
if (inviteNotifiableEvent.noisy) {
|
||||
// Compat
|
||||
|
|
@ -183,6 +170,12 @@ class NotificationFactory @Inject constructor(
|
|||
} else {
|
||||
priority = NotificationCompat.PRIORITY_LOW
|
||||
}
|
||||
setDeleteIntent(
|
||||
pendingIntentFactory.createDismissInvitePendingIntent(
|
||||
inviteNotifiableEvent.sessionId,
|
||||
inviteNotifiableEvent.roomId,
|
||||
)
|
||||
)
|
||||
setAutoCancel(true)
|
||||
}
|
||||
.build()
|
||||
|
|
@ -223,6 +216,39 @@ class NotificationFactory @Inject constructor(
|
|||
.build()
|
||||
}
|
||||
|
||||
fun createFallbackNotification(
|
||||
fallbackNotifiableEvent: FallbackNotifiableEvent,
|
||||
): Notification {
|
||||
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
|
||||
val smallIcon = R.drawable.ic_notification
|
||||
|
||||
val channelId = notificationChannels.getChannelIdForMessage(false)
|
||||
return NotificationCompat.Builder(context, channelId)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentTitle(buildMeta.applicationName.annotateForDebug(7))
|
||||
.setContentText(fallbackNotifiableEvent.description.orEmpty().annotateForDebug(8))
|
||||
.setGroup(fallbackNotifiableEvent.sessionId.value)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
|
||||
.setSmallIcon(smallIcon)
|
||||
.setColor(accentColor)
|
||||
.setAutoCancel(true)
|
||||
// Ideally we'd use `createOpenRoomPendingIntent` here, but the broken notification might apply to an invite
|
||||
// and the user won't have access to the room yet, resulting in an error screen.
|
||||
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(fallbackNotifiableEvent.sessionId))
|
||||
.setDeleteIntent(
|
||||
pendingIntentFactory.createDismissEventPendingIntent(
|
||||
fallbackNotifiableEvent.sessionId,
|
||||
fallbackNotifiableEvent.roomId,
|
||||
fallbackNotifiableEvent.eventId
|
||||
)
|
||||
)
|
||||
.apply {
|
||||
priority = NotificationCompat.PRIORITY_LOW
|
||||
setAutoCancel(true)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the summary notification.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ package io.element.android.libraries.push.impl.notifications.factories
|
|||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import io.element.android.libraries.androidutils.uri.createIgnoredUri
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
|
|
@ -39,19 +41,19 @@ class PendingIntentFactory @Inject constructor(
|
|||
private val actionIds: NotificationActionIds,
|
||||
) {
|
||||
fun createOpenSessionPendingIntent(sessionId: SessionId): PendingIntent? {
|
||||
return createPendingIntent(sessionId = sessionId, roomId = null, threadId = null)
|
||||
return createRoomPendingIntent(sessionId = sessionId, roomId = null, threadId = null)
|
||||
}
|
||||
|
||||
fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? {
|
||||
return createPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null)
|
||||
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null)
|
||||
}
|
||||
|
||||
fun createOpenThreadPendingIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? {
|
||||
return createPendingIntent(sessionId = roomInfo.sessionId, roomId = roomInfo.roomId, threadId = threadId)
|
||||
return createRoomPendingIntent(sessionId = roomInfo.sessionId, roomId = roomInfo.roomId, threadId = threadId)
|
||||
}
|
||||
|
||||
private fun createPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? {
|
||||
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
|
||||
private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? {
|
||||
val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
|
|
@ -87,6 +89,35 @@ class PendingIntentFactory @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
fun createDismissInvitePendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent {
|
||||
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||
intent.action = actionIds.dismissInvite
|
||||
intent.data = createIgnoredUri("deleteInvite/$sessionId/$roomId")
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
fun createDismissEventPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId): PendingIntent {
|
||||
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
|
||||
intent.action = actionIds.dismissEvent
|
||||
intent.data = createIgnoredUri("deleteEvent/$sessionId/$roomId")
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
|
||||
intent.putExtra(NotificationBroadcastReceiver.KEY_EVENT_ID, eventId.value)
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
clock.epochMillis().toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
fun createTestPendingIntent(): PendingIntent? {
|
||||
val testActionIntent = Intent(context, TestNotificationReceiver::class.java)
|
||||
testActionIntent.action = actionIds.diagnostic
|
||||
|
|
@ -97,4 +128,9 @@ class PendingIntentFactory @Inject constructor(
|
|||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
fun createInviteListPendingIntent(sessionId: SessionId): PendingIntent {
|
||||
val intent = intentProvider.getInviteListIntent(sessionId)
|
||||
return PendingIntentCompat.getActivity(context, 0, intent, 0, false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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 io.element.android.libraries.push.impl.notifications.model
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Used for notifications with events that couldn't be retrieved or decrypted, so we don't know their contents.
|
||||
* These are created separately from message notifications, so they can be displayed differently.
|
||||
*/
|
||||
data class FallbackNotifiableEvent(
|
||||
override val sessionId: SessionId,
|
||||
override val roomId: RoomId,
|
||||
override val eventId: EventId,
|
||||
override val editedEventId: EventId?,
|
||||
override val description: String?,
|
||||
override val canBeReplaced: Boolean,
|
||||
override val isRedacted: Boolean,
|
||||
override val isUpdated: Boolean,
|
||||
val timestamp: Long,
|
||||
) : NotifiableEvent
|
||||
|
|
@ -27,8 +27,8 @@ data class InviteNotifiableEvent(
|
|||
override val canBeReplaced: Boolean,
|
||||
val roomName: String?,
|
||||
val noisy: Boolean,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val title: String?,
|
||||
override val description: String,
|
||||
val type: String?,
|
||||
val timestamp: Long,
|
||||
val soundName: String?,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ sealed interface NotifiableEvent : Serializable {
|
|||
val roomId: RoomId
|
||||
val eventId: EventId
|
||||
val editedEventId: EventId?
|
||||
val description: String?
|
||||
|
||||
// Used to know if event should be replaced with the one coming from eventstream
|
||||
val canBeReplaced: Boolean
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
package io.element.android.libraries.push.impl.notifications.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
|
@ -54,7 +56,7 @@ data class NotifiableMessageEvent(
|
|||
) : NotifiableEvent {
|
||||
|
||||
val type: String = EventType.MESSAGE
|
||||
val description: String = body ?: ""
|
||||
override val description: String = body ?: ""
|
||||
val title: String = senderName ?: ""
|
||||
|
||||
// TODO EAx The image has to be downloaded and expose using the file provider.
|
||||
|
|
@ -64,12 +66,21 @@ data class NotifiableMessageEvent(
|
|||
get() = imageUriString?.let { Uri.parse(it) }
|
||||
}
|
||||
|
||||
fun NotifiableMessageEvent.shouldIgnoreMessageEventInRoom(
|
||||
/**
|
||||
* Used to check if a notification should be ignored based on the current app and navigation state.
|
||||
*/
|
||||
fun NotifiableEvent.shouldIgnoreEventInRoom(
|
||||
appNavigationState: AppNavigationState?
|
||||
): Boolean {
|
||||
val currentSessionId = appNavigationState?.currentSessionId() ?: return false
|
||||
return when (val currentRoomId = appNavigationState.currentRoomId()) {
|
||||
null -> false
|
||||
else -> sessionId == currentSessionId && roomId == currentRoomId && threadId == appNavigationState.currentThreadId()
|
||||
else -> isAppInForeground
|
||||
&& sessionId == currentSessionId
|
||||
&& roomId == currentRoomId
|
||||
&& (this as? NotifiableMessageEvent)?.threadId == appNavigationState.currentThreadId()
|
||||
}
|
||||
}
|
||||
|
||||
private val isAppInForeground: Boolean
|
||||
get() = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ data class SimpleNotifiableEvent(
|
|||
override val editedEventId: EventId?,
|
||||
val noisy: Boolean,
|
||||
val title: String,
|
||||
val description: String,
|
||||
override val description: String,
|
||||
val type: String?,
|
||||
val timestamp: Long,
|
||||
val soundName: String?,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import io.element.android.libraries.push.impl.PushersManager
|
|||
import io.element.android.libraries.push.impl.log.pushLoggerTag
|
||||
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
|
||||
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager
|
||||
import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager
|
||||
import io.element.android.libraries.push.impl.store.DefaultPushDataStore
|
||||
import io.element.android.libraries.pushproviders.api.PushData
|
||||
import io.element.android.libraries.pushproviders.api.PushHandler
|
||||
|
|
@ -48,7 +48,7 @@ private val loggerTag = LoggerTag("PushHandler", pushLoggerTag)
|
|||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPushHandler @Inject constructor(
|
||||
private val notificationDrawerManager: NotificationDrawerManager,
|
||||
private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
|
||||
private val notifiableEventResolver: NotifiableEventResolver,
|
||||
private val defaultPushDataStore: DefaultPushDataStore,
|
||||
private val userPushStoreFactory: UserPushStoreFactory,
|
||||
|
|
@ -121,9 +121,9 @@ class DefaultPushHandler @Inject constructor(
|
|||
return
|
||||
}
|
||||
|
||||
val notificationData = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId)
|
||||
val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId)
|
||||
|
||||
if (notificationData == null) {
|
||||
if (notifiableEvent == null) {
|
||||
Timber.w("Unable to get a notification data")
|
||||
return
|
||||
}
|
||||
|
|
@ -135,7 +135,7 @@ class DefaultPushHandler @Inject constructor(
|
|||
return
|
||||
}
|
||||
|
||||
notificationDrawerManager.onNotifiableEventReceived(notificationData)
|
||||
defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<string name="notification_channel_listening_for_events">"Listening for events"</string>
|
||||
<string name="notification_channel_noisy">"Noisy notifications"</string>
|
||||
<string name="notification_channel_silent">"Silent notifications"</string>
|
||||
<string name="notification_fallback_content">"Notification"</string>
|
||||
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
|
||||
<string name="notification_invitation_action_join">"Join"</string>
|
||||
<string name="notification_invitation_action_reject">"Reject"</string>
|
||||
|
|
@ -47,6 +48,5 @@
|
|||
<string name="push_distributor_background_sync_android">"Background synchronization"</string>
|
||||
<string name="push_distributor_firebase_android">"Google Services"</string>
|
||||
<string name="push_no_valid_google_play_services_apk_android">"No valid Google Play Services found. Notifications may not work properly."</string>
|
||||
<string name="notification_fallback_content">"Notification"</string>
|
||||
<string name="notification_room_action_quick_reply">"Quick reply"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ class NotifiableEventProcessorTest {
|
|||
@Test
|
||||
fun `given viewing the same room main timeline when processing main timeline message event then removes message`() {
|
||||
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null))
|
||||
events.forEach { outdatedDetector.givenEventIsOutOfDate(it) }
|
||||
|
||||
val result = eventProcessor.process(events, VIEWING_A_ROOM, renderedEvents = emptyList())
|
||||
|
||||
|
|
@ -133,6 +134,7 @@ class NotifiableEventProcessorTest {
|
|||
@Test
|
||||
fun `given viewing the same thread timeline when processing thread message event then removes message`() {
|
||||
val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID))
|
||||
events.forEach { outdatedDetector.givenEventIsOutOfDate(it) }
|
||||
|
||||
val result = eventProcessor.process(events, VIEWING_A_THREAD, renderedEvents = emptyList())
|
||||
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ class NotificationEventQueueTest {
|
|||
)
|
||||
)
|
||||
|
||||
queue.clearMemberShipNotificationForRoom(A_SESSION_ID, A_ROOM_ID)
|
||||
queue.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID)
|
||||
|
||||
assertThat(queue.rawEvents()).isEqualTo(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ private const val MY_USER_AVATAR_URL = "avatar-url"
|
|||
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_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList(), emptyList())
|
||||
private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk())
|
||||
private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed
|
||||
private val A_NOTIFICATION = mockk<Notification>()
|
||||
|
|
@ -202,13 +202,14 @@ class NotificationRendererTest {
|
|||
}
|
||||
|
||||
private fun givenNoNotifications() {
|
||||
givenNotifications(emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION)
|
||||
givenNotifications(emptyList(), 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(),
|
||||
fallbackNotifications: List<OneShotNotification> = emptyList(),
|
||||
useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT,
|
||||
summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION
|
||||
) {
|
||||
|
|
@ -219,6 +220,7 @@ class NotificationRendererTest {
|
|||
roomNotifications = roomNotifications,
|
||||
invitationNotifications = invitationNotifications,
|
||||
simpleNotifications = simpleNotifications,
|
||||
fallbackNotifications = fallbackNotifications,
|
||||
summaryNotification = summaryNotification
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,12 +36,14 @@ class FakeNotificationFactory {
|
|||
roomNotifications: List<RoomNotification>,
|
||||
invitationNotifications: List<OneShotNotification>,
|
||||
simpleNotifications: List<OneShotNotification>,
|
||||
fallbackNotifications: List<OneShotNotification>,
|
||||
summaryNotification: SummaryNotification
|
||||
) {
|
||||
with(instance) {
|
||||
coEvery { groupedEvents.roomEvents.toNotifications(matrixUser) } returns roomNotifications
|
||||
every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications
|
||||
every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications
|
||||
every { groupedEvents.fallbackEvents.toNotifications() } returns fallbackNotifications
|
||||
|
||||
every {
|
||||
createSummaryNotification(
|
||||
|
|
@ -49,6 +51,7 @@ class FakeNotificationFactory {
|
|||
roomNotifications,
|
||||
invitationNotifications,
|
||||
simpleNotifications,
|
||||
fallbackNotifications,
|
||||
useCompleteNotificationFormat
|
||||
)
|
||||
} returns summaryNotification
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue