Browse Source

Making progress on notification for multi account.

test/jme/compound-poc
Benoit Marty 1 year ago committed by Benoit Marty
parent
commit
7e7e798acf
  1. 5
      app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt
  2. 6
      libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt
  3. 1
      libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt
  4. 1
      libraries/push/impl/build.gradle.kts
  5. 10
      libraries/push/impl/src/main/AndroidManifest.xml
  6. 8
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt
  7. 8
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt
  8. 1
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt
  9. 9
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt
  10. 311
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt
  11. 1
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt
  12. 31
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt
  13. 2
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt
  14. 118
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt
  15. 27
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt
  16. 96
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt
  17. 52
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt
  18. 51
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt
  19. 154
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt
  20. 3
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt
  21. 17
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt
  22. 5
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt
  23. 28
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt
  24. 2
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt
  25. 17
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt
  26. 25
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt
  27. 101
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt
  28. 1
      libraries/push/impl/src/main/res/values/temporary.xml

5
app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt

@ -33,4 +33,9 @@ class IntentProviderImpl @Inject constructor(
override fun getMainIntent(): Intent { override fun getMainIntent(): Intent {
return Intent(context, MainActivity::class.java) return Intent(context, MainActivity::class.java)
} }
override fun getIntent(sessionId: String, roomId: String?, threadId: String?): Intent {
// TODO Handle deeplink or pass parameters
return Intent(context, MainActivity::class.java)
}
} }

6
libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt

