diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index c5bccc97f5..a6c3f93ecf 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -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()!!.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). diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/NotificationConfig.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/NotificationConfig.kt new file mode 100644 index 0000000000..eb37332f8f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/NotificationConfig.kt @@ -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 +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt index 5ae39e1102..3c82496626 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt +++ b/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 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, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt index c8331ed8cd..2cb01ba2f7 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt @@ -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 + } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt index 1a2d4a852f..4bb49e168f 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt +++ b/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 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> +// 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, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt deleted file mode 100755 index 8aeaa998ca..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt +++ /dev/null @@ -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()!!.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 - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt index ab38ad9fb4..00222728bf 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt @@ -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 diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt index ed0053e58c..a400c2b7a3 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt @@ -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, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt new file mode 100644 index 0000000000..fc4838e9a3 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt @@ -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) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt new file mode 100755 index 0000000000..5795ea5f5f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationFactory.kt @@ -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 + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt new file mode 100644 index 0000000000..4979eb3ddb --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt @@ -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 + ) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt new file mode 100644 index 0000000000..06ef22247e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt @@ -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() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt new file mode 100644 index 0000000000..0dcf4bb326 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt @@ -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() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt new file mode 100644 index 0000000000..a42ba3c86e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt @@ -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 + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt new file mode 100644 index 0000000000..75e1cdaf99 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt @@ -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() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt index 606923eb1f..7cc9479ee9 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt +++ b/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 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() diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationUtils.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt similarity index 66% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationUtils.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt index 046b2edd87..c046e1253f 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationUtils.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeAndroidNotificationFactory.kt @@ -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() +class FakeAndroidNotificationFactory { + val instance = mockk() - fun givenBuildRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification { + fun givenCreateRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification { val mockNotification = mockk() - 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() - every { instance.buildSimpleEventNotification(event) } returns mockNotification + every { instance.createSimpleEventNotification(event) } returns mockNotification return mockNotification } }