From 42ff70a966de70ae051ebdce3d80de5f2c33d0a9 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Fri, 8 Sep 2023 21:56:09 +0200 Subject: [PATCH] refactor: Move the notification code into its own file --- .../moxxy/moxxy_native/MoxxyNativePlugin.kt | 68 +--- .../moxxy_native/notifications/Helpers.kt | 21 + .../notifications/NotificationDataManager.kt | 79 ++++ .../notifications/NotificationReceiver.kt | 13 - .../notifications/Notifications.kt | 362 ------------------ .../NotificationsImplementation.kt | 331 ++++++++++++++++ 6 files changed, 436 insertions(+), 438 deletions(-) create mode 100644 android/src/main/kotlin/org/moxxy/moxxy_native/notifications/Helpers.kt create mode 100644 android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationDataManager.kt delete mode 100644 android/src/main/kotlin/org/moxxy/moxxy_native/notifications/Notifications.kt create mode 100644 android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationsImplementation.kt diff --git a/android/src/main/kotlin/org/moxxy/moxxy_native/MoxxyNativePlugin.kt b/android/src/main/kotlin/org/moxxy/moxxy_native/MoxxyNativePlugin.kt index 42f0cdb..5b1e632 100644 --- a/android/src/main/kotlin/org/moxxy/moxxy_native/MoxxyNativePlugin.kt +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/MoxxyNativePlugin.kt @@ -1,14 +1,12 @@ package org.moxxy.moxxy_native import android.app.Activity -import android.app.NotificationManager import android.content.Context import android.content.Intent import android.util.Log import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.NonNull -import androidx.core.app.NotificationManagerCompat import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -19,17 +17,9 @@ import org.moxxy.moxxy_native.cryptography.CryptographyImplementation import org.moxxy.moxxy_native.cryptography.MoxxyCryptographyApi import org.moxxy.moxxy_native.media.MediaImplementation import org.moxxy.moxxy_native.media.MoxxyMediaApi -import org.moxxy.moxxy_native.notifications.MessagingNotification import org.moxxy.moxxy_native.notifications.MoxxyNotificationsApi -import org.moxxy.moxxy_native.notifications.NotificationChannel -import org.moxxy.moxxy_native.notifications.NotificationDataManager import org.moxxy.moxxy_native.notifications.NotificationEvent -import org.moxxy.moxxy_native.notifications.NotificationGroup -import org.moxxy.moxxy_native.notifications.NotificationI18nData -import org.moxxy.moxxy_native.notifications.RegularNotification -import org.moxxy.moxxy_native.notifications.createNotificationChannelsImpl -import org.moxxy.moxxy_native.notifications.createNotificationGroupsImpl -import org.moxxy.moxxy_native.notifications.showNotificationImpl +import org.moxxy.moxxy_native.notifications.NotificationsImplementation import org.moxxy.moxxy_native.picker.FilePickerType import org.moxxy.moxxy_native.picker.MoxxyPickerApi import org.moxxy.moxxy_native.picker.PickerResultListener @@ -60,7 +50,7 @@ object NotificationCache { var lastEvent: NotificationEvent? = null } -class MoxxyNativePlugin : FlutterPlugin, ActivityAware, MoxxyPickerApi, MoxxyNotificationsApi { +class MoxxyNativePlugin : FlutterPlugin, ActivityAware, MoxxyPickerApi { private var context: Context? = null private var activity: Activity? = null private lateinit var pickerListener: PickerResultListener @@ -68,15 +58,17 @@ class MoxxyNativePlugin : FlutterPlugin, ActivityAware, MoxxyPickerApi, MoxxyNot private lateinit var contactsImplementation: ContactsImplementation private lateinit var platformImplementation: PlatformImplementation private val mediaImplementation = MediaImplementation() + private lateinit var notificationsImplementation: NotificationsImplementation override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { context = flutterPluginBinding.applicationContext contactsImplementation = ContactsImplementation(context!!) platformImplementation = PlatformImplementation(context!!) + notificationsImplementation = NotificationsImplementation(context!!) // Register the pigeon handlers MoxxyPickerApi.setUp(flutterPluginBinding.binaryMessenger, this) - MoxxyNotificationsApi.setUp(flutterPluginBinding.binaryMessenger, this) + MoxxyNotificationsApi.setUp(flutterPluginBinding.binaryMessenger, notificationsImplementation) MoxxyCryptographyApi.setUp(flutterPluginBinding.binaryMessenger, cryptographyImplementation) MoxxyContactsApi.setUp(flutterPluginBinding.binaryMessenger, contactsImplementation) MoxxyPlatformApi.setUp(flutterPluginBinding.binaryMessenger, platformImplementation) @@ -167,54 +159,4 @@ class MoxxyNativePlugin : FlutterPlugin, ActivityAware, MoxxyPickerApi, MoxxyNot val pickIntent = contract.createIntent(context!!, PickVisualMediaRequest(pickType)) activity?.startActivityForResult(pickIntent, PICK_FILE_WITH_DATA_REQUEST) } - - override fun createNotificationGroups(groups: List) { - createNotificationGroupsImpl(context!!, groups) - } - - override fun deleteNotificationGroups(ids: List) { - val notificationManager = context!!.getSystemService(NotificationManager::class.java) - for (id in ids) { - notificationManager.deleteNotificationChannelGroup(id) - } - } - - override fun createNotificationChannels(channels: List) { - createNotificationChannelsImpl(context!!, channels) - } - - override fun deleteNotificationChannels(ids: List) { - val notificationManager = context!!.getSystemService(NotificationManager::class.java) - for (id in ids) { - notificationManager.deleteNotificationChannel(id) - } - } - - override fun showMessagingNotification(notification: MessagingNotification) { - org.moxxy.moxxy_native.notifications.showMessagingNotification(context!!, notification) - } - - override fun showNotification(notification: RegularNotification) { - showNotificationImpl(context!!, notification) - } - - override fun dismissNotification(id: Long) { - NotificationManagerCompat.from(context!!).cancel(id.toInt()) - } - - override fun setNotificationSelfAvatar(path: String) { - NotificationDataManager.setAvatarPath(context!!, path) - } - - override fun setNotificationI18n(data: NotificationI18nData) { - NotificationDataManager.apply { - setYou(context!!, data.you) - setReply(context!!, data.reply) - setMarkAsRead(context!!, data.markAsRead) - } - } - - override fun notificationStub(event: NotificationEvent) { - TODO("Not yet implemented") - } } diff --git a/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/Helpers.kt b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/Helpers.kt new file mode 100644 index 0000000..0ab1016 --- /dev/null +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/Helpers.kt @@ -0,0 +1,21 @@ +package org.moxxy.moxxy_native.notifications + +import android.content.Intent +import android.util.Log +import org.moxxy.moxxy_native.TAG + +/* + * Extract all user-added extra key-value pairs from @intent. + * */ +fun extractPayloadMapFromIntent(intent: Intent): Map { + val extras = mutableMapOf() + intent.extras?.keySet()!!.forEach { + Log.d(TAG, "Checking $it -> ${intent.extras!!.get(it)}") + if (it.startsWith("payload_")) { + Log.d(TAG, "Adding $it") + extras[it.substring(8)] = intent.extras!!.getString(it) + } + } + + return extras +} diff --git a/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationDataManager.kt b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationDataManager.kt new file mode 100644 index 0000000..aaaee13 --- /dev/null +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationDataManager.kt @@ -0,0 +1,79 @@ +package org.moxxy.moxxy_native.notifications + +import android.content.Context +import org.moxxy.moxxy_native.SHARED_PREFERENCES_AVATAR_KEY +import org.moxxy.moxxy_native.SHARED_PREFERENCES_KEY +import org.moxxy.moxxy_native.SHARED_PREFERENCES_MARK_AS_READ_KEY +import org.moxxy.moxxy_native.SHARED_PREFERENCES_REPLY_KEY +import org.moxxy.moxxy_native.SHARED_PREFERENCES_YOU_KEY + +/* + * Holds "persistent" data for notifications, like i18n strings. While not useful now, this is + * useful for when the app is dead and we receive a notification. + * */ +object NotificationDataManager { + private var you: String? = null + private var markAsRead: String? = null + private var reply: String? = null + + private var fetchedAvatarPath = false + private var avatarPath: String? = null + + private fun getString(context: Context, key: String, fallback: String): String { + return context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)!!.getString(key, fallback)!! + } + + private fun setString(context: Context, key: String, value: String) { + val prefs = context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) + prefs.edit() + .putString(key, value) + .apply() + } + + fun getYou(context: Context): String { + if (you == null) you = getString(context, SHARED_PREFERENCES_YOU_KEY, "You") + return you!! + } + + fun setYou(context: Context, value: String) { + setString(context, SHARED_PREFERENCES_YOU_KEY, value) + you = value + } + + fun getMarkAsRead(context: Context): String { + if (markAsRead == null) markAsRead = getString(context, SHARED_PREFERENCES_MARK_AS_READ_KEY, "Mark as read") + return markAsRead!! + } + + fun setMarkAsRead(context: Context, value: String) { + setString(context, SHARED_PREFERENCES_MARK_AS_READ_KEY, value) + markAsRead = value + } + + fun getReply(context: Context): String { + if (reply != null) reply = getString(context, SHARED_PREFERENCES_REPLY_KEY, "Reply") + return reply!! + } + + fun setReply(context: Context, value: String) { + setString(context, SHARED_PREFERENCES_REPLY_KEY, value) + reply = value + } + + fun getAvatarPath(context: Context): String? { + if (avatarPath == null && !fetchedAvatarPath) { + val path = getString(context, SHARED_PREFERENCES_AVATAR_KEY, "") + if (path.isNotEmpty()) { + avatarPath = path + } + } + + return avatarPath + } + + fun setAvatarPath(context: Context, value: String) { + setString(context, SHARED_PREFERENCES_AVATAR_KEY, value) + fetchedAvatarPath = true + avatarPath = value + } +} diff --git a/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationReceiver.kt b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationReceiver.kt index 0292067..21a22ab 100644 --- a/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationReceiver.kt +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationReceiver.kt @@ -27,19 +27,6 @@ import org.moxxy.moxxy_native.TAP_ACTION import java.io.File import java.time.Instant -fun extractPayloadMapFromIntent(intent: Intent): Map { - val extras = mutableMapOf() - intent.extras?.keySet()!!.forEach { - Log.d(TAG, "Checking $it -> ${intent.extras!!.get(it)}") - if (it.startsWith("payload_")) { - Log.d(TAG, "Adding $it") - extras[it.substring(8)] = intent.extras!!.getString(it) - } - } - - return extras -} - class NotificationReceiver : BroadcastReceiver() { /* * Dismisses the notification through which we received @intent. diff --git a/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/Notifications.kt b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/Notifications.kt deleted file mode 100644 index 7ed0ce8..0000000 --- a/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/Notifications.kt +++ /dev/null @@ -1,362 +0,0 @@ -package org.moxxy.moxxy_native.notifications - -import android.annotation.SuppressLint -import android.app.Notification -import android.app.NotificationChannelGroup -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.graphics.BitmapFactory -import android.graphics.Color -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.Person -import androidx.core.app.RemoteInput -import androidx.core.app.TaskStackBuilder -import androidx.core.content.FileProvider -import androidx.core.graphics.drawable.IconCompat -import org.moxxy.moxxy_native.MARK_AS_READ_ACTION -import org.moxxy.moxxy_native.MOXXY_FILEPROVIDER_ID -import org.moxxy.moxxy_native.NOTIFICATION_EXTRA_ID_KEY -import org.moxxy.moxxy_native.NOTIFICATION_EXTRA_JID_KEY -import org.moxxy.moxxy_native.NOTIFICATION_MESSAGE_EXTRA_MIME -import org.moxxy.moxxy_native.NOTIFICATION_MESSAGE_EXTRA_PATH -import org.moxxy.moxxy_native.R -import org.moxxy.moxxy_native.REPLY_ACTION -import org.moxxy.moxxy_native.REPLY_TEXT_KEY -import org.moxxy.moxxy_native.SHARED_PREFERENCES_AVATAR_KEY -import org.moxxy.moxxy_native.SHARED_PREFERENCES_KEY -import org.moxxy.moxxy_native.SHARED_PREFERENCES_MARK_AS_READ_KEY -import org.moxxy.moxxy_native.SHARED_PREFERENCES_REPLY_KEY -import org.moxxy.moxxy_native.SHARED_PREFERENCES_YOU_KEY -import org.moxxy.moxxy_native.TAG -import org.moxxy.moxxy_native.TAP_ACTION -import java.io.File - -/* - * Holds "persistent" data for notifications, like i18n strings. While not useful now, this is - * useful for when the app is dead and we receive a notification. - * */ -object NotificationDataManager { - private var you: String? = null - private var markAsRead: String? = null - private var reply: String? = null - - private var fetchedAvatarPath = false - private var avatarPath: String? = null - - private fun getString(context: Context, key: String, fallback: String): String { - return context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)!!.getString(key, fallback)!! - } - - private fun setString(context: Context, key: String, value: String) { - val prefs = context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) - prefs.edit() - .putString(key, value) - .apply() - } - - fun getYou(context: Context): String { - if (you == null) you = getString(context, SHARED_PREFERENCES_YOU_KEY, "You") - return you!! - } - - fun setYou(context: Context, value: String) { - setString(context, SHARED_PREFERENCES_YOU_KEY, value) - you = value - } - - fun getMarkAsRead(context: Context): String { - if (markAsRead == null) markAsRead = getString(context, SHARED_PREFERENCES_MARK_AS_READ_KEY, "Mark as read") - return markAsRead!! - } - - fun setMarkAsRead(context: Context, value: String) { - setString(context, SHARED_PREFERENCES_MARK_AS_READ_KEY, value) - markAsRead = value - } - - fun getReply(context: Context): String { - if (reply != null) reply = getString(context, SHARED_PREFERENCES_REPLY_KEY, "Reply") - return reply!! - } - - fun setReply(context: Context, value: String) { - setString(context, SHARED_PREFERENCES_REPLY_KEY, value) - reply = value - } - - fun getAvatarPath(context: Context): String? { - if (avatarPath == null && !fetchedAvatarPath) { - val path = getString(context, SHARED_PREFERENCES_AVATAR_KEY, "") - if (path.isNotEmpty()) { - avatarPath = path - } - } - - return avatarPath - } - - fun setAvatarPath(context: Context, value: String) { - setString(context, SHARED_PREFERENCES_AVATAR_KEY, value) - fetchedAvatarPath = true - avatarPath = value - } -} - -fun createNotificationGroupsImpl(context: Context, groups: List) { - val notificationManager = context.getSystemService(NotificationManager::class.java) - for (group in groups) { - notificationManager.createNotificationChannelGroup( - NotificationChannelGroup(group.id, group.description), - ) - } -} - -fun createNotificationChannelsImpl(context: Context, channels: List) { - val notificationManager = context.getSystemService(NotificationManager::class.java) - for (channel in channels) { - val importance = when (channel.importance) { - NotificationChannelImportance.DEFAULT -> NotificationManager.IMPORTANCE_DEFAULT - NotificationChannelImportance.MIN -> NotificationManager.IMPORTANCE_MIN - NotificationChannelImportance.HIGH -> NotificationManager.IMPORTANCE_HIGH - } - val notificationChannel = android.app.NotificationChannel(channel.id, channel.title, importance).apply { - description = channel.description - - enableVibration(channel.vibration) - enableLights(channel.enableLights) - setShowBadge(channel.showBadge) - - if (channel.groupId != null) { - group = channel.groupId - } - } - notificationManager.createNotificationChannel(notificationChannel) - } -} - -// / Show a messaging style notification described by @notification. -@SuppressLint("WrongConstant") -fun showMessagingNotification(context: Context, notification: MessagingNotification) { - // Build the actions - // -> Reply action - val remoteInput = RemoteInput.Builder(REPLY_TEXT_KEY).apply { - setLabel(NotificationDataManager.getReply(context)) - }.build() - val replyIntent = Intent(context, NotificationReceiver::class.java).apply { - action = REPLY_ACTION - putExtra(NOTIFICATION_EXTRA_JID_KEY, notification.jid) - putExtra(NOTIFICATION_EXTRA_ID_KEY, notification.id) - - notification.extra?.forEach { - putExtra("payload_${it.key}", it.value) - } - } - val replyPendingIntent = PendingIntent.getBroadcast( - context.applicationContext, - 0, - replyIntent, - PendingIntent.FLAG_MUTABLE, - ) - val replyAction = NotificationCompat.Action.Builder( - R.drawable.reply, - NotificationDataManager.getReply(context), - replyPendingIntent, - ).apply { - addRemoteInput(remoteInput) - setAllowGeneratedReplies(true) - }.build() - - // -> Mark as read action - val markAsReadIntent = Intent(context, NotificationReceiver::class.java).apply { - action = MARK_AS_READ_ACTION - putExtra(NOTIFICATION_EXTRA_JID_KEY, notification.jid) - putExtra(NOTIFICATION_EXTRA_ID_KEY, notification.id) - - notification.extra?.forEach { - putExtra("payload_${it.key}", it.value) - } - } - val markAsReadPendingIntent = PendingIntent.getBroadcast( - context.applicationContext, - 0, - markAsReadIntent, - PendingIntent.FLAG_IMMUTABLE, - ) - val markAsReadAction = NotificationCompat.Action.Builder( - R.drawable.mark_as_read, - NotificationDataManager.getMarkAsRead(context), - markAsReadPendingIntent, - ).build() - - // -> Tap action - // Thanks to flutter_local_notifications for this "workaround" - val tapIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)!!.apply { - action = TAP_ACTION - putExtra(NOTIFICATION_EXTRA_JID_KEY, notification.jid) - putExtra(NOTIFICATION_EXTRA_ID_KEY, notification.id) - - notification.extra?.forEach { - putExtra("payload_${it.key}", it.value) - } - - // Do not launch a new task - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - } - val tapPendingIntent = TaskStackBuilder.create(context).run { - addNextIntentWithParentStack(tapIntent) - getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - } - - // Build the notification - val selfPerson = Person.Builder().apply { - setName(NotificationDataManager.getYou(context)) - - // Set an avatar, if we have one - val avatarPath = NotificationDataManager.getAvatarPath(context) - if (avatarPath != null) { - setIcon( - IconCompat.createWithAdaptiveBitmap( - BitmapFactory.decodeFile(avatarPath), - ), - ) - } - }.build() - val style = NotificationCompat.MessagingStyle(selfPerson) - style.isGroupConversation = notification.isGroupchat - if (notification.isGroupchat) { - style.conversationTitle = notification.title - } - - for (i in notification.messages.indices) { - val message = notification.messages[i]!! - - // Build the sender - // NOTE: Note that we set it to null if message.sender == null because otherwise this results in - // a bogus Person object which messes with the "self-message" display as Android expects - // null in that case. - val sender = if (message.sender == null) { - null - } else { - Person.Builder().apply { - setName(message.sender) - setKey(message.jid) - - // Set the avatar, if available - if (message.avatarPath != null) { - try { - setIcon( - IconCompat.createWithAdaptiveBitmap( - BitmapFactory.decodeFile(message.avatarPath), - ), - ) - } catch (ex: Throwable) { - Log.w(TAG, "Failed to open avatar at ${message.avatarPath}") - } - } - }.build() - } - - // Build the message - val body = message.content.body ?: "" - val msg = NotificationCompat.MessagingStyle.Message( - body, - message.timestamp, - sender, - ) - // If we got an image, turn it into a content URI and set it - if (message.content.mime != null && message.content.path != null) { - val fileUri = FileProvider.getUriForFile( - context, - MOXXY_FILEPROVIDER_ID, - File(message.content.path), - ) - msg.apply { - setData(message.content.mime, fileUri) - - extras.apply { - putString(NOTIFICATION_MESSAGE_EXTRA_MIME, message.content.mime) - putString(NOTIFICATION_MESSAGE_EXTRA_PATH, message.content.path) - } - } - } - - // Append the message - style.addMessage(msg) - } - - // Assemble the notification - val finalNotification = NotificationCompat.Builder(context, notification.channelId).apply { - setStyle(style) - // NOTE: It's okay to use the service icon here as I cannot get Android to display the - // actual logo. So we'll have to make do with the silhouette and the color purple. - setSmallIcon(R.drawable.ic_service) - color = Color.argb(255, 207, 74, 255) - setColorized(true) - - // Tap action - setContentIntent(tapPendingIntent) - - // Notification actions - addAction(replyAction) - addAction(markAsReadAction) - - // Groupchat title - if (notification.isGroupchat) { - setContentTitle(notification.title) - } - - // Prevent grouping with the foreground service - if (notification.groupId != null) { - setGroup(notification.groupId) - } - - setAllowSystemGeneratedContextualActions(true) - setCategory(Notification.CATEGORY_MESSAGE) - - // Prevent no notification when we replied before - setOnlyAlertOnce(false) - - // Automatically dismiss the notification on tap - setAutoCancel(true) - }.build() - - // Post the notification - try { - NotificationManagerCompat.from(context).notify( - notification.id.toInt(), - finalNotification, - ) - } catch (ex: SecurityException) { - // Should never happen as Moxxy checks for the permission before posting the notification - Log.e(TAG, "Failed to post notification: ${ex.message}") - } -} - -fun showNotificationImpl(context: Context, notification: RegularNotification) { - val builtNotification = NotificationCompat.Builder(context, notification.channelId).apply { - setContentTitle(notification.title) - setContentText(notification.body) - - when (notification.icon) { - NotificationIcon.ERROR -> setSmallIcon(R.drawable.error) - NotificationIcon.WARNING -> setSmallIcon(R.drawable.warning) - NotificationIcon.NONE -> {} - } - - if (notification.groupId != null) { - setGroup(notification.groupId) - } - }.build() - - // Post the notification - try { - NotificationManagerCompat.from(context).notify(notification.id.toInt(), builtNotification) - } catch (ex: SecurityException) { - // Should never happen as Moxxy checks for the permission before posting the notification - Log.e(TAG, "Failed to post notification: ${ex.message}") - } -} diff --git a/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationsImplementation.kt b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationsImplementation.kt new file mode 100644 index 0000000..3a534bb --- /dev/null +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationsImplementation.kt @@ -0,0 +1,331 @@ +package org.moxxy.moxxy_native.notifications + +import android.app.Notification +import android.app.NotificationChannelGroup +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.graphics.Color +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.app.RemoteInput +import androidx.core.app.TaskStackBuilder +import androidx.core.content.FileProvider +import androidx.core.graphics.drawable.IconCompat +import org.moxxy.moxxy_native.MARK_AS_READ_ACTION +import org.moxxy.moxxy_native.MOXXY_FILEPROVIDER_ID +import org.moxxy.moxxy_native.NOTIFICATION_EXTRA_ID_KEY +import org.moxxy.moxxy_native.NOTIFICATION_EXTRA_JID_KEY +import org.moxxy.moxxy_native.NOTIFICATION_MESSAGE_EXTRA_MIME +import org.moxxy.moxxy_native.NOTIFICATION_MESSAGE_EXTRA_PATH +import org.moxxy.moxxy_native.R +import org.moxxy.moxxy_native.REPLY_ACTION +import org.moxxy.moxxy_native.REPLY_TEXT_KEY +import org.moxxy.moxxy_native.TAG +import org.moxxy.moxxy_native.TAP_ACTION +import java.io.File + +class NotificationsImplementation(private val context: Context) : MoxxyNotificationsApi { + override fun createNotificationGroups(groups: List) { + val notificationManager = context.getSystemService(NotificationManager::class.java) + for (group in groups) { + notificationManager.createNotificationChannelGroup( + NotificationChannelGroup(group.id, group.description), + ) + } + } + + override fun deleteNotificationGroups(ids: List) { + val notificationManager = context.getSystemService(NotificationManager::class.java) + for (id in ids) { + notificationManager.deleteNotificationChannelGroup(id) + } + } + + override fun createNotificationChannels(channels: List) { + val notificationManager = context.getSystemService(NotificationManager::class.java) + for (channel in channels) { + val importance = when (channel.importance) { + NotificationChannelImportance.DEFAULT -> NotificationManager.IMPORTANCE_DEFAULT + NotificationChannelImportance.MIN -> NotificationManager.IMPORTANCE_MIN + NotificationChannelImportance.HIGH -> NotificationManager.IMPORTANCE_HIGH + } + val notificationChannel = + android.app.NotificationChannel(channel.id, channel.title, importance).apply { + description = channel.description + + enableVibration(channel.vibration) + enableLights(channel.enableLights) + setShowBadge(channel.showBadge) + + if (channel.groupId != null) { + group = channel.groupId + } + } + notificationManager.createNotificationChannel(notificationChannel) + } + } + + override fun deleteNotificationChannels(ids: List) { + val notificationManager = context.getSystemService(NotificationManager::class.java) + for (id in ids) { + notificationManager.deleteNotificationChannel(id) + } + } + + override fun showMessagingNotification(notification: MessagingNotification) { + // Build the actions + // -> Reply action + val remoteInput = RemoteInput.Builder(REPLY_TEXT_KEY).apply { + setLabel(NotificationDataManager.getReply(context)) + }.build() + val replyIntent = Intent(context, NotificationReceiver::class.java).apply { + action = REPLY_ACTION + putExtra(NOTIFICATION_EXTRA_JID_KEY, notification.jid) + putExtra(NOTIFICATION_EXTRA_ID_KEY, notification.id) + + notification.extra?.forEach { + putExtra("payload_${it.key}", it.value) + } + } + val replyPendingIntent = PendingIntent.getBroadcast( + context.applicationContext, + 0, + replyIntent, + PendingIntent.FLAG_MUTABLE, + ) + val replyAction = NotificationCompat.Action.Builder( + R.drawable.reply, + NotificationDataManager.getReply(context), + replyPendingIntent, + ).apply { + addRemoteInput(remoteInput) + setAllowGeneratedReplies(true) + }.build() + + // -> Mark as read action + val markAsReadIntent = Intent(context, NotificationReceiver::class.java).apply { + action = MARK_AS_READ_ACTION + putExtra(NOTIFICATION_EXTRA_JID_KEY, notification.jid) + putExtra(NOTIFICATION_EXTRA_ID_KEY, notification.id) + + notification.extra?.forEach { + putExtra("payload_${it.key}", it.value) + } + } + val markAsReadPendingIntent = PendingIntent.getBroadcast( + context.applicationContext, + 0, + markAsReadIntent, + PendingIntent.FLAG_IMMUTABLE, + ) + val markAsReadAction = NotificationCompat.Action.Builder( + R.drawable.mark_as_read, + NotificationDataManager.getMarkAsRead(context), + markAsReadPendingIntent, + ).build() + + // -> Tap action + // Thanks to flutter_local_notifications for this "workaround" + val tapIntent = + context.packageManager.getLaunchIntentForPackage(context.packageName)!!.apply { + action = TAP_ACTION + putExtra(NOTIFICATION_EXTRA_JID_KEY, notification.jid) + putExtra(NOTIFICATION_EXTRA_ID_KEY, notification.id) + + notification.extra?.forEach { + putExtra("payload_${it.key}", it.value) + } + + // Do not launch a new task + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val tapPendingIntent = TaskStackBuilder.create(context).run { + addNextIntentWithParentStack(tapIntent) + getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + + // Build the notification + val selfPerson = Person.Builder().apply { + setName(NotificationDataManager.getYou(context)) + + // Set an avatar, if we have one + val avatarPath = NotificationDataManager.getAvatarPath(context) + if (avatarPath != null) { + setIcon( + IconCompat.createWithAdaptiveBitmap( + BitmapFactory.decodeFile(avatarPath), + ), + ) + } + }.build() + val style = NotificationCompat.MessagingStyle(selfPerson) + style.isGroupConversation = notification.isGroupchat + if (notification.isGroupchat) { + style.conversationTitle = notification.title + } + + for (i in notification.messages.indices) { + val message = notification.messages[i]!! + + // Build the sender + // NOTE: Note that we set it to null if message.sender == null because otherwise this results in + // a bogus Person object which messes with the "self-message" display as Android expects + // null in that case. + val sender = if (message.sender == null) { + null + } else { + Person.Builder().apply { + setName(message.sender) + setKey(message.jid) + + // Set the avatar, if available + if (message.avatarPath != null) { + try { + setIcon( + IconCompat.createWithAdaptiveBitmap( + BitmapFactory.decodeFile(message.avatarPath), + ), + ) + } catch (ex: Throwable) { + Log.w(TAG, "Failed to open avatar at ${message.avatarPath}") + } + } + }.build() + } + + // Build the message + val body = message.content.body ?: "" + val msg = NotificationCompat.MessagingStyle.Message( + body, + message.timestamp, + sender, + ) + // If we got an image, turn it into a content URI and set it + if (message.content.mime != null && message.content.path != null) { + val fileUri = FileProvider.getUriForFile( + context, + MOXXY_FILEPROVIDER_ID, + File(message.content.path), + ) + msg.apply { + setData(message.content.mime, fileUri) + + extras.apply { + putString(NOTIFICATION_MESSAGE_EXTRA_MIME, message.content.mime) + putString(NOTIFICATION_MESSAGE_EXTRA_PATH, message.content.path) + } + } + } + + // Append the message + style.addMessage(msg) + } + + // Assemble the notification + val finalNotification = NotificationCompat.Builder(context, notification.channelId).apply { + setStyle(style) + // NOTE: It's okay to use the service icon here as I cannot get Android to display the + // actual logo. So we'll have to make do with the silhouette and the color purple. + setSmallIcon(R.drawable.ic_service) + color = Color.argb(255, 207, 74, 255) + setColorized(true) + + // Tap action + setContentIntent(tapPendingIntent) + + // Notification actions + addAction(replyAction) + addAction(markAsReadAction) + + // Groupchat title + if (notification.isGroupchat) { + setContentTitle(notification.title) + } + + // Prevent grouping with the foreground service + if (notification.groupId != null) { + setGroup(notification.groupId) + } + + setAllowSystemGeneratedContextualActions(true) + setCategory(Notification.CATEGORY_MESSAGE) + + // Prevent no notification when we replied before + setOnlyAlertOnce(false) + + // Automatically dismiss the notification on tap + setAutoCancel(true) + }.build() + + // Post the notification + try { + NotificationManagerCompat.from(context).notify( + notification.id.toInt(), + finalNotification, + ) + } catch (ex: SecurityException) { + // Should never happen as Moxxy checks for the permission before posting the notification + Log.e(TAG, "Failed to post notification: ${ex.message}") + } + } + + override fun showNotification(notification: RegularNotification) { + val builtNotification = NotificationCompat.Builder(context, notification.channelId).apply { + setContentTitle(notification.title) + setContentText(notification.body) + + when (notification.icon) { + NotificationIcon.ERROR -> setSmallIcon(R.drawable.error) + NotificationIcon.WARNING -> setSmallIcon(R.drawable.warning) + NotificationIcon.NONE -> {} + } + + if (notification.groupId != null) { + setGroup(notification.groupId) + } + }.build() + + // Post the notification + try { + NotificationManagerCompat.from(context) + .notify(notification.id.toInt(), builtNotification) + } catch (ex: SecurityException) { + // Should never happen as Moxxy checks for the permission before posting the notification + Log.e(TAG, "Failed to post notification: ${ex.message}") + } + } + + override fun dismissNotification(id: Long) { + NotificationManagerCompat.from(context).cancel(id.toInt()) + } + + override fun setNotificationSelfAvatar(path: String) { + NotificationDataManager.setAvatarPath(context, path) + } + + override fun setNotificationI18n(data: NotificationI18nData) { + NotificationDataManager.apply { + setYou( + context, + data.you, + ) + setReply( + context, + data.reply, + ) + setMarkAsRead( + context, + data.markAsRead, + ) + } + } + + override fun notificationStub(event: NotificationEvent) { + // N/A + } +}