Browse Source

Merge pull request #326 from vector-im/feature/bma/push4

Notification update
test/jme/compound-poc
Benoit Marty 1 year ago committed by GitHub
parent
commit
d68e4bd4f0
  1. 12
      libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt
  2. 25
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/NotificationConfig.kt
  3. 2
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt
  4. 33
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt
  5. 8
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt
  6. 718
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt
  7. 8
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt
  8. 7
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt
  9. 179
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt
  10. 295
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt
  11. 100
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt
  12. 60
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt
  13. 65
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt
  14. 103
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt
  15. 60
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt
  16. 10
      libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt
  17. 14
      libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt

12
libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt

@ -19,6 +19,7 @@ package io.element.android.libraries.androidutils.system @@ -19,6 +19,7 @@ package io.element.android.libraries.androidutils.system
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.app.Activity
import android.app.NotificationManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
@ -72,6 +73,17 @@ fun Context.getApplicationLabel(packageName: String): String { @@ -72,6 +73,17 @@ fun Context.getApplicationLabel(packageName: String): String {
}
}
/**
* Return true it the user has enabled the do not disturb mode.
*/
fun isDoNotDisturbModeOn(context: Context): Boolean {
// We cannot use NotificationManagerCompat here.
val setting = context.getSystemService<NotificationManager>()!!.currentInterruptionFilter
return setting == NotificationManager.INTERRUPTION_FILTER_NONE ||
setting == NotificationManager.INTERRUPTION_FILTER_ALARMS
}
/**
* display the system dialog for granting this permission. If previously granted, the
* system will not show it (so you should call this method).

25
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/NotificationConfig.kt

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
/*
* 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
object NotificationConfig {
// TODO EAx Implement and set to true at some point
const val supportMarkAsReadAction = false
// TODO EAx Implement and set to true at some point
const val supportQuickReplyAction = false
}

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

@ -89,7 +89,7 @@ private fun NotificationData.asNotifiableEvent(userId: SessionId, roomId: RoomId @@ -89,7 +89,7 @@ private fun NotificationData.asNotifiableEvent(userId: SessionId, roomId: RoomId
timestamp = System.currentTimeMillis(),
senderName = null,
senderId = null,
body = "$eventId in $roomId",
body = "Message ${eventId.value.take(8)} in room ${roomId.value.take(8)}",
imageUriString = null,
threadId = null,
roomName = null,

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

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
package io.element.android.libraries.push.impl.notifications
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.content.Context
import android.content.pm.PackageManager
@ -51,4 +52,36 @@ class NotificationDisplayer @Inject constructor( @@ -51,4 +52,36 @@ class NotificationDisplayer @Inject constructor(
Timber.e(e, "## cancelAllNotifications() failed")
}
}
@SuppressLint("LaunchActivityFromNotification")
fun displayDiagnosticNotification(notification: Notification) {
showNotificationMessage(
tag = "DIAGNOSTIC",
id = NOTIFICATION_ID_DIAGNOSTIC,
notification = notification
)
}
/**
* Cancel the foreground notification service.
*/
fun cancelNotificationForegroundService() {
notificationManager.cancel(NOTIFICATION_ID_FOREGROUND_SERVICE)
}
companion object {
/* ==========================================================================================
* IDs for notifications
* ========================================================================================== */
/**
* Identifier of the foreground notification used to keep the application alive
* when it runs in background.
* This notification, which is not removable by the end user, displays what
* the application is doing while in background.
*/
private const val NOTIFICATION_ID_FOREGROUND_SERVICE = 61
private const val NOTIFICATION_ID_DIAGNOSTIC = 888
}
}

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

