Merge pull request #326 from vector-im/feature/bma/push4
Notification update
This commit is contained in:
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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(
|
||||
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(
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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(
|
||||
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(
|
||||
shouldBing = events.any { it.noisy }
|
||||
)
|
||||
return RoomNotification.Message(
|
||||
notificationUtils.buildMessagesListNotification(
|
||||
notificationFactory.createMessagesListNotification(
|
||||
style,
|
||||
RoomEventGroupInfo(
|
||||
sessionId = sessionId,
|
||||
@@ -92,7 +93,6 @@ class RoomGroupMessageCreator @Inject constructor(
|
||||
threadId = lastKnownRoomEvent.threadId,
|
||||
largeIcon = largeBitmap,
|
||||
lastMessageTimestamp,
|
||||
userDisplayName,
|
||||
tickerText
|
||||
),
|
||||
meta
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
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(
|
||||
// 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(
|
||||
messageStr
|
||||
}
|
||||
}
|
||||
return notificationUtils.buildSummaryListNotification(
|
||||
return notificationFactory.createSummaryListNotification(
|
||||
sessionId = sessionId,
|
||||
style = null,
|
||||
compatSummary = privacyTitle,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
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 {
|
||||
|
||||
@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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user