@ -19,12 +19,6 @@ package io.element.android.libraries.push.api
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
interface PushService { interface PushService {
// TODO EAx remove
fun setCurrentRoom(roomId: String?)
// TODO EAx remove
fun setCurrentThread(threadId: String?)
fun notificationStyleChanged() fun notificationStyleChanged()
// Ensure pusher is registered // Ensure pusher is registered

1
libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt

@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.Flow
interface PushDataStore { interface PushDataStore {
val pushCounterFlow: Flow<Int> val pushCounterFlow: Flow<Int>
// TODO Move all those settings to the per user store...
fun areNotificationEnabledForDevice(): Boolean fun areNotificationEnabledForDevice(): Boolean
fun setNotificationEnabledForDevice(enabled: Boolean) fun setNotificationEnabledForDevice(enabled: Boolean)

1
libraries/push/impl/build.gradle.kts

@ -46,6 +46,7 @@ dependencies {
api(projects.libraries.push.api) api(projects.libraries.push.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.services.appnavstate.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
api("me.gujun.android:span:1.7") { api("me.gujun.android:span:1.7") {

10
libraries/push/impl/src/main/AndroidManifest.xml

@ -60,5 +60,15 @@
<action android:name="org.unifiedpush.android.distributor.REGISTER" /> <action android:name="org.unifiedpush.android.distributor.REGISTER" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver
android:name=".notifications.TestNotificationReceiver"
android:exported="false" />
<receiver
android:name=".notifications.NotificationBroadcastReceiver"
android:enabled="true"
android:exported="false" />
</application> </application>
</manifest> </manifest>

8
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt

@ -32,14 +32,6 @@ class DefaultPushService @Inject constructor(
private val pushersManager: PushersManager, private val pushersManager: PushersManager,
private val fcmHelper: FcmHelper, private val fcmHelper: FcmHelper,
) : PushService { ) : PushService {
override fun setCurrentRoom(roomId: String?) {
notificationDrawerManager.setCurrentRoom(roomId)
}
override fun setCurrentThread(threadId: String?) {
notificationDrawerManager.setCurrentThread(threadId)
}
override fun notificationStyleChanged() { override fun notificationStyleChanged() {
notificationDrawerManager.notificationStyleChanged() notificationDrawerManager.notificationStyleChanged()
} }

8
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt

@ -20,7 +20,13 @@ import android.content.Intent
interface IntentProvider { interface IntentProvider {
/** /**
* Provide an intent to start the application * Provide an intent to start the application.
*/ */
fun getMainIntent(): Intent fun getMainIntent(): Intent
fun getIntent(
sessionId: String,
roomId: String?,
threadId: String?,
): Intent
} }

1
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/log/LoggerTag.kt

@ -19,3 +19,4 @@ package io.element.android.libraries.push.impl.log
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
internal val pushLoggerTag = LoggerTag("Push") internal val pushLoggerTag = LoggerTag("Push")
internal val notificationLoggerTag = LoggerTag("Notification", pushLoggerTag)

9
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt

@ -17,6 +17,7 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.push.impl.notifications.model.* import io.element.android.libraries.push.impl.notifications.model.*
import io.element.android.services.appnavstate.api.AppNavigationState
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -26,12 +27,16 @@ class NotifiableEventProcessor @Inject constructor(
private val outdatedDetector: OutdatedEventDetector, private val outdatedDetector: OutdatedEventDetector,
) { ) {
fun process(queuedEvents: List<NotifiableEvent>, currentRoomId: String?, currentThreadId: String?, renderedEvents: ProcessedEvents): ProcessedEvents { fun process(
queuedEvents: List<NotifiableEvent>,
appNavigationState: AppNavigationState?,
renderedEvents: ProcessedEvents,
): ProcessedEvents {
val processedEvents = queuedEvents.map { val processedEvents = queuedEvents.map {
val type = when (it) { val type = when (it) {
is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP
is NotifiableMessageEvent -> when { is NotifiableMessageEvent -> when {
it.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId) -> { it.shouldIgnoreMessageEventInRoom(appNavigationState) -> {
ProcessedEvent.Type.REMOVE ProcessedEvent.Type.REMOVE
.also { Timber.d("notification message removed due to currently viewing the same room or thread") } .also { Timber.d("notification message removed due to currently viewing the same room or thread") }
} }

311
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt

@ -15,13 +15,28 @@
*/ */
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
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.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.push.impl.log.pushLoggerTag
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent 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.NotifiableMessageEvent
import io.element.android.services.toolbox.api.strings.StringProvider import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
private val loggerTag = LoggerTag("NotifiableEventResolver", pushLoggerTag)
/** /**
* The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event. * The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event.
* It is used as a bridge between the Event Thread and the NotificationDrawerManager. * It is used as a bridge between the Event Thread and the NotificationDrawerManager.
@ -33,232 +48,92 @@ class NotifiableEventResolver @Inject constructor(
// private val noticeEventFormatter: NoticeEventFormatter, // private val noticeEventFormatter: NoticeEventFormatter,
// private val displayableEventFormatter: DisplayableEventFormatter, // private val displayableEventFormatter: DisplayableEventFormatter,
private val clock: SystemClock, private val clock: SystemClock,
private val matrixAuthenticationService: MatrixAuthenticationService,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) { ) {
suspend fun resolveEvent(/*event: Event, session: Session, isNoisy: Boolean*/): NotifiableEvent? { suspend fun resolveEvent(userId: String, roomId: String, eventId: String): NotifiableEvent? {
return TODO() // Restore session
/* val session = matrixAuthenticationService.restoreSession(SessionId(userId)).getOrNull() ?: return null
val roomID = event.roomId ?: return null // TODO EAx, no need for a session?
val eventId = event.eventId ?: return null val notificationData = session.let {// TODO Use make the app crashes
if (event.getClearType() == EventType.STATE_ROOM_MEMBER) { it.notificationService().getNotification(
return resolveStateRoomEvent(event, session, canBeReplaced = false, isNoisy = isNoisy) userId = userId,
} roomId = roomId,
val timelineEvent = session.getRoom(roomID)?.getTimelineEvent(eventId) ?: return null eventId = eventId,
return when {
event.supportsNotification() || event.type == EventType.ENCRYPTED -> {
resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy)
}
else -> {
// If the event can be displayed, display it as is
Timber.w("NotifiableEventResolver Received an unsupported event matching a bing rule")
// TODO Better event text display
val bodyPreview = event.type ?: EventType.MISSING_TYPE
SimpleNotifiableEvent(
session.myUserId,
eventId = event.eventId!!,
editedEventId = timelineEvent.getEditedEventId(),
noisy = false, // will be updated
timestamp = event.originServerTs ?: clock.epochMillis(),
description = bodyPreview,
title = stringProvider.getString(StringR.string.notification_unknown_new_event),
soundName = null,
type = event.type,
canBeReplaced = false
)
}
}
*/
}
suspend fun resolveInMemoryEvent(/*session: Session, event: Event, canBeReplaced: Boolean*/): NotifiableEvent? {
TODO()
/*
if (!event.supportsNotification()) return null
// Ignore message edition
if (event.isEdition()) return null
val actions = session.pushRuleService().getActions(event)
val notificationAction = actions.toNotificationAction()
return if (notificationAction.shouldNotify) {
val user = session.getUserOrDefault(event.senderId!!)
val timelineEvent = TimelineEvent(
root = event,
localId = -1,
eventId = event.eventId!!,
displayIndex = 0,
senderInfo = SenderInfo(
userId = user.userId,
displayName = user.toMatrixItem().getBestName(),
isUniqueDisplayName = true,
avatarUrl = user.avatarUrl
)
) )
resolveMessageEvent(timelineEvent, session, canBeReplaced = canBeReplaced, isNoisy = !notificationAction.soundName.isNullOrBlank()) }.fold(
} else { {
Timber.d("Matched push rule is set to not notify") it
null },
} {
Timber.tag(loggerTag.value).e(it, "Unable to resolve event.")
null
}
).orDefault(roomId, eventId)
*/ return notificationData.asNotifiableEvent(userId, roomId, eventId)
} }
}
private suspend fun resolveMessageEvent(/*event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean*/): NotifiableMessageEvent? { private fun NotificationData.asNotifiableEvent(userId: String, roomId: String, eventId: String): NotifiableEvent {
TODO() return NotifiableMessageEvent(
/* sessionId = userId,
// The event only contains an eventId, and roomId (type is m.room.*) , we need to get the displayable content (names, avatar, text, etc...) roomId = roomId,
val room = session.getRoom(event.root.roomId!! /*roomID cannot be null*/) eventId = eventId,
editedEventId = null,
return if (room == null) { canBeReplaced = true,
Timber.e("## Unable to resolve room for eventId [$event]") noisy = false,
// Ok room is not known in store, but we can still display something timestamp = System.currentTimeMillis(),
val body = displayableEventFormatter.format(event, isDm = false, appendAuthor = false) senderName = null,
val roomName = stringProvider.getString(StringR.string.notification_unknown_room_name) senderId = null,
val senderDisplayName = event.senderInfo.disambiguatedDisplayName body = "$eventId in $roomId",
imageUriString = null,
NotifiableMessageEvent( threadId = null,
eventId = event.root.eventId!!, roomName = null,
editedEventId = event.getEditedEventId(), roomIsDirect = false,
canBeReplaced = canBeReplaced, roomAvatarPath = null,
timestamp = event.root.originServerTs ?: 0, senderAvatarPath = null,
noisy = isNoisy, soundName = null,
senderName = senderDisplayName, outGoingMessage = false,
senderId = event.root.senderId, outGoingMessageFailed = false,
body = body.toString(), isRedacted = false,
imageUriString = event.fetchImageIfPresent(session)?.toString(), isUpdated = false
roomId = event.root.roomId!!, )
threadId = event.root.getRootThreadEventId(), }
roomName = roomName,
matrixID = session.myUserId
)
} else {
event.attemptToDecryptIfNeeded(session)
// only convert encrypted messages to NotifiableMessageEvents
when {
event.root.supportsNotification() -> {
val body = displayableEventFormatter.format(event, isDm = room.roomSummary()?.isDirect.orFalse(), appendAuthor = false).toString()
val roomName = room.roomSummary()?.displayName ?: ""
val senderDisplayName = event.senderInfo.disambiguatedDisplayName
NotifiableMessageEvent( /**
eventId = event.root.eventId!!, * TODO This is a temporary method for EAx
editedEventId = event.getEditedEventId(), */
canBeReplaced = canBeReplaced, private fun NotificationData?.orDefault(roomId: String, eventId: String): NotificationData {
timestamp = event.root.originServerTs ?: 0, return this ?: NotificationData(
noisy = isNoisy, item = MatrixTimelineItem.Event(
senderName = senderDisplayName, event = EventTimelineItem(
senderId = event.root.senderId, uniqueIdentifier = eventId,
body = body, eventId = EventId(eventId),
imageUriString = event.fetchImageIfPresent(session)?.toString(), isEditable = false,
roomId = event.root.roomId!!, isLocal = false,
threadId = event.root.getRootThreadEventId(), isOwn = false,
roomName = roomName, isRemote = false,
roomIsDirect = room.roomSummary()?.isDirect ?: false, localSendState = null,
roomAvatarPath = session.contentUrlResolver() reactions = emptyList(),
.resolveThumbnail( sender = UserId(""),
room.roomSummary()?.avatarUrl, senderProfile = ProfileTimelineDetails.Unavailable,
250, timestamp = System.currentTimeMillis(),
250, content = MessageContent(
ContentUrlResolver.ThumbnailMethod.SCALE body = eventId,
), inReplyTo = null,
senderAvatarPath = session.contentUrlResolver() isEdited = false,
.resolveThumbnail( type = TextMessageType(
event.senderInfo.avatarUrl, body = eventId,
250, formatted = null
250,
ContentUrlResolver.ThumbnailMethod.SCALE
),
matrixID = session.myUserId,
soundName = null
) )
}
else -> null
}
}
*/
}
/*
private suspend fun TimelineEvent.attemptToDecryptIfNeeded(session: Session) {
if (root.isEncrypted() && root.mxDecryptionResult == null) {
// TODO use a global event decryptor? attache to session and that listen to new sessionId?
// for now decrypt sync
try {
val result = session.cryptoService().decryptEvent(root, root.roomId + UUID.randomUUID().toString())
root.mxDecryptionResult = OlmDecryptionResult(
payload = result.clearEvent,
senderKey = result.senderCurve25519Key,
keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) },
forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain,
isSafe = result.isSafe
) )
} catch (ignore: MXCryptoError) { ),
} ),
} title = roomId,
} subtitle = eventId,
*/ isNoisy = false,
avatarUrl = null,
/* )
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 {
getVectorLastMessageContent()?.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
val dName = event.senderId?.let { session.roomService().getRoomMember(it, roomId)?.displayName }
if (Membership.INVITE == content.membership) {
val roomSummary = session.getRoomSummary(roomId)
val body = noticeEventFormatter.format(event, dName, isDm = roomSummary?.isDirect.orFalse())
?: stringProvider.getString(StringR.string.notification_new_invitation)
return InviteNotifiableEvent(
session.myUserId,
eventId = event.eventId!!,
editedEventId = null,
canBeReplaced = canBeReplaced,
roomId = roomId,
roomName = roomSummary?.displayName,
timestamp = event.originServerTs ?: 0,
noisy = isNoisy,
title = stringProvider.getString(StringR.string.notification_new_invitation),
description = body.toString(),
soundName = null, // will be set later
type = event.getClearType()
)
} else {
Timber.e("## unsupported notifiable event for eventId [${event.eventId}]")
if (buildMeta.lowPrivacyLoggingEnabled) {
Timber.e("## unsupported notifiable event for event [$event]")
}
// TODO generic handling?
}
return null
}
*/
} }

1
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt

@ -27,7 +27,6 @@ import javax.inject.Inject
data class NotificationActionIds @Inject constructor( data class NotificationActionIds @Inject constructor(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) { ) {
val join = "${buildMeta.applicationId}.NotificationActions.JOIN_ACTION" val join = "${buildMeta.applicationId}.NotificationActions.JOIN_ACTION"
val reject = "${buildMeta.applicationId}.NotificationActions.REJECT_ACTION" val reject = "${buildMeta.applicationId}.NotificationActions.REJECT_ACTION"
val quickLaunch = "${buildMeta.applicationId}.NotificationActions.QUICK_LAUNCH_ACTION" val quickLaunch = "${buildMeta.applicationId}.NotificationActions.QUICK_LAUNCH_ACTION"

31
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt

@ -21,11 +21,15 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.push.impl.log.notificationLoggerTag
import io.element.android.services.analytics.api.AnalyticsTracker import io.element.android.services.analytics.api.AnalyticsTracker
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
private val loggerTag = LoggerTag("NotificationBroadcastReceiver", notificationLoggerTag)
/** /**
* Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.). * Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.).
*/ */
@ -41,37 +45,38 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent == null || context == null) return if (intent == null || context == null) return
context.bindings<NotificationBroadcastReceiverBindings>().inject(this) context.bindings<NotificationBroadcastReceiverBindings>().inject(this)
Timber.v("NotificationBroadcastReceiver received : $intent") Timber.tag(loggerTag.value).v("NotificationBroadcastReceiver received : $intent")
val sessionId = intent.extras?.getString(KEY_SESSION_ID) ?: return
when (intent.action) { when (intent.action) {
actionIds.smartReply -> actionIds.smartReply ->
handleSmartReply(intent, context) handleSmartReply(intent, context)
actionIds.dismissRoom -> actionIds.dismissRoom ->
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId -> intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) } notificationDrawerManager.updateEvents { it.clearMessagesForRoom(sessionId, roomId) }
} }
actionIds.dismissSummary -> actionIds.dismissSummary ->
notificationDrawerManager.clearAllEvents() notificationDrawerManager.clearAllEvents(sessionId)
actionIds.markRoomRead -> actionIds.markRoomRead ->
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId -> intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMessagesForRoom(roomId) } notificationDrawerManager.updateEvents { it.clearMessagesForRoom(sessionId, roomId) }
handleMarkAsRead(roomId) handleMarkAsRead(sessionId, roomId)
} }
actionIds.join -> { actionIds.join -> {
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId -> intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) } notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(sessionId, roomId) }
handleJoinRoom(roomId) handleJoinRoom(sessionId, roomId)
} }
} }
actionIds.reject -> { actionIds.reject -> {
intent.getStringExtra(KEY_ROOM_ID)?.let { roomId -> intent.getStringExtra(KEY_ROOM_ID)?.let { roomId ->
notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomId) } notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(sessionId, roomId) }
handleRejectRoom(roomId) handleRejectRoom(sessionId, roomId)
} }
} }
} }
} }
private fun handleJoinRoom(roomId: String) { private fun handleJoinRoom(sessionId: String, roomId: String) {
/* /*
activeSessionHolder.getSafeActiveSession()?.let { session -> activeSessionHolder.getSafeActiveSession()?.let { session ->
val room = session.getRoom(roomId) val room = session.getRoom(roomId)
@ -88,7 +93,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
*/ */
} }
private fun handleRejectRoom(roomId: String) { private fun handleRejectRoom(sessionId: String, roomId: String) {
/* /*
activeSessionHolder.getSafeActiveSession()?.let { session -> activeSessionHolder.getSafeActiveSession()?.let { session ->
session.coroutineScope.launch { session.coroutineScope.launch {
@ -99,7 +104,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
*/ */
} }
private fun handleMarkAsRead(roomId: String) { private fun handleMarkAsRead(sessionId: String, roomId: String) {
/* /*
activeSessionHolder.getActiveSession().let { session -> activeSessionHolder.getActiveSession().let { session ->
val room = session.getRoom(roomId) val room = session.getRoom(roomId)
@ -115,6 +120,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
private fun handleSmartReply(intent: Intent, context: Context) { private fun handleSmartReply(intent: Intent, context: Context) {
val message = getReplyMessage(intent) val message = getReplyMessage(intent)
val sessionId = intent.getStringExtra(KEY_SESSION_ID)
val roomId = intent.getStringExtra(KEY_ROOM_ID) val roomId = intent.getStringExtra(KEY_ROOM_ID)
val threadId = intent.getStringExtra(KEY_THREAD_ID) val threadId = intent.getStringExtra(KEY_THREAD_ID)
@ -234,6 +240,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
} }
companion object { companion object {
const val KEY_SESSION_ID = "sessionID"
const val KEY_ROOM_ID = "roomID" const val KEY_ROOM_ID = "roomID"
const val KEY_THREAD_ID = "threadID" const val KEY_THREAD_ID = "threadID"
const val KEY_TEXT_REPLY = "key_text_reply" const val KEY_TEXT_REPLY = "key_text_reply"

2
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt

@ -26,8 +26,6 @@ import io.element.android.libraries.di.ApplicationContext
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
const val TEMPORARY_ID = 101
class NotificationDisplayer @Inject constructor( class NotificationDisplayer @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) {

118
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt

@ -30,6 +30,10 @@ import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent 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.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -42,31 +46,24 @@ import javax.inject.Inject
class NotificationDrawerManager @Inject constructor( class NotificationDrawerManager @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
private val pushDataStore: PushDataStore, private val pushDataStore: PushDataStore,
// private val activeSessionDataSource: ActiveSessionDataSource,
private val notifiableEventProcessor: NotifiableEventProcessor, private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationRenderer: NotificationRenderer, private val notificationRenderer: NotificationRenderer,
private val notificationEventPersistence: NotificationEventPersistence, private val notificationEventPersistence: NotificationEventPersistence,
private val filteredEventDetector: FilteredEventDetector, private val filteredEventDetector: FilteredEventDetector,
private val appNavigationStateService: AppNavigationStateService,
private val coroutineScope: CoroutineScope,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) { ) {
private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY) private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY)
private var backgroundHandler: Handler private var backgroundHandler: Handler
// TODO Multi-session: this will have to be improved
/*
private val currentSession: Session?
get() = activeSessionDataSource.currentValue?.orNull()
*/
/** /**
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events. * Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
*/ */
private val notificationState by lazy { createInitialNotificationState() } private val notificationState by lazy { createInitialNotificationState() }
private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size) private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
private var currentRoomId: String? = null private var currentAppNavigationState: AppNavigationState? = null
private var currentThreadId: String? = null
private val firstThrottler = FirstThrottler(200) private val firstThrottler = FirstThrottler(200)
private var useCompleteNotificationFormat = pushDataStore.useCompleteNotificationFormat() private var useCompleteNotificationFormat = pushDataStore.useCompleteNotificationFormat()
@ -74,6 +71,31 @@ class NotificationDrawerManager @Inject constructor(
init { init {
handlerThread.start() handlerThread.start()
backgroundHandler = Handler(handlerThread.looper) backgroundHandler = Handler(handlerThread.looper)
// Observe application state
coroutineScope.launch {
appNavigationStateService.appNavigationStateFlow
.collect { onAppNavigationStateChange(it) }
}
}
private fun onAppNavigationStateChange(appNavigationState: AppNavigationState) {
currentAppNavigationState = appNavigationState
when (appNavigationState) {
AppNavigationState.Root -> {}
is AppNavigationState.Session -> {}
is AppNavigationState.Space -> {}
is AppNavigationState.Room -> {
// Cleanup notification for current room
onEnteringRoom(appNavigationState.parentSpace.parentSession.sessionId.value, appNavigationState.roomId.value)
}
is AppNavigationState.Thread -> {
onEnteringThread(
appNavigationState.parentRoom.parentSpace.parentSession.sessionId.value,
appNavigationState.parentRoom.roomId.value,
appNavigationState.threadId.value
)
}
}
} }
private fun createInitialNotificationState(): NotificationState { private fun createInitialNotificationState(): NotificationState {
@ -114,21 +136,17 @@ class NotificationDrawerManager @Inject constructor(
/** /**
* Clear all known events and refresh the notification drawer. * Clear all known events and refresh the notification drawer.
*/ */
fun clearAllEvents() { fun clearAllEvents(sessionId: String) {
updateEvents { it.clear() } updateEvents { it.clearMessagesForSession(sessionId) }
} }
/** /**
* Should be called when the application is currently opened and showing timeline for the given roomId. * Should be called when the application is currently opened and showing timeline for the given roomId.
* Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room. * 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?) { private fun onEnteringRoom(sessionId: String, roomId: String) {
updateEvents { updateEvents {
val hasChanged = roomId != currentRoomId it.clearMessagesForRoom(sessionId, roomId)
currentRoomId = roomId
if (hasChanged && roomId != null) {
it.clearMessagesForRoom(roomId)
}
} }
} }
@ -136,18 +154,13 @@ class NotificationDrawerManager @Inject constructor(
* Should be called when the application is currently opened and showing timeline for the given threadId. * Should be called when the application is currently opened and showing timeline for the given threadId.
* Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room. * Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
*/ */
fun setCurrentThread(threadId: String?) { private fun onEnteringThread(sessionId: String, roomId: String, threadId: String) {
updateEvents { updateEvents {
val hasChanged = threadId != currentThreadId it.clearMessagesForThread(sessionId, roomId, threadId)
currentThreadId = threadId
currentRoomId?.let { roomId ->
if (hasChanged && threadId != null) {
it.clearMessagesForThread(roomId, threadId)
}
}
} }
} }
// TODO EAx Must be per account
fun notificationStyleChanged() { fun notificationStyleChanged() {
updateEvents { updateEvents {
val newSettings = pushDataStore.useCompleteNotificationFormat() val newSettings = pushDataStore.useCompleteNotificationFormat()
@ -189,7 +202,7 @@ class NotificationDrawerManager @Inject constructor(
private fun refreshNotificationDrawerBg() { private fun refreshNotificationDrawerBg() {
Timber.v("refreshNotificationDrawerBg()") Timber.v("refreshNotificationDrawerBg()")
val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents -> val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
notifiableEventProcessor.process(queuedEvents.rawEvents(), currentRoomId, currentThreadId, renderedEvents).also { notifiableEventProcessor.process(queuedEvents.rawEvents(), currentAppNavigationState, renderedEvents).also {
queuedEvents.clearAndAdd(it.onlyKeptEvents()) queuedEvents.clearAndAdd(it.onlyKeptEvents())
} }
} }
@ -198,9 +211,7 @@ class NotificationDrawerManager @Inject constructor(
Timber.d("Skipping notification update due to event list not changing") Timber.d("Skipping notification update due to event list not changing")
} else { } else {
notificationState.clearAndAddRenderedEvents(eventsToRender) notificationState.clearAndAddRenderedEvents(eventsToRender)
// TODO EAx renderEvents(eventsToRender)
//val session = currentSession ?: return
//renderEvents(session, eventsToRender)
persistEvents() persistEvents()
} }
} }
@ -211,37 +222,28 @@ class NotificationDrawerManager @Inject constructor(
} }
} }
private fun renderEvents(/*session: Session, eventsToRender: List<ProcessedEvent<NotifiableEvent>>*/) { private fun renderEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
/* TODO EAx // Group by sessionId
val user = session.getUserOrDefault(session.myUserId) val eventsForSessions = eventsToRender.groupBy {
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash it.event.sessionId
val myUserDisplayName = user.toMatrixItem().getBestName() }
val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail(
contentUrl = user.avatarUrl,
width = avatarSize,
height = avatarSize,
method = ContentUrlResolver.ThumbnailMethod.SCALE
)
notificationRenderer.render(session.myUserId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, eventsToRender)
*/ eventsForSessions.forEach { (sessionId, notifiableEvents) ->
// TODO EAx val user = session.getUserOrDefault(session.myUserId)
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
val myUserDisplayName = "Todo display name" // user.toMatrixItem().getBestName()
// TODO EAx avatar URL
val myUserAvatarUrl = null // session.contentUrlResolver().resolveThumbnail(
// contentUrl = user.avatarUrl,
// width = avatarSize,
// height = avatarSize,
// method = ContentUrlResolver.ThumbnailMethod.SCALE
//)
notificationRenderer.render(sessionId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, notifiableEvents)
}
} }
fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean { fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean {
return resolvedEvent.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId) return resolvedEvent.shouldIgnoreMessageEventInRoom(currentAppNavigationState)
}
/**
* Temporary notification for EAx
*/
fun displayTemporaryNotification() {
notificationRenderer.displayTemporaryNotification()
}
companion object {
const val SUMMARY_NOTIFICATION_ID = 0
const val ROOM_MESSAGES_NOTIFICATION_ID = 1
const val ROOM_EVENT_NOTIFICATION_ID = 2
const val ROOM_INVITATION_NOTIFICATION_ID = 3
} }
} }

27
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt

@ -23,7 +23,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableMess
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import timber.log.Timber import timber.log.Timber
data class NotificationEventQueue( data class NotificationEventQueue constructor(
private val queue: MutableList<NotifiableEvent>, private val queue: MutableList<NotifiableEvent>,
/** /**
* An in memory FIFO cache of the seen events. * An in memory FIFO cache of the seen events.
@ -103,7 +103,7 @@ data class NotificationEventQueue(
} }
private fun findExistingById(notifiableEvent: NotifiableEvent): NotifiableEvent? { private fun findExistingById(notifiableEvent: NotifiableEvent): NotifiableEvent? {
return queue.firstOrNull { it.eventId == notifiableEvent.eventId } return queue.firstOrNull { it.sessionId == notifiableEvent.sessionId && it.eventId == notifiableEvent.eventId }
} }
private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? { private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? {
@ -125,19 +125,24 @@ data class NotificationEventQueue(
) )
} }
fun clearMemberShipNotificationForRoom(roomId: String) { fun clearMemberShipNotificationForRoom(sessionId: String, roomId: String) {
Timber.d("clearMemberShipOfRoom $roomId") Timber.d("clearMemberShipOfRoom $sessionId, $roomId")
queue.removeAll { it is InviteNotifiableEvent && it.roomId == roomId } queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId }
} }
fun clearMessagesForRoom(roomId: String) { fun clearMessagesForSession(sessionId: String) {
Timber.d("clearMessageEventOfRoom $roomId") Timber.d("clearMessagesForSession $sessionId")
queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId } queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId}
} }
fun clearMessagesForThread(roomId: String, threadId: String) { fun clearMessagesForRoom(sessionId: String, roomId: String) {
Timber.d("clearMessageEventOfThread $roomId, $threadId") Timber.d("clearMessageEventOfRoom $sessionId, $roomId")
queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId && it.threadId == threadId } queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId }
}
fun clearMessagesForThread(sessionId: String, roomId: String, threadId: String) {
Timber.d("clearMessageEventOfThread $sessionId, $roomId, $threadId")
queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId && it.threadId == threadId }
} }
fun rawEvents(): List<NotifiableEvent> = queue fun rawEvents(): List<NotifiableEvent> = queue

96
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt

@ -25,18 +25,28 @@ import javax.inject.Inject
private typealias ProcessedMessageEvents = List<ProcessedEvent<NotifiableMessageEvent>> private typealias ProcessedMessageEvents = List<ProcessedEvent<NotifiableMessageEvent>>
class NotificationFactory @Inject constructor( class NotificationFactory @Inject constructor(
private val notificationUtils: NotificationUtils, private val notificationUtils: NotificationUtils,
private val roomGroupMessageCreator: RoomGroupMessageCreator, private val roomGroupMessageCreator: RoomGroupMessageCreator,
private val summaryGroupMessageCreator: SummaryGroupMessageCreator private val summaryGroupMessageCreator: SummaryGroupMessageCreator
) { ) {
fun Map<String, ProcessedMessageEvents>.toNotifications(myUserDisplayName: String, myUserAvatarUrl: String?): List<RoomNotification> { fun Map<String, ProcessedMessageEvents>.toNotifications(
sessionId: String,
myUserDisplayName: String,
myUserAvatarUrl: String?
): List<RoomNotification> {
return map { (roomId, events) -> return map { (roomId, events) ->
when { when {
events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId) events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId)
else -> { else -> {
val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted } val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted }
roomGroupMessageCreator.createRoomMessage(messageEvents, roomId, myUserDisplayName, myUserAvatarUrl) roomGroupMessageCreator.createRoomMessage(
sessionId = sessionId,
events = messageEvents,
roomId = roomId,
userDisplayName = myUserDisplayName,
userAvatarUrl = myUserAvatarUrl
)
} }
} }
} }
@ -49,46 +59,47 @@ class NotificationFactory @Inject constructor(
private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted
@JvmName("toNotificationsInviteNotifiableEvent") @JvmName("toNotificationsInviteNotifiableEvent")
fun List<ProcessedEvent<InviteNotifiableEvent>>.toNotifications(myUserId: String): List<OneShotNotification> { fun List<ProcessedEvent<InviteNotifiableEvent>>.toNotifications(): List<OneShotNotification> {
return map { (processed, event) -> return map { (processed, event) ->
when (processed) { when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId) ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append( ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationUtils.buildRoomInvitationNotification(event, myUserId), notificationUtils.buildRoomInvitationNotification(event),
OneShotNotification.Append.Meta( OneShotNotification.Append.Meta(
key = event.roomId, key = event.roomId,
summaryLine = event.description, summaryLine = event.description,
isNoisy = event.noisy, isNoisy = event.noisy,
timestamp = event.timestamp timestamp = event.timestamp
) )
) )
} }
} }
} }
@JvmName("toNotificationsSimpleNotifiableEvent") @JvmName("toNotificationsSimpleNotifiableEvent")
fun List<ProcessedEvent<SimpleNotifiableEvent>>.toNotifications(myUserId: String): List<OneShotNotification> { fun List<ProcessedEvent<SimpleNotifiableEvent>>.toNotifications(): List<OneShotNotification> {
return map { (processed, event) -> return map { (processed, event) ->
when (processed) { when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId) ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append( ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationUtils.buildSimpleEventNotification(event, myUserId), notificationUtils.buildSimpleEventNotification(event),
OneShotNotification.Append.Meta( OneShotNotification.Append.Meta(
key = event.eventId, key = event.eventId,
summaryLine = event.description, summaryLine = event.description,
isNoisy = event.noisy, isNoisy = event.noisy,
timestamp = event.timestamp timestamp = event.timestamp
) )
) )
} }
} }
} }
fun createSummaryNotification( fun createSummaryNotification(
roomNotifications: List<RoomNotification>, sessionId: String,
invitationNotifications: List<OneShotNotification>, roomNotifications: List<RoomNotification>,
simpleNotifications: List<OneShotNotification>, invitationNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean simpleNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean
): SummaryNotification { ): SummaryNotification {
val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta } val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta }
val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta } val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
@ -96,30 +107,27 @@ class NotificationFactory @Inject constructor(
return when { return when {
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
else -> SummaryNotification.Update( else -> SummaryNotification.Update(
summaryGroupMessageCreator.createSummaryNotification( summaryGroupMessageCreator.createSummaryNotification(
roomNotifications = roomMeta, sessionId = sessionId,
invitationNotifications = invitationMeta, roomNotifications = roomMeta,
simpleNotifications = simpleMeta, invitationNotifications = invitationMeta,
useCompleteNotificationFormat = useCompleteNotificationFormat simpleNotifications = simpleMeta,
) useCompleteNotificationFormat = useCompleteNotificationFormat
)
) )
} }
} }
fun createTemporaryNotification(): Notification {
return notificationUtils.createTemporaryNotification()
}
} }
sealed interface RoomNotification { sealed interface RoomNotification {
data class Removed(val roomId: String) : RoomNotification data class Removed(val roomId: String) : RoomNotification
data class Message(val notification: Notification, val meta: Meta) : RoomNotification { data class Message(val notification: Notification, val meta: Meta) : RoomNotification {
data class Meta( data class Meta(
val summaryLine: CharSequence, val roomId: String,
val messageCount: Int, val summaryLine: CharSequence,
val latestTimestamp: Long, val messageCount: Int,
val roomId: String, val latestTimestamp: Long,
val shouldBing: Boolean val shouldBing: Boolean
) )
} }
} }
@ -128,10 +136,10 @@ sealed interface OneShotNotification {
data class Removed(val key: String) : OneShotNotification data class Removed(val key: String) : OneShotNotification
data class Append(val notification: Notification, val meta: Meta) : OneShotNotification { data class Append(val notification: Notification, val meta: Meta) : OneShotNotification {
data class Meta( data class Meta(
val key: String, val key: String,
val summaryLine: CharSequence, val summaryLine: CharSequence,
val isNoisy: Boolean, val isNoisy: Boolean,
val timestamp: Long, val timestamp: Long,
) )
} }
} }

52
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProvider.kt

@ -0,0 +1,52 @@
/*
* 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
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
@SingleIn(AppScope::class)
class NotificationIdProvider @Inject constructor() {
fun getSummaryNotificationId(sessionId: String): Int {
return getOffset(sessionId) + SUMMARY_NOTIFICATION_ID
}
fun getRoomMessagesNotificationId(sessionId: String): Int {
return getOffset(sessionId) + ROOM_MESSAGES_NOTIFICATION_ID
}
fun getRoomEventNotificationId(sessionId: String): Int {
return getOffset(sessionId) + ROOM_EVENT_NOTIFICATION_ID
}
fun getRoomInvitationNotificationId(sessionId: String): Int {
return getOffset(sessionId) + ROOM_INVITATION_NOTIFICATION_ID
}
private fun getOffset(sessionId: String): Int {
// TODO EAx multi account: return different value for users and persist data
return 0
}
companion object {
private const val SUMMARY_NOTIFICATION_ID = 0
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1
private const val ROOM_EVENT_NOTIFICATION_ID = 2
private const val ROOM_INVITATION_NOTIFICATION_ID = 3
}
}

51
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt

@ -16,10 +16,6 @@
package io.element.android.libraries.push.impl.notifications package io.element.android.libraries.push.impl.notifications
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.ROOM_EVENT_NOTIFICATION_ID
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.ROOM_INVITATION_NOTIFICATION_ID
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.ROOM_MESSAGES_NOTIFICATION_ID
import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager.Companion.SUMMARY_NOTIFICATION_ID
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent 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.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -28,13 +24,14 @@ import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class NotificationRenderer @Inject constructor( class NotificationRenderer @Inject constructor(
private val notificationIdProvider: NotificationIdProvider,
private val notificationDisplayer: NotificationDisplayer, private val notificationDisplayer: NotificationDisplayer,
private val notificationFactory: NotificationFactory, private val notificationFactory: NotificationFactory,
) { ) {
@WorkerThread @WorkerThread
fun render( fun render(
myUserId: String, sessionId: String,
myUserDisplayName: String, myUserDisplayName: String,
myUserAvatarUrl: String?, myUserAvatarUrl: String?,
useCompleteNotificationFormat: Boolean, useCompleteNotificationFormat: Boolean,
@ -42,10 +39,11 @@ class NotificationRenderer @Inject constructor(
) { ) {
val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType() val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType()
with(notificationFactory) { with(notificationFactory) {
val roomNotifications = roomEvents.toNotifications(myUserDisplayName, myUserAvatarUrl) val roomNotifications = roomEvents.toNotifications(sessionId, myUserDisplayName, myUserAvatarUrl)
val invitationNotifications = invitationEvents.toNotifications(myUserId) val invitationNotifications = invitationEvents.toNotifications()
val simpleNotifications = simpleEvents.toNotifications(myUserId) val simpleNotifications = simpleEvents.toNotifications()
val summaryNotification = createSummaryNotification( val summaryNotification = createSummaryNotification(
sessionId = sessionId,
roomNotifications = roomNotifications, roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications, invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications, simpleNotifications = simpleNotifications,
@ -55,18 +53,22 @@ class NotificationRenderer @Inject constructor(
// Remove summary first to avoid briefly displaying it after dismissing the last notification // Remove summary first to avoid briefly displaying it after dismissing the last notification
if (summaryNotification == SummaryNotification.Removed) { if (summaryNotification == SummaryNotification.Removed) {
Timber.d("Removing summary notification") Timber.d("Removing summary notification")
notificationDisplayer.cancelNotificationMessage(null, SUMMARY_NOTIFICATION_ID) notificationDisplayer.cancelNotificationMessage(null, notificationIdProvider.getSummaryNotificationId(sessionId))
} }
roomNotifications.forEach { wrapper -> roomNotifications.forEach { wrapper ->
when (wrapper) { when (wrapper) {
is RoomNotification.Removed -> { is RoomNotification.Removed -> {
Timber.d("Removing room messages notification ${wrapper.roomId}") Timber.d("Removing room messages notification ${wrapper.roomId}")
notificationDisplayer.cancelNotificationMessage(wrapper.roomId, ROOM_MESSAGES_NOTIFICATION_ID) notificationDisplayer.cancelNotificationMessage(wrapper.roomId, notificationIdProvider.getRoomMessagesNotificationId(sessionId))
} }
is RoomNotification.Message -> if (useCompleteNotificationFormat) { is RoomNotification.Message -> if (useCompleteNotificationFormat) {
Timber.d("Updating room messages notification ${wrapper.meta.roomId}") Timber.d("Updating room messages notification ${wrapper.meta.roomId}")
notificationDisplayer.showNotificationMessage(wrapper.meta.roomId, ROOM_MESSAGES_NOTIFICATION_ID, wrapper.notification) notificationDisplayer.showNotificationMessage(
wrapper.meta.roomId,
notificationIdProvider.getRoomMessagesNotificationId(sessionId),
wrapper.notification
)
} }
} }
} }
@ -75,11 +77,15 @@ class NotificationRenderer @Inject constructor(
when (wrapper) { when (wrapper) {
is OneShotNotification.Removed -> { is OneShotNotification.Removed -> {
Timber.d("Removing invitation notification ${wrapper.key}") Timber.d("Removing invitation notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(wrapper.key, ROOM_INVITATION_NOTIFICATION_ID) notificationDisplayer.cancelNotificationMessage(wrapper.key, notificationIdProvider.getRoomInvitationNotificationId(sessionId))
} }
is OneShotNotification.Append -> if (useCompleteNotificationFormat) { is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.d("Updating invitation notification ${wrapper.meta.key}") Timber.d("Updating invitation notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(wrapper.meta.key, ROOM_INVITATION_NOTIFICATION_ID, wrapper.notification) notificationDisplayer.showNotificationMessage(
wrapper.meta.key,
notificationIdProvider.getRoomInvitationNotificationId(sessionId),
wrapper.notification
)
} }
} }
} }
@ -88,11 +94,15 @@ class NotificationRenderer @Inject constructor(
when (wrapper) { when (wrapper) {
is OneShotNotification.Removed -> { is OneShotNotification.Removed -> {
Timber.d("Removing simple notification ${wrapper.key}") Timber.d("Removing simple notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(wrapper.key, ROOM_EVENT_NOTIFICATION_ID) notificationDisplayer.cancelNotificationMessage(wrapper.key, notificationIdProvider.getRoomEventNotificationId(sessionId))
} }
is OneShotNotification.Append -> if (useCompleteNotificationFormat) { is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.d("Updating simple notification ${wrapper.meta.key}") Timber.d("Updating simple notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(wrapper.meta.key, ROOM_EVENT_NOTIFICATION_ID, wrapper.notification) notificationDisplayer.showNotificationMessage(
wrapper.meta.key,
notificationIdProvider.getRoomEventNotificationId(sessionId),
wrapper.notification
)
} }
} }
} }
@ -100,7 +110,11 @@ class NotificationRenderer @Inject constructor(
// Update summary last to avoid briefly displaying it before other notifications // Update summary last to avoid briefly displaying it before other notifications
if (summaryNotification is SummaryNotification.Update) { if (summaryNotification is SummaryNotification.Update) {
Timber.d("Updating summary notification") Timber.d("Updating summary notification")
notificationDisplayer.showNotificationMessage(null, SUMMARY_NOTIFICATION_ID, summaryNotification.notification) notificationDisplayer.showNotificationMessage(
null,
notificationIdProvider.getSummaryNotificationId(sessionId),
summaryNotification.notification
)
} }
} }
} }
@ -108,11 +122,6 @@ class NotificationRenderer @Inject constructor(
fun cancelAllNotifications() { fun cancelAllNotifications() {
notificationDisplayer.cancelAllNotifications() notificationDisplayer.cancelAllNotifications()
} }
fun displayTemporaryNotification() {
val notification = notificationFactory.createTemporaryNotification()
notificationDisplayer.showNotificationMessage(null, TEMPORARY_ID, notification)
}
} }
private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotificationEvents { private fun List<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotificationEvents {

154
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt

@ -228,7 +228,7 @@ class NotificationUtils @Inject constructor(
true true
/** TODO EAx vectorPreferences.areThreadMessagesEnabled() */ /** TODO EAx vectorPreferences.areThreadMessagesEnabled() */
-> buildOpenThreadIntent(roomInfo, threadId) -> buildOpenThreadIntent(roomInfo, threadId)
else -> buildOpenRoomIntent(roomInfo.roomId) else -> buildOpenRoomIntent(roomInfo.sessionId, roomInfo.roomId)
} }
val smallIcon = R.drawable.ic_notification val smallIcon = R.drawable.ic_notification
@ -259,8 +259,7 @@ class NotificationUtils @Inject constructor(
) )
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+) // Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID // devices and all Wear devices. But we want a custom grouping, so we specify the groupID
// TODO Group should be current user display name .setGroup(roomInfo.sessionId)
.setGroup(buildMeta.applicationName)
// In order to avoid notification making sound twice (due to the summary notification) // In order to avoid notification making sound twice (due to the summary notification)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon) .setSmallIcon(smallIcon)
@ -287,7 +286,8 @@ class NotificationUtils @Inject constructor(
// Mark room as read // Mark room as read
val markRoomReadIntent = Intent(context, NotificationBroadcastReceiver::class.java) val markRoomReadIntent = Intent(context, NotificationBroadcastReceiver::class.java)
markRoomReadIntent.action = actionIds.markRoomRead markRoomReadIntent.action = actionIds.markRoomRead
markRoomReadIntent.data = createIgnoredUri(roomInfo.roomId) markRoomReadIntent.data = createIgnoredUri("markRead?${roomInfo.sessionId}&$${roomInfo.roomId}")
markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, roomInfo.sessionId)
markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId) markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
val markRoomReadPendingIntent = PendingIntent.getBroadcast( val markRoomReadPendingIntent = PendingIntent.getBroadcast(
context, context,
@ -307,7 +307,7 @@ class NotificationUtils @Inject constructor(
// Quick reply // Quick reply
if (!roomInfo.hasSmartReplyError) { if (!roomInfo.hasSmartReplyError) {
buildQuickReplyIntent(roomInfo.roomId, threadId, senderDisplayNameForReplyCompat)?.let { replyPendingIntent -> buildQuickReplyIntent(roomInfo.sessionId, roomInfo.roomId, threadId, senderDisplayNameForReplyCompat)?.let { replyPendingIntent ->
val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
.setLabel(stringProvider.getString(StringR.string.action_quick_reply)) .setLabel(stringProvider.getString(StringR.string.action_quick_reply))
.build() .build()
@ -332,8 +332,9 @@ class NotificationUtils @Inject constructor(
} }
val intent = Intent(context, NotificationBroadcastReceiver::class.java) val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
intent.action = actionIds.dismissRoom intent.action = actionIds.dismissRoom
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, roomInfo.sessionId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
val pendingIntent = PendingIntent.getBroadcast( val pendingIntent = PendingIntent.getBroadcast(
context.applicationContext, context.applicationContext,
clock.epochMillis().toInt(), clock.epochMillis().toInt(),
@ -347,8 +348,7 @@ class NotificationUtils @Inject constructor(
} }
fun buildRoomInvitationNotification( fun buildRoomInvitationNotification(
inviteNotifiableEvent: InviteNotifiableEvent, inviteNotifiableEvent: InviteNotifiableEvent
matrixId: String
): Notification { ): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Build the pending intent for when the notification is clicked // Build the pending intent for when the notification is clicked
@ -359,7 +359,7 @@ class NotificationUtils @Inject constructor(
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentTitle(inviteNotifiableEvent.roomName ?: buildMeta.applicationName) .setContentTitle(inviteNotifiableEvent.roomName ?: buildMeta.applicationName)
.setContentText(inviteNotifiableEvent.description) .setContentText(inviteNotifiableEvent.description)
.setGroup(buildMeta.applicationName) .setGroup(inviteNotifiableEvent.sessionId)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon) .setSmallIcon(smallIcon)
.setColor(accentColor) .setColor(accentColor)
@ -368,7 +368,8 @@ class NotificationUtils @Inject constructor(
// offer to type a quick reject button // offer to type a quick reject button
val rejectIntent = Intent(context, NotificationBroadcastReceiver::class.java) val rejectIntent = Intent(context, NotificationBroadcastReceiver::class.java)
rejectIntent.action = actionIds.reject rejectIntent.action = actionIds.reject
rejectIntent.data = createIgnoredUri("$roomId&$matrixId") rejectIntent.data = createIgnoredUri("rejectInvite?${inviteNotifiableEvent.sessionId}&$roomId")
rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, inviteNotifiableEvent.sessionId)
rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val rejectIntentPendingIntent = PendingIntent.getBroadcast( val rejectIntentPendingIntent = PendingIntent.getBroadcast(
context, context,
@ -386,7 +387,8 @@ class NotificationUtils @Inject constructor(
// offer to type a quick accept button // offer to type a quick accept button
val joinIntent = Intent(context, NotificationBroadcastReceiver::class.java) val joinIntent = Intent(context, NotificationBroadcastReceiver::class.java)
joinIntent.action = actionIds.join joinIntent.action = actionIds.join
joinIntent.data = createIgnoredUri("$roomId&$matrixId") joinIntent.data = createIgnoredUri("acceptInvite?${inviteNotifiableEvent.sessionId}&$roomId")
joinIntent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, inviteNotifiableEvent.sessionId)
joinIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) joinIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val joinIntentPendingIntent = PendingIntent.getBroadcast( val joinIntentPendingIntent = PendingIntent.getBroadcast(
context, context,
@ -433,7 +435,6 @@ class NotificationUtils @Inject constructor(
fun buildSimpleEventNotification( fun buildSimpleEventNotification(
simpleNotifiableEvent: SimpleNotifiableEvent, simpleNotifiableEvent: SimpleNotifiableEvent,
matrixId: String
): Notification { ): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Build the pending intent for when the notification is clicked // Build the pending intent for when the notification is clicked
@ -445,7 +446,7 @@ class NotificationUtils @Inject constructor(
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentTitle(buildMeta.applicationName) .setContentTitle(buildMeta.applicationName)
.setContentText(simpleNotifiableEvent.description) .setContentText(simpleNotifiableEvent.description)
.setGroup(buildMeta.applicationName) .setGroup(simpleNotifiableEvent.sessionId)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon) .setSmallIcon(smallIcon)
.setColor(accentColor) .setColor(accentColor)
@ -476,81 +477,45 @@ class NotificationUtils @Inject constructor(
.build() .build()
} }
private fun buildOpenRoomIntent(roomId: String): PendingIntent? { private fun buildOpenRoomIntent(sessionId: String, roomId: String): PendingIntent? {
return null val roomIntent = intentProvider.getIntent(sessionId = sessionId, roomId = roomId, threadId = null)
/* roomIntent.action = actionIds.tapToView
val roomIntentTap = RoomDetailActivity.newIntent(context, TimelineArgs(roomId = roomId, switchToParentSpace = true), true)
roomIntentTap.action = actionIds.tapToView
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
roomIntentTap.data = createIgnoredUri("openRoom?$roomId") roomIntent.data = createIgnoredUri("openRoom?$sessionId&$roomId")
// Recreate the back stack return PendingIntent.getActivity(
return TaskStackBuilder.create(context) context,
.addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false)) clock.epochMillis().toInt(),
.addNextIntent(roomIntentTap) roomIntent,
.getPendingIntent( PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
clock.epochMillis().toInt(), )
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
*/
} }
private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: String?): PendingIntent? { private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: String?): PendingIntent? {
return null val sessionId = roomInfo.sessionId
/* val roomId = roomInfo.roomId
val threadTimelineArgs = ThreadTimelineArgs( val threadIntentTap = intentProvider.getIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
startsThread = false,
roomId = roomInfo.roomId,
rootThreadEventId = threadId,
showKeyboard = false,
displayName = roomInfo.roomDisplayName,
avatarUrl = null,
roomEncryptionTrustLevel = null,
)
val threadIntentTap = ThreadsActivity.newIntent(
context = context,
threadTimelineArgs = threadTimelineArgs,
threadListArgs = null,
firstStartMainActivity = true,
)
threadIntentTap.action = actionIds.tapToView threadIntentTap.action = actionIds.tapToView
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
threadIntentTap.data = createIgnoredUri("openThread?$threadId") threadIntentTap.data = createIgnoredUri("openThread?$sessionId&$roomId&$threadId")
val roomIntent = RoomDetailActivity.newIntent( return PendingIntent.getActivity(
context = context, context,
timelineArgs = TimelineArgs( clock.epochMillis().toInt(),
roomId = roomInfo.roomId, threadIntentTap,
switchToParentSpace = true PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
),
firstStartMainActivity = false
) )
// Recreate the back stack
return TaskStackBuilder.create(context)
.addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false))
.addNextIntentWithParentStack(roomIntent)
.addNextIntent(threadIntentTap)
.getPendingIntent(
clock.epochMillis().toInt(),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
*/
} }
private fun buildOpenHomePendingIntentForSummary(): PendingIntent { private fun buildOpenHomePendingIntentForSummary(sessionId: String): PendingIntent {
TODO() val intent = intentProvider.getIntent(sessionId = sessionId, roomId = null, threadId = null)
/* intent.data = createIgnoredUri("tapSummary?$sessionId")
val intent = HomeActivity.newIntent(context, firstStartMainActivity = false, clearNotification = true)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
intent.data = createIgnoredUri("tapSummary")
val mainIntent = MainActivity.getIntentWithNextIntent(context, intent)
return PendingIntent.getActivity( return PendingIntent.getActivity(
context, context,
Random.nextInt(1000), clock.epochMillis().toInt(),
mainIntent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
) )
*/
} }
/* /*
@ -560,12 +525,18 @@ class NotificationUtils @Inject constructor(
However, for Android devices running Marshmallow and below (API level 23 and below), However, for Android devices running Marshmallow and below (API level 23 and below),
it will be more appropriate to use an activity. Since you have to provide your own UI. it will be more appropriate to use an activity. Since you have to provide your own UI.
*/ */
private fun buildQuickReplyIntent(roomId: String, threadId: String?, senderName: String?): PendingIntent? { private fun buildQuickReplyIntent(
sessionId: String,
roomId: String,
threadId: String?,
senderName: String?
): PendingIntent? {
val intent: Intent val intent: Intent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
intent = Intent(context, NotificationBroadcastReceiver::class.java) intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.smartReply intent.action = actionIds.smartReply
intent.data = createIgnoredUri(roomId) intent.data = createIgnoredUri("quickReply?$sessionId&$roomId")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
threadId?.let { threadId?.let {
intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it) intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it)
@ -602,6 +573,7 @@ class NotificationUtils @Inject constructor(
* Build the summary notification. * Build the summary notification.
*/ */
fun buildSummaryListNotification( fun buildSummaryListNotification(
sessionId: String,
style: NotificationCompat.InboxStyle?, style: NotificationCompat.InboxStyle?,
compatSummary: String, compatSummary: String,
noisy: Boolean, noisy: Boolean,
@ -615,12 +587,12 @@ class NotificationUtils @Inject constructor(
// used in compat < N, after summary is built based on child notifications // used in compat < N, after summary is built based on child notifications
.setWhen(lastMessageTimestamp) .setWhen(lastMessageTimestamp)
.setStyle(style) .setStyle(style)
.setContentTitle(buildMeta.applicationName) .setContentTitle(sessionId)
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setSmallIcon(smallIcon) .setSmallIcon(smallIcon)
// set content text to support devices running API level < 24 // set content text to support devices running API level < 24
.setContentText(compatSummary) .setContentText(compatSummary)
.setGroup(buildMeta.applicationName) .setGroup(sessionId)
// set this notification as the summary for the group // set this notification as the summary for the group
.setGroupSummary(true) .setGroupSummary(true)
.setColor(accentColor) .setColor(accentColor)
@ -639,15 +611,16 @@ class NotificationUtils @Inject constructor(
priority = NotificationCompat.PRIORITY_LOW priority = NotificationCompat.PRIORITY_LOW
} }
} }
.setContentIntent(buildOpenHomePendingIntentForSummary()) .setContentIntent(buildOpenHomePendingIntentForSummary(sessionId))
.setDeleteIntent(getDismissSummaryPendingIntent()) .setDeleteIntent(getDismissSummaryPendingIntent(sessionId))
.build() .build()
} }
private fun getDismissSummaryPendingIntent(): PendingIntent { private fun getDismissSummaryPendingIntent(sessionId: String): PendingIntent {
val intent = Intent(context, NotificationBroadcastReceiver::class.java) val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.dismissSummary intent.action = actionIds.dismissSummary
intent.data = createIgnoredUri("deleteSummary") intent.data = createIgnoredUri("deleteSummary?$sessionId")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
return PendingIntent.getBroadcast( return PendingIntent.getBroadcast(
context.applicationContext, context.applicationContext,
0, 0,
@ -703,23 +676,6 @@ class NotificationUtils @Inject constructor(
) )
} }
fun createTemporaryNotification(): Notification {
val contentIntent = intentProvider.getMainIntent()
val pendingIntent = PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE)
return NotificationCompat.Builder(context, NOISY_NOTIFICATION_CHANNEL_ID)
.setContentTitle(buildMeta.applicationName)
.setContentText(stringProvider.getString(R.string.notification_new_messages_temporary))
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(getBitmap(context, R.drawable.element_logo_green))
.setColor(ContextCompat.getColor(context, R.color.notification_accent_color))
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
}
private fun getBitmap(context: Context, @DrawableRes drawableRes: Int): Bitmap? { private fun getBitmap(context: Context, @DrawableRes drawableRes: Int): Bitmap? {
val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null
val canvas = Canvas() val canvas = Canvas()

3
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt

@ -20,8 +20,9 @@ package io.element.android.libraries.push.impl.notifications
* Data class to hold information about a group of notifications for a room. * Data class to hold information about a group of notifications for a room.
*/ */
data class RoomEventGroupInfo( data class RoomEventGroupInfo(
val sessionId: String,
val roomId: String, val roomId: String,
val roomDisplayName: String = "", val roomDisplayName: String,
val isDirect: Boolean = false val isDirect: Boolean = false
) { ) {
// An event in the list has not yet been display // An event in the list has not yet been display

17
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt

@ -33,7 +33,13 @@ class RoomGroupMessageCreator @Inject constructor(
private val notificationUtils: NotificationUtils private val notificationUtils: NotificationUtils
) { ) {
fun createRoomMessage(events: List<NotifiableMessageEvent>, roomId: String, userDisplayName: String, userAvatarUrl: String?): RoomNotification.Message { fun createRoomMessage(
sessionId: String,
events: List<NotifiableMessageEvent>,
roomId: String,
userDisplayName: String,
userAvatarUrl: String?
): RoomNotification.Message {
val lastKnownRoomEvent = events.last() val lastKnownRoomEvent = events.last()
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: "" val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: ""
val roomIsGroup = !lastKnownRoomEvent.roomIsDirect val roomIsGroup = !lastKnownRoomEvent.roomIsDirect
@ -41,7 +47,7 @@ class RoomGroupMessageCreator @Inject constructor(
Person.Builder() Person.Builder()
.setName(userDisplayName) .setName(userDisplayName)
.setIcon(bitmapLoader.getUserIcon(userAvatarUrl)) .setIcon(bitmapLoader.getUserIcon(userAvatarUrl))
.setKey(lastKnownRoomEvent.matrixID) .setKey(lastKnownRoomEvent.sessionId)
.build() .build()
).also { ).also {
it.conversationTitle = roomName.takeIf { roomIsGroup } it.conversationTitle = roomName.takeIf { roomIsGroup }
@ -70,7 +76,12 @@ class RoomGroupMessageCreator @Inject constructor(
return RoomNotification.Message( return RoomNotification.Message(
notificationUtils.buildMessagesListNotification( notificationUtils.buildMessagesListNotification(
style, style,
RoomEventGroupInfo(roomId, roomName, isDirect = !roomIsGroup).also { RoomEventGroupInfo(
sessionId = sessionId,
roomId = roomId,
roomDisplayName = roomName,
isDirect = !roomIsGroup,
).also {
it.hasSmartReplyError = smartReplyErrors.isNotEmpty() it.hasSmartReplyError = smartReplyErrors.isNotEmpty()
it.shouldBing = meta.shouldBing it.shouldBing = meta.shouldBing
it.customSound = events.last().soundName it.customSound = events.last().soundName

5
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt

@ -42,6 +42,7 @@ class SummaryGroupMessageCreator @Inject constructor(
) { ) {
fun createSummaryNotification( fun createSummaryNotification(
sessionId: String,
roomNotifications: List<RoomNotification.Message.Meta>, roomNotifications: List<RoomNotification.Message.Meta>,
invitationNotifications: List<OneShotNotification.Append.Meta>, invitationNotifications: List<OneShotNotification.Append.Meta>,
simpleNotifications: List<OneShotNotification.Append.Meta>, simpleNotifications: List<OneShotNotification.Append.Meta>,
@ -71,6 +72,7 @@ class SummaryGroupMessageCreator @Inject constructor(
.setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents)) .setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents))
return if (useCompleteNotificationFormat) { return if (useCompleteNotificationFormat) {
notificationUtils.buildSummaryListNotification( notificationUtils.buildSummaryListNotification(
sessionId,
summaryInboxStyle, summaryInboxStyle,
sumTitle, sumTitle,
noisy = summaryIsNoisy, noisy = summaryIsNoisy,
@ -78,6 +80,7 @@ class SummaryGroupMessageCreator @Inject constructor(
) )
} else { } else {
processSimpleGroupSummary( processSimpleGroupSummary(
sessionId,
summaryIsNoisy, summaryIsNoisy,
messageCount, messageCount,
simpleNotifications.size, simpleNotifications.size,
@ -89,6 +92,7 @@ class SummaryGroupMessageCreator @Inject constructor(
} }
private fun processSimpleGroupSummary( private fun processSimpleGroupSummary(
sessionId: String,
summaryIsNoisy: Boolean, summaryIsNoisy: Boolean,
messageEventsCount: Int, messageEventsCount: Int,
simpleEventsCount: Int, simpleEventsCount: Int,
@ -147,6 +151,7 @@ class SummaryGroupMessageCreator @Inject constructor(
} }
} }
return notificationUtils.buildSummaryListNotification( return notificationUtils.buildSummaryListNotification(
sessionId = sessionId,
style = null, style = null,
compatSummary = privacyTitle, compatSummary = privacyTitle,
noisy = summaryIsNoisy, noisy = summaryIsNoisy,

28
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt

@ -16,18 +16,18 @@
package io.element.android.libraries.push.impl.notifications.model package io.element.android.libraries.push.impl.notifications.model
data class InviteNotifiableEvent( data class InviteNotifiableEvent(
val matrixID: String?, override val sessionId: String,
override val eventId: String, override val roomId: String,
override val editedEventId: String?, override val eventId: String,
override val canBeReplaced: Boolean, override val editedEventId: String?,
val roomId: String, override val canBeReplaced: Boolean,
val roomName: String?, val roomName: String?,
val noisy: Boolean, val noisy: Boolean,
val title: String, val title: String,
val description: String, val description: String,
val type: String?, val type: String?,
val timestamp: Long, val timestamp: Long,
val soundName: String?, val soundName: String?,
override val isRedacted: Boolean = false, override val isRedacted: Boolean = false,
override val isUpdated: Boolean = false override val isUpdated: Boolean = false
) : NotifiableEvent ) : NotifiableEvent

2
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt

@ -21,6 +21,8 @@ import java.io.Serializable
* Parent interface for all events which can be displayed as a Notification. * Parent interface for all events which can be displayed as a Notification.
*/ */
sealed interface NotifiableEvent : Serializable { sealed interface NotifiableEvent : Serializable {
val sessionId: String
val roomId: String
val eventId: String val eventId: String
val editedEventId: String? val editedEventId: String?

17
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt

@ -16,8 +16,14 @@
package io.element.android.libraries.push.impl.notifications.model package io.element.android.libraries.push.impl.notifications.model
import android.net.Uri import android.net.Uri
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.currentRoomId
import io.element.android.services.appnavstate.api.currentSessionId
import io.element.android.services.appnavstate.api.currentThreadId
data class NotifiableMessageEvent( data class NotifiableMessageEvent(
override val sessionId: String,
override val roomId: String,
override val eventId: String, override val eventId: String,
override val editedEventId: String?, override val editedEventId: String?,
override val canBeReplaced: Boolean, override val canBeReplaced: Boolean,
@ -29,13 +35,11 @@ data class NotifiableMessageEvent(
// We cannot use Uri? type here, as that could trigger a // We cannot use Uri? type here, as that could trigger a
// NotSerializableException when persisting this to storage // NotSerializableException when persisting this to storage
val imageUriString: String?, val imageUriString: String?,
val roomId: String,
val threadId: String?, val threadId: String?,
val roomName: String?, val roomName: String?,
val roomIsDirect: Boolean = false, val roomIsDirect: Boolean = false,
val roomAvatarPath: String? = null, val roomAvatarPath: String? = null,
val senderAvatarPath: String? = null, val senderAvatarPath: String? = null,
val matrixID: String? = null,
val soundName: String? = null, val soundName: String? = null,
// This is used for >N notification, as the result of a smart reply // This is used for >N notification, as the result of a smart reply
val outGoingMessage: Boolean = false, val outGoingMessage: Boolean = false,
@ -52,9 +56,12 @@ data class NotifiableMessageEvent(
get() = imageUriString?.let { Uri.parse(it) } get() = imageUriString?.let { Uri.parse(it) }
} }
fun NotifiableMessageEvent.shouldIgnoreMessageEventInRoom(currentRoomId: String?, currentThreadId: String?): Boolean { fun NotifiableMessageEvent.shouldIgnoreMessageEventInRoom(
return when (currentRoomId) { appNavigationState: AppNavigationState?
): Boolean {
val currentSessionId = appNavigationState?.currentSessionId()?.value ?: return false
return when (val currentRoomId = appNavigationState.currentRoomId()?.value) {
null -> false null -> false
else -> roomId == currentRoomId && threadId == currentThreadId else -> sessionId == currentSessionId && roomId == currentRoomId && threadId == appNavigationState.currentThreadId()?.value
} }
} }

25
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt

@ -16,16 +16,17 @@
package io.element.android.libraries.push.impl.notifications.model package io.element.android.libraries.push.impl.notifications.model
data class SimpleNotifiableEvent( data class SimpleNotifiableEvent(
val matrixID: String?, override val sessionId: String,
override val eventId: String, override val roomId: String,
override val editedEventId: String?, override val eventId: String,
val noisy: Boolean, override val editedEventId: String?,
val title: String, val noisy: Boolean,
val description: String, val title: String,
val type: String?, val description: String,
val timestamp: Long, val type: String?,
val soundName: String?, val timestamp: Long,
override var canBeReplaced: Boolean, val soundName: String?,
override val isRedacted: Boolean = false, override var canBeReplaced: Boolean,
override val isUpdated: Boolean = false override val isRedacted: Boolean = false,
override val isUpdated: Boolean = false
) : NotifiableEvent ) : NotifiableEvent

101
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt

@ -20,15 +20,12 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import io.element.android.libraries.androidutils.network.WifiDetector import io.element.android.libraries.androidutils.network.WifiDetector
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.store.PushDataStore import io.element.android.libraries.push.api.store.PushDataStore
import io.element.android.libraries.push.impl.PushersManager import io.element.android.libraries.push.impl.PushersManager
import io.element.android.libraries.push.impl.clientsecret.PushClientSecret import io.element.android.libraries.push.impl.clientsecret.PushClientSecret
@ -49,7 +46,6 @@ private val loggerTag = LoggerTag("PushHandler", pushLoggerTag)
class PushHandler @Inject constructor( class PushHandler @Inject constructor(
private val notificationDrawerManager: NotificationDrawerManager, private val notificationDrawerManager: NotificationDrawerManager,
private val notifiableEventResolver: NotifiableEventResolver, private val notifiableEventResolver: NotifiableEventResolver,
// private val activeSessionHolder: ActiveSessionHolder,
private val pushDataStore: PushDataStore, private val pushDataStore: PushDataStore,
private val defaultPushDataStore: DefaultPushDataStore, private val defaultPushDataStore: DefaultPushDataStore,
private val pushClientSecret: PushClientSecret, private val pushClientSecret: PushClientSecret,
@ -95,12 +91,7 @@ class PushHandler @Inject constructor(
} }
mUIHandler.post { mUIHandler.post {
if (ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) }
// we are in foreground, let the sync do the things?
Timber.tag(loggerTag.value).d("PUSH received in a foreground state, ignore")
} else {
coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) }
}
} }
} }
@ -136,96 +127,16 @@ class PushHandler @Inject constructor(
return return
} }
// Restore session val notificationData = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId)
val session = matrixAuthenticationService.restoreSession(SessionId(userId)).getOrNull() ?: return
// TODO EAx, no need for a session?
val notificationData = session.let {// TODO Use make the app crashes
it.notificationService().getNotification(
userId = userId,
roomId = pushData.roomId,
eventId = pushData.eventId,
)
}
// TODO Remove
Timber.w("Notification: $notificationData")
// TODO Display notification
notificationDrawerManager.displayTemporaryNotification()
/* TODO EAx
- get the event
- display the notif
val session = activeSessionHolder.getOrInitializeSession()
if (session == null) { if (notificationData == null) {
Timber.tag(loggerTag.value).w("## Can't sync from push, no current session") Timber.w("Unable to get a notification data")
} else { return
if (isEventAlreadyKnown(pushData)) {
Timber.tag(loggerTag.value).d("Ignoring push, event already known")
} else {
// Try to get the Event content faster
Timber.tag(loggerTag.value).d("Requesting event in fast lane")
getEventFastLane(session, pushData)
Timber.tag(loggerTag.value).d("Requesting background sync")
session.syncService().requireBackgroundSync()
}
} }
*/ notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(notificationData) }
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
} }
} }
/* TODO EAx
private suspend fun getEventFastLane(session: Session, pushData: PushData) {
pushData.roomId ?: return
pushData.eventId ?: return
if (wifiDetector.isConnectedToWifi().not()) {
Timber.tag(loggerTag.value).d("No WiFi network, do not get Event")
return
}
Timber.tag(loggerTag.value).d("Fast lane: start request")
val event = tryOrNull { session.eventService().getEvent(pushData.roomId, pushData.eventId) } ?: return
val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event, canBeReplaced = true)
if (resolvedEvent is NotifiableMessageEvent) {
// If the room is currently displayed, we will not show a notification, so no need to get the Event faster
if (notificationDrawerManager.shouldIgnoreMessageEventInRoom(resolvedEvent)) {
return
}
}
resolvedEvent
?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") }
?.let {
notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(resolvedEvent) }
}
}
*/
// check if the event was not yet received
// a previous catchup might have already retrieved the notified event
private fun isEventAlreadyKnown(pushData: PushData): Boolean {
/* TODO EAx
if (pushData.eventId != null && pushData.roomId != null) {
try {
val session = activeSessionHolder.getSafeActiveSession() ?: return false
val room = session.getRoom(pushData.roomId) ?: return false
return room.getTimelineEvent(pushData.eventId) != null
} catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## isEventAlreadyKnown() : failed to check if the event was already defined")
}
}
*/
return false
}
} }

1
libraries/push/impl/src/main/res/values/temporary.xml

@ -25,7 +25,6 @@
<string name="notification_silent_notifications">Silent notifications</string> <string name="notification_silent_notifications">Silent notifications</string>
<string name="call">Call</string> <string name="call">Call</string>
<string name="notification_new_messages">New Messages</string> <string name="notification_new_messages">New Messages</string>
<string name="notification_new_messages_temporary">You have new message(s)</string>
<string name="action_mark_room_read">Mark as read</string> <string name="action_mark_room_read">Mark as read</string>
<string name="action_join">Join</string> <string name="action_join">Join</string>
<string name="action_reject">Reject</string> <string name="action_reject">Reject</string>

Loading…
Cancel
Save