@ -19,6 +19,7 @@ package io.element.android.libraries.push.impl.notifications @@ -19,6 +19,7 @@ package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
@ -26,8 +27,9 @@ import javax.inject.Inject @@ -26,8 +27,9 @@ import javax.inject.Inject
private typealias ProcessedMessageEvents = List<ProcessedEvent<NotifiableMessageEvent>>
// TODO Find a better name, it clashes with io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
class NotificationFactory @Inject constructor(
private val notificationUtils: NotificationUtils,
private val notificationFactory: NotificationFactory,
private val roomGroupMessageCreator: RoomGroupMessageCreator,
private val summaryGroupMessageCreator: SummaryGroupMessageCreator
) {
@ -66,7 +68,7 @@ class NotificationFactory @Inject constructor( @@ -66,7 +68,7 @@ class NotificationFactory @Inject constructor(
when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId.value)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationUtils.buildRoomInvitationNotification(event),
notificationFactory.createRoomInvitationNotification(event),
OneShotNotification.Append.Meta(
key = event.roomId.value,
summaryLine = event.description,
@ -84,7 +86,7 @@ class NotificationFactory @Inject constructor( @@ -84,7 +86,7 @@ class NotificationFactory @Inject constructor(
when (processed) {
ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value)
ProcessedEvent.Type.KEEP -> OneShotNotification.Append(
notificationUtils.buildSimpleEventNotification(event),
notificationFactory.createSimpleEventNotification(event),
OneShotNotification.Append.Meta(
key = event.eventId.value,
summaryLine = event.description,

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

@ -1,718 +0,0 @@ @@ -1,718 +0,0 @@
/*
* 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.
*/
@file:Suppress("UNUSED_PARAMETER")
package io.element.android.libraries.push.impl.notifications
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.annotation.DrawableRes
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.content.res.ResourcesCompat
import io.element.android.libraries.androidutils.system.startNotificationChannelSettingsIntent
import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.intent.IntentProvider
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import timber.log.Timber
import javax.inject.Inject
// TODO EAx Split into factories
@SingleIn(AppScope::class)
class NotificationUtils @Inject constructor(
@ApplicationContext private val context: Context,
// private val vectorPreferences: VectorPreferences,
private val stringProvider: StringProvider,
private val clock: SystemClock,
private val actionIds: NotificationActionIds,
private val intentProvider: IntentProvider,
private val buildMeta: BuildMeta,
) {
companion object {
/* ==========================================================================================
* IDs for notifications
* ========================================================================================== */
/**
* Identifier of the foreground notification used to keep the application alive
* when it runs in background.
* This notification, which is not removable by the end user, displays what
* the application is doing while in background.
*/
const val NOTIFICATION_ID_FOREGROUND_SERVICE = 61
/* ==========================================================================================
* IDs for channels
* ========================================================================================== */
// on devices >= android O, we need to define a channel for each notifications
private const val LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID = "LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID"
private const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID"
const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2"
private const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V2"
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
fun openSystemSettingsForSilentCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, SILENT_NOTIFICATION_CHANNEL_ID)
}
fun openSystemSettingsForNoisyCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, NOISY_NOTIFICATION_CHANNEL_ID)
}
fun openSystemSettingsForCallCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, CALL_NOTIFICATION_CHANNEL_ID)
}
}
private val notificationManager = NotificationManagerCompat.from(context)
init {
createNotificationChannels()
}
/* ==========================================================================================
* Channel names
* ========================================================================================== */
/**
* Create notification channels.
*/
private fun createNotificationChannels() {
if (!supportNotificationChannels()) {
return
}
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Migration - the noisy channel was deleted and recreated when sound preference was changed (id was DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE
// + currentTimeMillis).
// Now the sound can only be change directly in system settings, so for app upgrading we are deleting this former channel
// Starting from this version the channel will not be dynamic
for (channel in notificationManager.notificationChannels) {
val channelId = channel.id
val legacyBaseName = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE"
if (channelId.startsWith(legacyBaseName)) {
notificationManager.deleteNotificationChannel(channelId)
}
}
// Migration - Remove deprecated channels
for (channelId in listOf("DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID", "CALL_NOTIFICATION_CHANNEL_ID")) {
notificationManager.getNotificationChannel(channelId)?.let {
notificationManager.deleteNotificationChannel(channelId)
}
}
/**
* Default notification importance: shows everywhere, makes noise, but does not visually
* intrude.
*/
notificationManager.createNotificationChannel(NotificationChannel(
NOISY_NOTIFICATION_CHANNEL_ID,
stringProvider.getString(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" },
NotificationManager.IMPORTANCE_DEFAULT
)
.apply {
description = stringProvider.getString(R.string.notification_channel_noisy)
enableVibration(true)
enableLights(true)
lightColor = accentColor
})
/**
* Low notification importance: shows everywhere, but is not intrusive.
*/
notificationManager.createNotificationChannel(NotificationChannel(
SILENT_NOTIFICATION_CHANNEL_ID,
stringProvider.getString(R.string.notification_channel_silent).ifEmpty { "Silent notifications" },
NotificationManager.IMPORTANCE_LOW
)
.apply {
description = stringProvider.getString(R.string.notification_channel_silent)
setSound(null, null)
enableLights(true)
lightColor = accentColor
})
notificationManager.createNotificationChannel(NotificationChannel(
LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID,
stringProvider.getString(R.string.notification_channel_listening_for_events).ifEmpty { "Listening for events" },
NotificationManager.IMPORTANCE_MIN
)
.apply {
description = stringProvider.getString(R.string.notification_channel_listening_for_events)
setSound(null, null)
setShowBadge(false)
})
notificationManager.createNotificationChannel(NotificationChannel(
CALL_NOTIFICATION_CHANNEL_ID,
stringProvider.getString(R.string.notification_channel_call).ifEmpty { "Call" },
NotificationManager.IMPORTANCE_HIGH
)
.apply {
description = stringProvider.getString(R.string.notification_channel_call)
setSound(null, null)
enableLights(true)
lightColor = accentColor
})
}
fun getChannel(channelId: String): NotificationChannel? {
return notificationManager.getNotificationChannel(channelId)
}
fun getChannelForIncomingCall(fromBg: Boolean): NotificationChannel? {
val notificationChannel = if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
return getChannel(notificationChannel)
}
/**
* Build a notification for a Room.
*/
fun buildMessagesListNotification(
messageStyle: NotificationCompat.MessagingStyle,
roomInfo: RoomEventGroupInfo,
threadId: ThreadId?,
largeIcon: Bitmap?,
lastMessageTimestamp: Long,
senderDisplayNameForReplyCompat: String?,
tickerText: String
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Build the pending intent for when the notification is clicked
val openIntent = when {
threadId != null &&
true
/** TODO EAx vectorPreferences.areThreadMessagesEnabled() */
-> buildOpenThreadIntent(roomInfo, threadId)
else -> buildOpenRoomIntent(roomInfo.sessionId, roomInfo.roomId)
}
val smallIcon = R.drawable.ic_notification
val channelID = if (roomInfo.shouldBing) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
return NotificationCompat.Builder(context, channelID)
.setOnlyAlertOnce(roomInfo.isUpdated)
.setWhen(lastMessageTimestamp)
// MESSAGING_STYLE sets title and content for API 16 and above devices.
.setStyle(messageStyle)
// A category allows groups of notifications to be ranked and filtered – per user or system settings.
// For example, alarm notifications should display before promo notifications, or message from known contact
// that can be displayed in not disturb mode if white listed (the later will need compat28.x)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
// ID of the corresponding shortcut, for conversation features under API 30+
.setShortcutId(roomInfo.roomId.value)
// Title for API < 16 devices.
.setContentTitle(roomInfo.roomDisplayName)
// Content for API < 16 devices.
.setContentText(stringProvider.getString(R.string.notification_new_messages))
// Number of new notifications for API <24 (M and below) devices.
.setSubText(
stringProvider.getQuantityString(
R.plurals.notification_new_messages_for_room,
messageStyle.messages.size,
messageStyle.messages.size
)
)
// 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
.setGroup(roomInfo.sessionId.value)
// In order to avoid notification making sound twice (due to the summary notification)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
// Set primary color (important for Wear 2.0 Notifications).
.setColor(accentColor)
// Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for
// 'importance' which is set in the NotificationChannel. The integers representing
// 'priority' are different from 'importance', so make sure you don't mix them.
.apply {
if (roomInfo.shouldBing) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(accentColor, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
// Add actions and notification intents
// Mark room as read
val markRoomReadIntent = Intent(context, NotificationBroadcastReceiver::class.java)
markRoomReadIntent.action = actionIds.markRoomRead
markRoomReadIntent.data = createIgnoredUri("markRead?${roomInfo.sessionId}&$${roomInfo.roomId}")
markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, roomInfo.sessionId)
markRoomReadIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
val markRoomReadPendingIntent = PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
markRoomReadIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
NotificationCompat.Action.Builder(
R.drawable.ic_material_done_all_white,
stringProvider.getString(R.string.notification_room_action_mark_as_read), markRoomReadPendingIntent
)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
.let { addAction(it) }
// Quick reply
if (!roomInfo.hasSmartReplyError) {
buildQuickReplyIntent(roomInfo.sessionId, roomInfo.roomId, threadId, senderDisplayNameForReplyCompat)?.let { replyPendingIntent ->
val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
.setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply))
.build()
NotificationCompat.Action.Builder(
R.drawable.vector_notification_quick_reply,
stringProvider.getString(R.string.notification_room_action_quick_reply), replyPendingIntent
)
.addRemoteInput(remoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build()
.let { addAction(it) }
}
}
if (openIntent != null) {
setContentIntent(openIntent)
}
if (largeIcon != null) {
setLargeIcon(largeIcon)
}
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.dismissRoom
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, roomInfo.sessionId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomInfo.roomId)
val pendingIntent = PendingIntent.getBroadcast(
context.applicationContext,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setDeleteIntent(pendingIntent)
}
.setTicker(tickerText)
.build()
}
fun buildRoomInvitationNotification(
inviteNotifiableEvent: InviteNotifiableEvent
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Build the pending intent for when the notification is clicked
val smallIcon = R.drawable.ic_notification
val channelID = if (inviteNotifiableEvent.noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
return NotificationCompat.Builder(context, channelID)
.setOnlyAlertOnce(true)
.setContentTitle(inviteNotifiableEvent.roomName ?: buildMeta.applicationName)
.setContentText(inviteNotifiableEvent.description)
.setGroup(inviteNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(accentColor)
.apply {
val roomId = inviteNotifiableEvent.roomId
// offer to type a quick reject button
val rejectIntent = Intent(context, NotificationBroadcastReceiver::class.java)
rejectIntent.action = actionIds.reject
rejectIntent.data = createIgnoredUri("rejectInvite?${inviteNotifiableEvent.sessionId}&$roomId")
rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, inviteNotifiableEvent.sessionId)
rejectIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val rejectIntentPendingIntent = PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
rejectIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
addAction(
R.drawable.vector_notification_reject_invitation,
stringProvider.getString(R.string.notification_invitation_action_reject),
rejectIntentPendingIntent
)
// offer to type a quick accept button
val joinIntent = Intent(context, NotificationBroadcastReceiver::class.java)
joinIntent.action = actionIds.join
joinIntent.data = createIgnoredUri("acceptInvite?${inviteNotifiableEvent.sessionId}&$roomId")
joinIntent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, inviteNotifiableEvent.sessionId)
joinIntent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val joinIntentPendingIntent = PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
joinIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
addAction(
R.drawable.vector_notification_accept_invitation,
stringProvider.getString(R.string.notification_invitation_action_join),
joinIntentPendingIntent
)
/*
val contentIntent = HomeActivity.newIntent(
context,
firstStartMainActivity = true,
inviteNotificationRoomId = inviteNotifiableEvent.roomId
)
contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
contentIntent.data = createIgnoredUri(inviteNotifiableEvent.eventId)
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
*/
if (inviteNotifiableEvent.noisy) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(accentColor, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
setAutoCancel(true)
}
.build()
}
fun buildSimpleEventNotification(
simpleNotifiableEvent: SimpleNotifiableEvent,
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Build the pending intent for when the notification is clicked
val smallIcon = R.drawable.ic_notification
val channelID = if (simpleNotifiableEvent.noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
return NotificationCompat.Builder(context, channelID)
.setOnlyAlertOnce(true)
.setContentTitle(buildMeta.applicationName)
.setContentText(simpleNotifiableEvent.description)
.setGroup(simpleNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(accentColor)
.setAutoCancel(true)
.apply {
/* TODO EAx
val contentIntent = HomeActivity.newIntent(context, firstStartMainActivity = true)
contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
contentIntent.data = createIgnoredUri(simpleNotifiableEvent.eventId)
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
*/
if (simpleNotifiableEvent.noisy) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(accentColor, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
setAutoCancel(true)
}
.build()
}
private fun buildOpenRoomIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? {
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = null)
return PendingIntent.getActivity(
context,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? {
val sessionId = roomInfo.sessionId
val roomId = roomInfo.roomId
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
return PendingIntent.getActivity(
context,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun buildOpenHomePendingIntentForSummary(sessionId: SessionId): PendingIntent {
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = null, threadId = null)
return PendingIntent.getActivity(
context,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
/*
Direct reply is new in Android N, and Android already handles the UI, so the right pending intent
here will ideally be a Service/IntentService (for a long running background task) or a BroadcastReceiver,
which runs on the UI thread. It also works without unlocking, making the process really fluid for the user.
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.
*/
private fun buildQuickReplyIntent(
sessionId: SessionId,
roomId: RoomId,
threadId: ThreadId?,
senderName: String?
): PendingIntent? {
val intent: Intent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.smartReply
intent.data = createIgnoredUri("quickReply?$sessionId&$roomId")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
threadId?.let {
intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it)
}
return PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
// PendingIntents attached to actions with remote inputs must be mutable
PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
)
} else {
/*
TODO
if (!LockScreenActivity.isDisplayingALockScreenActivity()) {
// start your activity for Android M and below
val quickReplyIntent = Intent(context, LockScreenActivity::class.java)
quickReplyIntent.putExtra(LockScreenActivity.EXTRA_ROOM_ID, roomId)
quickReplyIntent.putExtra(LockScreenActivity.EXTRA_SENDER_NAME, senderName ?: "")
// the action must be unique else the parameters are ignored
quickReplyIntent.action = QUICK_LAUNCH_ACTION
quickReplyIntent.data = createIgnoredUri($roomId")
return PendingIntent.getActivity(context, 0, quickReplyIntent, PendingIntentCompat.FLAG_IMMUTABLE)
}
*/
}
return null
}
// // Number of new notifications for API <24 (M and below) devices.
/**
* Build the summary notification.
*/
fun buildSummaryListNotification(
sessionId: SessionId,
style: NotificationCompat.InboxStyle?,
compatSummary: String,
noisy: Boolean,
lastMessageTimestamp: Long
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
val smallIcon = R.drawable.ic_notification
return NotificationCompat.Builder(context, if (noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID)
.setOnlyAlertOnce(true)
// used in compat < N, after summary is built based on child notifications
.setWhen(lastMessageTimestamp)
.setStyle(style)
.setContentTitle(sessionId.value)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setSmallIcon(smallIcon)
// set content text to support devices running API level < 24
.setContentText(compatSummary)
.setGroup(sessionId.value)
// set this notification as the summary for the group
.setGroupSummary(true)
.setColor(accentColor)
.apply {
if (noisy) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(accentColor, 500, 500)
} else {
// compat
priority = NotificationCompat.PRIORITY_LOW
}
}
.setContentIntent(buildOpenHomePendingIntentForSummary(sessionId))
.setDeleteIntent(getDismissSummaryPendingIntent(sessionId))
.build()
}
private fun getDismissSummaryPendingIntent(sessionId: SessionId): PendingIntent {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.dismissSummary
intent.data = createIgnoredUri("deleteSummary?$sessionId")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
return PendingIntent.getBroadcast(
context.applicationContext,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
/**
* Cancel the foreground notification service.
*/
fun cancelNotificationForegroundService() {
notificationManager.cancel(NOTIFICATION_ID_FOREGROUND_SERVICE)
}
/**
* Cancel all the notification.
*/
fun cancelAllNotifications() {
// Keep this try catch (reported by GA)
try {
notificationManager.cancelAll()
} catch (e: Exception) {
Timber.e(e, "## cancelAllNotifications() failed")
}
}
@SuppressLint("LaunchActivityFromNotification")
fun displayDiagnosticNotification() {
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
Timber.w("Not allowed to notify.")
return
}
val testActionIntent = Intent(context, TestNotificationReceiver::class.java)
testActionIntent.action = actionIds.diagnostic
val testPendingIntent = PendingIntent.getBroadcast(
context,
0,
testActionIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
notificationManager.notify(
"DIAGNOSTIC",
888,
NotificationCompat.Builder(context, NOISY_NOTIFICATION_CHANNEL_ID)
.setContentTitle(buildMeta.applicationName)
.setContentText(stringProvider.getString(R.string.notification_test_push_notification_content))
.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(testPendingIntent)
.build()
)
}
private fun getBitmap(context: Context, @DrawableRes drawableRes: Int): Bitmap? {
val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null
val canvas = Canvas()
val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888)
canvas.setBitmap(bitmap)
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
drawable.draw(canvas)
return bitmap
}
/**
* Return true it the user has enabled the do not disturb mode.
*/
fun isDoNotDisturbModeOn(): Boolean {
// We cannot use NotificationManagerCompat here.
val setting = context.getSystemService<NotificationManager>()!!.currentInterruptionFilter
return setting == NotificationManager.INTERRUPTION_FILTER_NONE ||
setting == NotificationManager.INTERRUPTION_FILTER_ALARMS
}
/*
private fun getActionText(@StringRes stringRes: Int, @AttrRes colorRes: Int): Spannable {
return SpannableString(context.getText(stringRes)).apply {
val foregroundColorSpan = ForegroundColorSpan(ThemeUtils.getColor(context, colorRes))
setSpan(foregroundColorSpan, 0, length, 0)
}
}
*/
private fun ensureTitleNotEmpty(title: String?): CharSequence {
if (title.isNullOrBlank()) {
return buildMeta.applicationName
}
return title
}
}

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

@ -22,6 +22,7 @@ import androidx.core.app.Person @@ -22,6 +22,7 @@ import androidx.core.app.Person
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import me.gujun.android.span.Span
@ -32,7 +33,7 @@ import javax.inject.Inject @@ -32,7 +33,7 @@ import javax.inject.Inject
class RoomGroupMessageCreator @Inject constructor(
private val bitmapLoader: NotificationBitmapLoader,
private val stringProvider: StringProvider,
private val notificationUtils: NotificationUtils
private val notificationFactory: NotificationFactory
) {
fun createRoomMessage(
@ -43,7 +44,7 @@ class RoomGroupMessageCreator @Inject constructor( @@ -43,7 +44,7 @@ class RoomGroupMessageCreator @Inject constructor(
userAvatarUrl: String?
): RoomNotification.Message {
val lastKnownRoomEvent = events.last()
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: ""
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: "Room name (${roomId.value.take(8)}…)"
val roomIsGroup = !lastKnownRoomEvent.roomIsDirect
val style = NotificationCompat.MessagingStyle(
Person.Builder()
@ -76,7 +77,7 @@ class RoomGroupMessageCreator @Inject constructor( @@ -76,7 +77,7 @@ class RoomGroupMessageCreator @Inject constructor(
shouldBing = events.any { it.noisy }
)
return RoomNotification.Message(
notificationUtils.buildMessagesListNotification(
notificationFactory.createMessagesListNotification(
style,
RoomEventGroupInfo(
sessionId = sessionId,
@ -92,7 +93,6 @@ class RoomGroupMessageCreator @Inject constructor( @@ -92,7 +93,6 @@ class RoomGroupMessageCreator @Inject constructor(
threadId = lastKnownRoomEvent.threadId,
largeIcon = largeBitmap,
lastMessageTimestamp,
userDisplayName,
tickerText
),
meta

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

@ -20,6 +20,7 @@ import android.app.Notification @@ -20,6 +20,7 @@ import android.app.Notification
import androidx.core.app.NotificationCompat
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
@ -39,7 +40,7 @@ import javax.inject.Inject @@ -39,7 +40,7 @@ import javax.inject.Inject
*/
class SummaryGroupMessageCreator @Inject constructor(
private val stringProvider: StringProvider,
private val notificationUtils: NotificationUtils
private val notificationFactory: NotificationFactory
) {
fun createSummaryNotification(
@ -72,7 +73,7 @@ class SummaryGroupMessageCreator @Inject constructor( @@ -72,7 +73,7 @@ class SummaryGroupMessageCreator @Inject constructor(
// TODO get latest event?
.setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents))
return if (useCompleteNotificationFormat) {
notificationUtils.buildSummaryListNotification(
notificationFactory.createSummaryListNotification(
sessionId,
summaryInboxStyle,
sumTitle,
@ -165,7 +166,7 @@ class SummaryGroupMessageCreator @Inject constructor( @@ -165,7 +166,7 @@ class SummaryGroupMessageCreator @Inject constructor(
messageStr
}
}
return notificationUtils.buildSummaryListNotification(
return notificationFactory.createSummaryListNotification(
sessionId = sessionId,
style = null,
compatSummary = privacyTitle,

179
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt

@ -0,0 +1,179 @@ @@ -0,0 +1,179 @@
/*
* 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.channels
import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import io.element.android.libraries.androidutils.system.startNotificationChannelSettingsIntent
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.push.impl.R
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
/**
* on devices >= android O, we need to define a channel for each notifications.
*/
@SingleIn(AppScope::class)
class NotificationChannels @Inject constructor(
@ApplicationContext private val context: Context,
private val stringProvider: StringProvider,
) {
private val notificationManager = NotificationManagerCompat.from(context)
init {
createNotificationChannels()
}
/* ==========================================================================================
* Channel names
* ========================================================================================== */
/**
* Create notification channels.
*/
private fun createNotificationChannels() {
if (!supportNotificationChannels()) {
return
}
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Migration - the noisy channel was deleted and recreated when sound preference was changed (id was DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE
// + currentTimeMillis).
// Now the sound can only be change directly in system settings, so for app upgrading we are deleting this former channel
// Starting from this version the channel will not be dynamic
for (channel in notificationManager.notificationChannels) {
val channelId = channel.id
val legacyBaseName = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_BASE"
if (channelId.startsWith(legacyBaseName)) {
notificationManager.deleteNotificationChannel(channelId)
}
}
// Migration - Remove deprecated channels
for (channelId in listOf("DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID", "CALL_NOTIFICATION_CHANNEL_ID")) {
notificationManager.getNotificationChannel(channelId)?.let {
notificationManager.deleteNotificationChannel(channelId)
}
}
/**
* Default notification importance: shows everywhere, makes noise, but does not visually
* intrude.
*/
notificationManager.createNotificationChannel(
NotificationChannel(
NOISY_NOTIFICATION_CHANNEL_ID,
stringProvider.getString(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" },
NotificationManager.IMPORTANCE_DEFAULT
)
.apply {
description = stringProvider.getString(R.string.notification_channel_noisy)
enableVibration(true)
enableLights(true)
lightColor = accentColor
})
/**
* Low notification importance: shows everywhere, but is not intrusive.
*/
notificationManager.createNotificationChannel(
NotificationChannel(
SILENT_NOTIFICATION_CHANNEL_ID,
stringProvider.getString(R.string.notification_channel_silent).ifEmpty { "Silent notifications" },
NotificationManager.IMPORTANCE_LOW
)
.apply {
description = stringProvider.getString(R.string.notification_channel_silent)
setSound(null, null)
enableLights(true)
lightColor = accentColor
})
notificationManager.createNotificationChannel(
NotificationChannel(
LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID,
stringProvider.getString(R.string.notification_channel_listening_for_events).ifEmpty { "Listening for events" },
NotificationManager.IMPORTANCE_MIN
)
.apply {
description = stringProvider.getString(R.string.notification_channel_listening_for_events)
setSound(null, null)
setShowBadge(false)
})
notificationManager.createNotificationChannel(
NotificationChannel(
CALL_NOTIFICATION_CHANNEL_ID,
stringProvider.getString(R.string.notification_channel_call).ifEmpty { "Call" },
NotificationManager.IMPORTANCE_HIGH
)
.apply {
description = stringProvider.getString(R.string.notification_channel_call)
setSound(null, null)
enableLights(true)
lightColor = accentColor
})
}
private fun getChannel(channelId: String): NotificationChannel? {
return notificationManager.getNotificationChannel(channelId)
}
fun getChannelForIncomingCall(fromBg: Boolean): NotificationChannel? {
val notificationChannel = if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
return getChannel(notificationChannel)
}
fun getChannelIdForMessage(noisy: Boolean): String {
return if (noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
}
fun getChannelIdForTest(): String = NOISY_NOTIFICATION_CHANNEL_ID
companion object {
/* ==========================================================================================
* IDs for channels
* ========================================================================================== */
private const val LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID = "LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID"
private const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2"
private const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID"
private const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V2"
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
private fun supportNotificationChannels() = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
fun openSystemSettingsForSilentCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, SILENT_NOTIFICATION_CHANNEL_ID)
}
fun openSystemSettingsForNoisyCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, NOISY_NOTIFICATION_CHANNEL_ID)
}
fun openSystemSettingsForCallCategory(activity: Activity) {
startNotificationChannelSettingsIntent(activity, CALL_NOTIFICATION_CHANNEL_ID)
}
}
}

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

@ -0,0 +1,295 @@ @@ -0,0 +1,295 @@
/*
* 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.factories
import android.app.Notification
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
class NotificationFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationChannels: NotificationChannels,
private val stringProvider: StringProvider,
private val buildMeta: BuildMeta,
private val pendingIntentFactory: PendingIntentFactory,
private val markAsReadActionFactory: MarkAsReadActionFactory,
private val quickReplyActionFactory: QuickReplyActionFactory,
private val rejectInvitationActionFactory: RejectInvitationActionFactory,
private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
) {
/**
* Create a notification for a Room.
*/
fun createMessagesListNotification(
messageStyle: NotificationCompat.MessagingStyle,
roomInfo: RoomEventGroupInfo,
threadId: ThreadId?,
largeIcon: Bitmap?,
lastMessageTimestamp: Long,
tickerText: String
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Build the pending intent for when the notification is clicked
val openIntent = when {
threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo, threadId)
else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)
}
val smallIcon = R.drawable.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(roomInfo.shouldBing)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(roomInfo.isUpdated)
.setWhen(lastMessageTimestamp)
// MESSAGING_STYLE sets title and content for API 16 and above devices.
.setStyle(messageStyle)
// A category allows groups of notifications to be ranked and filtered – per user or system settings.
// For example, alarm notifications should display before promo notifications, or message from known contact
// that can be displayed in not disturb mode if white listed (the later will need compat28.x)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
// ID of the corresponding shortcut, for conversation features under API 30+
.setShortcutId(roomInfo.roomId.value)
// Title for API < 16 devices.
.setContentTitle(roomInfo.roomDisplayName)
// Content for API < 16 devices.
.setContentText(stringProvider.getString(R.string.notification_new_messages))
// Number of new notifications for API <24 (M and below) devices.
.setSubText(
stringProvider.getQuantityString(
R.plurals.notification_new_messages_for_room,
messageStyle.messages.size,
messageStyle.messages.size
)
)
// 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
.setGroup(roomInfo.sessionId.value)
// In order to avoid notification making sound twice (due to the summary notification)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
// Set primary color (important for Wear 2.0 Notifications).
.setColor(accentColor)
// Sets priority for 25 and below. For 26 and above, 'priority' is deprecated for
// 'importance' which is set in the NotificationChannel. The integers representing
// 'priority' are different from 'importance', so make sure you don't mix them.
.apply {
if (roomInfo.shouldBing) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(accentColor, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
// Add actions and notification intents
// Mark room as read
addAction(markAsReadActionFactory.create(roomInfo))
// Quick reply
if (!roomInfo.hasSmartReplyError) {
addAction(quickReplyActionFactory.create(roomInfo, threadId))
}
if (openIntent != null) {
setContentIntent(openIntent)
}
if (largeIcon != null) {
setLargeIcon(largeIcon)
}
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
}
.setTicker(tickerText)
.build()
}
fun createRoomInvitationNotification(
inviteNotifiableEvent: InviteNotifiableEvent
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
val smallIcon = R.drawable.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(inviteNotifiableEvent.noisy)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
.setContentTitle(inviteNotifiableEvent.roomName ?: buildMeta.applicationName)
.setContentText(inviteNotifiableEvent.description)
.setGroup(inviteNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(accentColor)
.addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
.addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
.apply {
/*
// Build the pending intent for when the notification is clicked
val contentIntent = HomeActivity.newIntent(
context,
firstStartMainActivity = true,
inviteNotificationRoomId = inviteNotifiableEvent.roomId
)
contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
// pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that
contentIntent.data = createIgnoredUri(inviteNotifiableEvent.eventId)
setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentCompat.FLAG_IMMUTABLE))
*/
if (inviteNotifiableEvent.noisy) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(accentColor, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
setAutoCancel(true)
}
.build()
}
fun createSimpleEventNotification(
simpleNotifiableEvent: SimpleNotifiableEvent,
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
val smallIcon = R.drawable.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
.setContentTitle(buildMeta.applicationName)
.setContentText(simpleNotifiableEvent.description)
.setGroup(simpleNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
.setColor(accentColor)
.setAutoCancel(true)
.setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId))
.apply {
if (simpleNotifiableEvent.noisy) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(accentColor, 500, 500)
} else {
priority = NotificationCompat.PRIORITY_LOW
}
setAutoCancel(true)
}
.build()
}
/**
* Create the summary notification.
*/
fun createSummaryListNotification(
sessionId: SessionId,
style: NotificationCompat.InboxStyle?,
compatSummary: String,
noisy: Boolean,
lastMessageTimestamp: Long
): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
val smallIcon = R.drawable.ic_notification
val channelId = notificationChannels.getChannelIdForMessage(noisy)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
// used in compat < N, after summary is built based on child notifications
.setWhen(lastMessageTimestamp)
.setStyle(style)
.setContentTitle(sessionId.value)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setSmallIcon(smallIcon)
// set content text to support devices running API level < 24
.setContentText(compatSummary)
.setGroup(sessionId.value)
// set this notification as the summary for the group
.setGroupSummary(true)
.setColor(accentColor)
.apply {
if (noisy) {
// Compat
priority = NotificationCompat.PRIORITY_DEFAULT
/*
vectorPreferences.getNotificationRingTone()?.let {
setSound(it)
}
*/
setLights(accentColor, 500, 500)
} else {
// compat
priority = NotificationCompat.PRIORITY_LOW
}
}
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(sessionId))
.setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(sessionId))
.build()
}
fun createDiagnosticNotification(): Notification {
return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest())
.setContentTitle(buildMeta.applicationName)
.setContentText(stringProvider.getString(R.string.notification_test_push_notification_content))
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(getBitmap(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(pendingIntentFactory.createTestPendingIntent())
.build()
}
private fun getBitmap(@DrawableRes drawableRes: Int): Bitmap? {
val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null
val canvas = Canvas()
val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888)
canvas.setBitmap(bitmap)
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
drawable.draw(canvas)
return bitmap
}
}

100
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt

@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
/*
* 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.factories
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.intent.IntentProvider
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.TestNotificationReceiver
import io.element.android.services.toolbox.api.systemclock.SystemClock
import javax.inject.Inject
class PendingIntentFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val intentProvider: IntentProvider,
private val clock: SystemClock,
private val actionIds: NotificationActionIds,
) {
fun createOpenSessionPendingIntent(sessionId: SessionId): PendingIntent? {
return createPendingIntent(sessionId = sessionId, roomId = null, threadId = null)
}
fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? {
return createPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null)
}
fun createOpenThreadPendingIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? {
return createPendingIntent(sessionId = roomInfo.sessionId, roomId = roomInfo.roomId, threadId = threadId)
}
private fun createPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? {
val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
return PendingIntent.getActivity(
context,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
fun createDismissSummaryPendingIntent(sessionId: SessionId): PendingIntent {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.dismissSummary
intent.data = createIgnoredUri("deleteSummary/${sessionId.value}")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
return PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
fun createDismissRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.dismissRoom
intent.data = createIgnoredUri("deleteRoom/${sessionId.value}/${roomId.value}")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
return PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
fun createTestPendingIntent(): PendingIntent? {
val testActionIntent = Intent(context, TestNotificationReceiver::class.java)
testActionIntent.action = actionIds.diagnostic
return PendingIntent.getBroadcast(
context,
0,
testActionIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
}

60
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
/*
* 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.factories.action
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import javax.inject.Inject
class AcceptInvitationActionFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val actionIds: NotificationActionIds,
private val stringProvider: StringProvider,
private val clock: SystemClock,
) {
// offer to type a quick accept button
fun create(inviteNotifiableEvent: InviteNotifiableEvent): NotificationCompat.Action {
val sessionId = inviteNotifiableEvent.sessionId.value
val roomId = inviteNotifiableEvent.roomId.value
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.join
intent.data = createIgnoredUri("acceptInvite/$sessionId/$roomId")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val pendingIntent = PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Action.Builder(
R.drawable.vector_notification_accept_invitation,
stringProvider.getString(R.string.notification_invitation_action_join),
pendingIntent
).build()
}
}

65
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
/*
* 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.factories.action
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.impl.NotificationConfig
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import javax.inject.Inject
class MarkAsReadActionFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val actionIds: NotificationActionIds,
private val stringProvider: StringProvider,
private val clock: SystemClock,
) {
fun create(roomInfo: RoomEventGroupInfo): NotificationCompat.Action? {
if (!NotificationConfig.supportMarkAsReadAction) return null
val sessionId = roomInfo.sessionId.value
val roomId = roomInfo.roomId.value
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.markRoomRead
intent.data = createIgnoredUri("markRead/$sessionId/$roomId")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val pendingIntent = PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Action.Builder(
R.drawable.ic_material_done_all_white,
stringProvider.getString(R.string.notification_room_action_mark_as_read),
pendingIntent
)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
}
}

103
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt

@ -0,0 +1,103 @@ @@ -0,0 +1,103 @@
/*
* 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.factories.action
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.NotificationConfig
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import javax.inject.Inject
class QuickReplyActionFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val actionIds: NotificationActionIds,
private val stringProvider: StringProvider,
private val clock: SystemClock,
) {
fun create(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): NotificationCompat.Action? {
if (!NotificationConfig.supportQuickReplyAction) return null
val sessionId = roomInfo.sessionId
val roomId = roomInfo.roomId
return buildQuickReplyIntent(sessionId, roomId, threadId)?.let { replyPendingIntent ->
val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
.setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply))
.build()
NotificationCompat.Action.Builder(
R.drawable.vector_notification_quick_reply,
stringProvider.getString(R.string.notification_room_action_quick_reply),
replyPendingIntent
)
.addRemoteInput(remoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build()
}
}
/*
* Direct reply is new in Android N, and Android already handles the UI, so the right pending intent
* here will ideally be a Service/IntentService (for a long running background task) or a BroadcastReceiver,
* which runs on the UI thread. It also works without unlocking, making the process really fluid for the user.
* 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.
*/
private fun buildQuickReplyIntent(
sessionId: SessionId,
roomId: RoomId,
threadId: ThreadId?,
): PendingIntent? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.smartReply
intent.data = createIgnoredUri("quickReply/${sessionId.value}/${roomId.value}" + threadId?.let { "/${it.value}" }.orEmpty())
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
threadId?.let {
intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value)
}
PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
// PendingIntents attached to actions with remote inputs must be mutable
PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
)
} else {
null
}
}
}

60
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
/*
* 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.factories.action
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import javax.inject.Inject
class RejectInvitationActionFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val actionIds: NotificationActionIds,
private val stringProvider: StringProvider,
private val clock: SystemClock,
) {
fun create(inviteNotifiableEvent: InviteNotifiableEvent): NotificationCompat.Action? {
val sessionId = inviteNotifiableEvent.sessionId.value
val roomId = inviteNotifiableEvent.roomId.value
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.reject
intent.data = createIgnoredUri("rejectInvite/$sessionId/$roomId")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
val pendingIntent = PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Action.Builder(
R.drawable.vector_notification_reject_invitation,
stringProvider.getString(R.string.notification_invitation_action_reject),
pendingIntent
).build()
}
}

10
libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt

@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.core.EventId @@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationUtils
import io.element.android.libraries.push.impl.notifications.fake.FakeAndroidNotificationFactory
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
@ -36,19 +36,19 @@ private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roo @@ -36,19 +36,19 @@ private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roo
class NotificationFactoryTest {
private val notificationUtils = FakeNotificationUtils()
private val androidNotificationFactory = FakeAndroidNotificationFactory()
private val roomGroupMessageCreator = FakeRoomGroupMessageCreator()
private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator()
private val notificationFactory = NotificationFactory(
notificationUtils.instance,
androidNotificationFactory.instance,
roomGroupMessageCreator.instance,
summaryGroupMessageCreator.instance
)
@Test
fun `given a room invitation when mapping to notification then is Append`() = testWith(notificationFactory) {
val expectedNotification = notificationUtils.givenBuildRoomInvitationNotificationFor(AN_INVITATION_EVENT)
val expectedNotification = androidNotificationFactory.givenCreateRoomInvitationNotificationFor(AN_INVITATION_EVENT)
val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, AN_INVITATION_EVENT))
val result = roomInvitation.toNotifications()
@ -85,7 +85,7 @@ class NotificationFactoryTest { @@ -85,7 +85,7 @@ class NotificationFactoryTest {
@Test
fun `given a simple event when mapping to notification then is Append`() = testWith(notificationFactory) {
val expectedNotification = notificationUtils.givenBuildSimpleInvitationNotificationFor(A_SIMPLE_EVENT)
val expectedNotification = androidNotificationFactory.givenCreateSimpleInvitationNotificationFor(A_SIMPLE_EVENT)
val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_SIMPLE_EVENT))
val result = roomInvitation.toNotifications()

14
libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationUtils.kt → libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt

@ -17,24 +17,24 @@ @@ -17,24 +17,24 @@
package io.element.android.libraries.push.impl.notifications.fake
import android.app.Notification
import io.element.android.libraries.push.impl.notifications.NotificationUtils
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.mockk.every
import io.mockk.mockk
class FakeNotificationUtils {
val instance = mockk<NotificationUtils>()
class FakeAndroidNotificationFactory {
val instance = mockk<NotificationFactory>()
fun givenBuildRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification {
fun givenCreateRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification {
val mockNotification = mockk<Notification>()
every { instance.buildRoomInvitationNotification(event) } returns mockNotification
every { instance.createRoomInvitationNotification(event) } returns mockNotification
return mockNotification
}
fun givenBuildSimpleInvitationNotificationFor(event: SimpleNotifiableEvent): Notification {
fun givenCreateSimpleInvitationNotificationFor(event: SimpleNotifiableEvent): Notification {
val mockNotification = mockk<Notification>()
every { instance.buildSimpleEventNotification(event) } returns mockNotification
every { instance.createSimpleEventNotification(event) } returns mockNotification
return mockNotification
}
}
Loading…
Cancel
Save