diff --git a/android/build.gradle b/android/build.gradle index 9976768..f9e4677 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -48,4 +48,5 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "androidx.activity:activity-ktx:1.7.2" + implementation "androidx.datastore:datastore-preferences:1.0.0" } \ No newline at end of file diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 6b1d6f6..9de0eaa 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,19 @@ + package="org.moxxy.moxxy_native"> + + + + + + + + + + diff --git a/android/src/main/kotlin/org/moxxy/moxxy_native/Constants.kt b/android/src/main/kotlin/org/moxxy/moxxy_native/Constants.kt index 67a3a9a..f3f2ce0 100644 --- a/android/src/main/kotlin/org/moxxy/moxxy_native/Constants.kt +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/Constants.kt @@ -2,7 +2,35 @@ package org.moxxy.moxxy_native const val TAG = "moxxy_native" +// The data key for text entered in the notification's reply field +const val REPLY_TEXT_KEY = "key_reply_text" + +// The key for the notification id to mark as read +const val MARK_AS_READ_ID_KEY = "notification_id" + +// Values for actions performed through the notification +const val REPLY_ACTION = "reply" +const val MARK_AS_READ_ACTION = "mark_as_read" +const val TAP_ACTION = "tap" + +// Extra data keys for the intents that reach the NotificationReceiver +const val NOTIFICATION_EXTRA_JID_KEY = "jid" +const val NOTIFICATION_EXTRA_ID_KEY = "notification_id" + +// Extra data keys for messages embedded inside the notification style +const val NOTIFICATION_MESSAGE_EXTRA_MIME = "mime" +const val NOTIFICATION_MESSAGE_EXTRA_PATH = "path" + +const val MOXXY_FILEPROVIDER_ID = "org.moxxy.moxxyv2.fileprovider" + +// Shared preferences keys +const val SHARED_PREFERENCES_KEY = "org.moxxy.moxxyv2" +const val SHARED_PREFERENCES_YOU_KEY = "you" +const val SHARED_PREFERENCES_MARK_AS_READ_KEY = "mark_as_read" +const val SHARED_PREFERENCES_REPLY_KEY = "reply" +const val SHARED_PREFERENCES_AVATAR_KEY = "avatar_path" + // Request codes const val PICK_FILE_REQUEST = 42 const val PICK_FILES_REQUEST = 43 -const val PICK_FILE_WITH_DATA_REQUEST = 44 \ No newline at end of file +const val PICK_FILE_WITH_DATA_REQUEST = 44 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 90fc20f..e7d01ab 100644 --- a/android/src/main/kotlin/org/moxxy/moxxy_native/MoxxyNativePlugin.kt +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/MoxxyNativePlugin.kt @@ -1,110 +1,200 @@ 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 -import org.moxxy.moxxy_native.generated.FilePickerType -import org.moxxy.moxxy_native.generated.MoxxyPickerApi +import io.flutter.plugin.common.EventChannel +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.picker.FilePickerType +import org.moxxy.moxxy_native.picker.MoxxyPickerApi import org.moxxy.moxxy_native.picker.PickerResultListener -class MoxxyNativePlugin: FlutterPlugin, ActivityAware, MoxxyPickerApi { - private var context: Context? = null - private var activity: Activity? = null - private lateinit var pickerListener: PickerResultListener - - override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext - MoxxyPickerApi.setUp(flutterPluginBinding.binaryMessenger, this) - pickerListener = PickerResultListener(context!!) - Log.d(TAG, "Attached to engine") - } - - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { - Log.d(TAG, "Detached from engine") - } - - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - activity = binding.activity - binding.addActivityResultListener(pickerListener) - Log.d(TAG, "Attached to activity") - } - - override fun onDetachedFromActivityForConfigChanges() { - activity = null - Log.d(TAG, "Detached from activity") - } - - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - activity = binding.activity - } - - override fun onDetachedFromActivity() { - activity = null - Log.d(TAG, "Detached from activity") - } - - override fun pickFiles( - type: FilePickerType, - multiple: Boolean, - callback: (Result>) -> Unit - ) { - val requestCode = if (multiple) PICK_FILES_REQUEST else PICK_FILE_REQUEST - AsyncRequestTracker.requestTracker[requestCode] = callback as (Result) -> Unit - if (type == FilePickerType.GENERIC) { - val pickIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - this.type = "*/*" - - // Allow/disallow picking multiple files - putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple) - } - activity?.startActivityForResult(pickIntent, requestCode) - return - } - - val contract = when (multiple) { - false -> ActivityResultContracts.PickVisualMedia() - true -> ActivityResultContracts.PickMultipleVisualMedia() - } - val pickType = when (type) { - // We keep FilePickerType.GENERIC here, even though we know that @type will never be - // GENERIC to make Kotlin happy. - FilePickerType.GENERIC, FilePickerType.IMAGE -> ActivityResultContracts.PickVisualMedia.ImageOnly - FilePickerType.VIDEO -> ActivityResultContracts.PickVisualMedia.VideoOnly - FilePickerType.IMAGEANDVIDEO -> ActivityResultContracts.PickVisualMedia.ImageAndVideo - } - val pickIntent = contract.createIntent(context!!, PickVisualMediaRequest(pickType)) - activity?.startActivityForResult(pickIntent, requestCode) - } - - override fun pickFileWithData(type: FilePickerType, callback: (Result) -> Unit) { - AsyncRequestTracker.requestTracker[PICK_FILE_WITH_DATA_REQUEST] = callback as (Result) -> Unit - if (type == FilePickerType.GENERIC) { - val pickIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - this.type = "*/*" - } - activity?.startActivityForResult(pickIntent, PICK_FILE_WITH_DATA_REQUEST) - return - } - - val pickType = when (type) { - // We keep FilePickerType.GENERIC here, even though we know that @type will never be - // GENERIC to make Kotlin happy. - FilePickerType.GENERIC, FilePickerType.IMAGE -> ActivityResultContracts.PickVisualMedia.ImageOnly - FilePickerType.VIDEO -> ActivityResultContracts.PickVisualMedia.VideoOnly - FilePickerType.IMAGEANDVIDEO -> ActivityResultContracts.PickVisualMedia.ImageAndVideo - } - val contract = ActivityResultContracts.PickVisualMedia() - val pickIntent = contract.createIntent(context!!, PickVisualMediaRequest(pickType)) - activity?.startActivityForResult(pickIntent, PICK_FILE_WITH_DATA_REQUEST) - } +object MoxxyEventChannels { + var notificationChannel: EventChannel? = null + var notificationEventSink: EventChannel.EventSink? = null +} + +object NotificationStreamHandler : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + Log.d(TAG, "NotificationStreamHandler: Attached stream") + MoxxyEventChannels.notificationEventSink = events + } + + override fun onCancel(arguments: Any?) { + Log.d(TAG, "NotificationStreamHandler: Detached stream") + MoxxyEventChannels.notificationEventSink = null + } +} + +/* + * Hold the last notification event in case we did a cold start. + */ +object NotificationCache { + var lastEvent: NotificationEvent? = null +} + +class MoxxyNativePlugin : FlutterPlugin, ActivityAware, MoxxyPickerApi, MoxxyNotificationsApi { + private var context: Context? = null + private var activity: Activity? = null + private lateinit var activityClass: Class + private lateinit var pickerListener: PickerResultListener + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + MoxxyPickerApi.setUp(flutterPluginBinding.binaryMessenger, this) + MoxxyNotificationsApi.setUp(flutterPluginBinding.binaryMessenger, this) + pickerListener = PickerResultListener(context!!) + Log.d(TAG, "Attached to engine") + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + Log.d(TAG, "Detached from engine") + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + activityClass = activity!!.javaClass + binding.addActivityResultListener(pickerListener) + Log.d(TAG, "Attached to activity") + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + Log.d(TAG, "Detached from activity") + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivity() { + activity = null + Log.d(TAG, "Detached from activity") + } + + override fun pickFiles( + type: FilePickerType, + multiple: Boolean, + callback: (Result>) -> Unit, + ) { + val requestCode = if (multiple) PICK_FILES_REQUEST else PICK_FILE_REQUEST + AsyncRequestTracker.requestTracker[requestCode] = callback as (Result) -> Unit + if (type == FilePickerType.GENERIC) { + val pickIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + this.type = "*/*" + + // Allow/disallow picking multiple files + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple) + } + activity?.startActivityForResult(pickIntent, requestCode) + return + } + + val contract = when (multiple) { + false -> ActivityResultContracts.PickVisualMedia() + true -> ActivityResultContracts.PickMultipleVisualMedia() + } + val pickType = when (type) { + // We keep FilePickerType.GENERIC here, even though we know that @type will never be + // GENERIC to make Kotlin happy. + FilePickerType.GENERIC, FilePickerType.IMAGE -> ActivityResultContracts.PickVisualMedia.ImageOnly + FilePickerType.VIDEO -> ActivityResultContracts.PickVisualMedia.VideoOnly + FilePickerType.IMAGEANDVIDEO -> ActivityResultContracts.PickVisualMedia.ImageAndVideo + } + val pickIntent = contract.createIntent(context!!, PickVisualMediaRequest(pickType)) + activity?.startActivityForResult(pickIntent, requestCode) + } + + override fun pickFileWithData(type: FilePickerType, callback: (Result) -> Unit) { + AsyncRequestTracker.requestTracker[PICK_FILE_WITH_DATA_REQUEST] = callback as (Result) -> Unit + if (type == FilePickerType.GENERIC) { + val pickIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + this.type = "*/*" + } + activity?.startActivityForResult(pickIntent, PICK_FILE_WITH_DATA_REQUEST) + return + } + + val pickType = when (type) { + // We keep FilePickerType.GENERIC here, even though we know that @type will never be + // GENERIC to make Kotlin happy. + FilePickerType.GENERIC, FilePickerType.IMAGE -> ActivityResultContracts.PickVisualMedia.ImageOnly + FilePickerType.VIDEO -> ActivityResultContracts.PickVisualMedia.VideoOnly + FilePickerType.IMAGEANDVIDEO -> ActivityResultContracts.PickVisualMedia.ImageAndVideo + } + val contract = ActivityResultContracts.PickVisualMedia() + 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/content/MoxxyFileProvider.kt b/android/src/main/kotlin/org/moxxy/moxxy_native/content/MoxxyFileProvider.kt new file mode 100644 index 0000000..63639b1 --- /dev/null +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/content/MoxxyFileProvider.kt @@ -0,0 +1,6 @@ +package org.moxxy.moxxy_native.content + +import androidx.core.content.FileProvider +import org.moxxy.moxxy_native.R + +class MoxxyFileProvider : FileProvider(R.xml.file_paths) diff --git a/android/src/main/kotlin/org/moxxy/moxxy_native/generated/PickerApi.kt b/android/src/main/kotlin/org/moxxy/moxxy_native/generated/PickerApi.kt deleted file mode 100644 index dd42fcc..0000000 --- a/android/src/main/kotlin/org/moxxy/moxxy_native/generated/PickerApi.kt +++ /dev/null @@ -1,130 +0,0 @@ -// Autogenerated from Pigeon (v11.0.1), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -package org.moxxy.moxxy_native.generated - -import android.util.Log -import io.flutter.plugin.common.BasicMessageChannel -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.MessageCodec -import io.flutter.plugin.common.StandardMessageCodec -import java.io.ByteArrayOutputStream -import java.nio.ByteBuffer - -private fun wrapResult(result: Any?): List { - return listOf(result) -} - -private fun wrapError(exception: Throwable): List { - if (exception is FlutterError) { - return listOf( - exception.code, - exception.message, - exception.details - ) - } else { - return listOf( - exception.javaClass.simpleName, - exception.toString(), - "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) - ) - } -} - -/** - * Error class for passing custom error details to Flutter via a thrown PlatformException. - * @property code The error code. - * @property message The error message. - * @property details The error details. Must be a datatype supported by the api codec. - */ -class FlutterError ( - val code: String, - override val message: String? = null, - val details: Any? = null -) : Throwable() - -enum class FilePickerType(val raw: Int) { - /** Pick only image(s) */ - IMAGE(0), - /** Pick only video(s) */ - VIDEO(1), - /** Pick image(s) and video(s) */ - IMAGEANDVIDEO(2), - /** Pick any kind of file(s) */ - GENERIC(3); - - companion object { - fun ofRaw(raw: Int): FilePickerType? { - return values().firstOrNull { it.raw == raw } - } - } -} - -/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ -interface MoxxyPickerApi { - /** - * Open either the photo picker or the generic file picker to get a list of paths that were - * selected and are accessable. If the list is empty, then the user dismissed the picker without - * selecting anything. - * - * [type] specifies what kind of file(s) should be picked. - * - * [multiple] controls whether multiple files can be picked (true) or just a single file - * is enough (false). - */ - fun pickFiles(type: FilePickerType, multiple: Boolean, callback: (Result>) -> Unit) - /** Like [pickFiles] but sets multiple to false and returns the raw binary data from the file. */ - fun pickFileWithData(type: FilePickerType, callback: (Result) -> Unit) - - companion object { - /** The codec used by MoxxyPickerApi. */ - val codec: MessageCodec by lazy { - StandardMessageCodec() - } - /** Sets up an instance of `MoxxyPickerApi` to handle messages through the `binaryMessenger`. */ - @Suppress("UNCHECKED_CAST") - fun setUp(binaryMessenger: BinaryMessenger, api: MoxxyPickerApi?) { - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyPickerApi.pickFiles", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val typeArg = FilePickerType.ofRaw(args[0] as Int)!! - val multipleArg = args[1] as Boolean - api.pickFiles(typeArg, multipleArg) { result: Result> -> - val error = result.exceptionOrNull() - if (error != null) { - reply.reply(wrapError(error)) - } else { - val data = result.getOrNull() - reply.reply(wrapResult(data)) - } - } - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyPickerApi.pickFileWithData", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val typeArg = FilePickerType.ofRaw(args[0] as Int)!! - api.pickFileWithData(typeArg) { result: Result -> - val error = result.exceptionOrNull() - if (error != null) { - reply.reply(wrapError(error)) - } else { - val data = result.getOrNull() - reply.reply(wrapResult(data)) - } - } - } - } else { - channel.setMessageHandler(null) - } - } - } - } -} 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 new file mode 100644 index 0000000..0292067 --- /dev/null +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationReceiver.kt @@ -0,0 +1,212 @@ +package org.moxxy.moxxy_native.notifications + +import android.app.Notification +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.graphics.drawable.Icon +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import androidx.core.content.FileProvider +import org.moxxy.moxxy_native.MARK_AS_READ_ACTION +import org.moxxy.moxxy_native.MOXXY_FILEPROVIDER_ID +import org.moxxy.moxxy_native.MoxxyEventChannels +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.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 +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. + * */ + private fun dismissNotification(context: Context, intent: Intent) { + // Dismiss the notification + val notificationId = intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1).toInt() + if (notificationId != -1) { + NotificationManagerCompat.from(context).cancel( + notificationId, + ) + } else { + Log.e("NotificationReceiver", "No id specified. Cannot dismiss notification") + } + } + + private fun findActiveNotification(context: Context, id: Int): Notification? { + return (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .activeNotifications + .find { it.id == id }?.notification + } + + private fun handleMarkAsRead(context: Context, intent: Intent) { + MoxxyEventChannels.notificationEventSink?.success( + NotificationEvent( + intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1), + intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!, + NotificationEventType.MARKASREAD, + null, + extractPayloadMapFromIntent(intent), + ).toList(), + ) + dismissNotification(context, intent) + } + + private fun handleReply(context: Context, intent: Intent) { + val remoteInput = RemoteInput.getResultsFromIntent(intent) ?: return + val replyPayload = remoteInput.getCharSequence(REPLY_TEXT_KEY) + MoxxyEventChannels.notificationEventSink?.success( + NotificationEvent( + intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1), + intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!, + NotificationEventType.REPLY, + replyPayload.toString(), + extractPayloadMapFromIntent(intent), + ).toList(), + ) + + val id = intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1).toInt() + if (id == -1) { + Log.e(TAG, "Failed to find notification id for reply") + return + } + + val notification = findActiveNotification(context, id) + if (notification == null) { + Log.e(TAG, "Failed to find notification for id $id") + return + } + + // Thanks https://medium.com/@sidorovroman3/android-how-to-use-messagingstyle-for-notifications-without-caching-messages-c414ef2b816c + val recoveredStyle = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(notification)!! + val newStyle = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Notification.MessagingStyle( + android.app.Person.Builder().apply { + setName(NotificationDataManager.getYou(context)) + + // Set an avatar, if we have one + val avatarPath = NotificationDataManager.getAvatarPath(context) + if (avatarPath != null) { + setIcon( + Icon.createWithAdaptiveBitmap( + BitmapFactory.decodeFile(avatarPath), + ), + ) + } + }.build(), + ) + } else { + Notification.MessagingStyle(NotificationDataManager.getYou(context)) + } + + newStyle.apply { + conversationTitle = recoveredStyle.conversationTitle + recoveredStyle.messages.forEach { + // Check if we have to request (or refresh) the content URI to be able to still + // see the embedded image. + val mime = it.extras.getString(NOTIFICATION_MESSAGE_EXTRA_MIME) + val path = it.extras.getString(NOTIFICATION_MESSAGE_EXTRA_PATH) + val message = Notification.MessagingStyle.Message(it.text, it.timestamp, it.sender) + if (mime != null && path != null) { + // Request a new URI from the file provider to ensure we can still see the image + // in the notification + val fileUri = FileProvider.getUriForFile( + context, + MOXXY_FILEPROVIDER_ID, + File(path), + ) + message.setData( + mime, + fileUri, + ) + + // As we're creating a new message, also recreate the additional metadata + message.extras.apply { + putString(NOTIFICATION_MESSAGE_EXTRA_MIME, mime) + putString(NOTIFICATION_MESSAGE_EXTRA_PATH, path) + } + } + + // Append the old message + addMessage(message) + } + } + + // Append our new message + newStyle.addMessage( + Notification.MessagingStyle.Message( + replyPayload!!, + Instant.now().toEpochMilli(), + null as CharSequence?, + ), + ) + + // Post the new notification + val recoveredBuilder = Notification.Builder.recoverBuilder(context, notification).apply { + style = newStyle + setOnlyAlertOnce(true) + } + + try { + NotificationManagerCompat.from(context).notify(id, recoveredBuilder.build()) + } catch (ex: SecurityException) { + Log.e(TAG, "Failed to post reply-notification: ${ex.message}") + } + } + + fun handleTap(context: Context, intent: Intent) { + MoxxyEventChannels.notificationEventSink?.success( + NotificationEvent( + intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1), + intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!, + NotificationEventType.OPEN, + null, + extractPayloadMapFromIntent(intent), + ).toList(), + ) + + // Bring the app into the foreground + Log.d(TAG, "Querying launch intent for ${context.packageName}") + val tapIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)!! + Log.d(TAG, "Starting activity") + context.startActivity(tapIntent) + + // Dismiss the notification + Log.d(TAG, "Dismissing notification") + dismissNotification(context, intent) + } + + override fun onReceive(context: Context, intent: Intent) { + // TODO: We need to be careful to ensure that the Flutter engine is running. + // If it's not, we have to start it. However, that's only an issue when we expect to + // receive notifications while not running, i.e. Push Notifications. + when (intent.action) { + MARK_AS_READ_ACTION -> handleMarkAsRead(context, intent) + REPLY_ACTION -> handleReply(context, intent) + TAP_ACTION -> handleTap(context, 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 new file mode 100644 index 0000000..7ed0ce8 --- /dev/null +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/Notifications.kt @@ -0,0 +1,362 @@ +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/NotificationsApi.kt b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationsApi.kt new file mode 100644 index 0000000..4a3272d --- /dev/null +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationsApi.kt @@ -0,0 +1,671 @@ +// Autogenerated from Pigeon (v11.0.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +package org.moxxy.moxxy_native.notifications + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +private fun wrapResult(result: Any?): List { + return listOf(result) +} + +private fun wrapError(exception: Throwable): List { + if (exception is FlutterError) { + return listOf( + exception.code, + exception.message, + exception.details, + ) + } else { + return listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception), + ) + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError( + val code: String, + override val message: String? = null, + val details: Any? = null, +) : Throwable() + +enum class NotificationIcon(val raw: Int) { + WARNING(0), + ERROR(1), + NONE(2), + ; + + companion object { + fun ofRaw(raw: Int): NotificationIcon? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class NotificationEventType(val raw: Int) { + MARKASREAD(0), + REPLY(1), + OPEN(2), + ; + + companion object { + fun ofRaw(raw: Int): NotificationEventType? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class NotificationChannelImportance(val raw: Int) { + MIN(0), + HIGH(1), + DEFAULT(2), + ; + + companion object { + fun ofRaw(raw: Int): NotificationChannelImportance? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class NotificationMessageContent( + /** The textual body of the message. */ + val body: String? = null, + /** The path and mime type of the media to show. */ + val mime: String? = null, + val path: String? = null, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NotificationMessageContent { + val body = list[0] as String? + val mime = list[1] as String? + val path = list[2] as String? + return NotificationMessageContent(body, mime, path) + } + } + fun toList(): List { + return listOf( + body, + mime, + path, + ) + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class NotificationMessage( + /** The grouping key for the notification. */ + val groupId: String? = null, + /** The sender of the message. */ + val sender: String? = null, + /** The jid of the sender. */ + val jid: String? = null, + /** The body of the message. */ + val content: NotificationMessageContent, + /** Milliseconds since epoch. */ + val timestamp: Long, + /** The path to the avatar to use */ + val avatarPath: String? = null, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NotificationMessage { + val groupId = list[0] as String? + val sender = list[1] as String? + val jid = list[2] as String? + val content = NotificationMessageContent.fromList(list[3] as List) + val timestamp = list[4].let { if (it is Int) it.toLong() else it as Long } + val avatarPath = list[5] as String? + return NotificationMessage(groupId, sender, jid, content, timestamp, avatarPath) + } + } + fun toList(): List { + return listOf( + groupId, + sender, + jid, + content.toList(), + timestamp, + avatarPath, + ) + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class MessagingNotification( + /** The title of the conversation. */ + val title: String, + /** The id of the notification. */ + val id: Long, + /** The id of the notification channel the notification should appear on. */ + val channelId: String, + /** The JID of the chat in which the notifications happen. */ + val jid: String, + /** Messages to show. */ + val messages: List, + /** Flag indicating whether this notification is from a groupchat or not. */ + val isGroupchat: Boolean, + /** The id for notification grouping. */ + val groupId: String? = null, + /** Additional data to include. */ + val extra: Map? = null, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): MessagingNotification { + val title = list[0] as String + val id = list[1].let { if (it is Int) it.toLong() else it as Long } + val channelId = list[2] as String + val jid = list[3] as String + val messages = list[4] as List + val isGroupchat = list[5] as Boolean + val groupId = list[6] as String? + val extra = list[7] as Map? + return MessagingNotification(title, id, channelId, jid, messages, isGroupchat, groupId, extra) + } + } + fun toList(): List { + return listOf( + title, + id, + channelId, + jid, + messages, + isGroupchat, + groupId, + extra, + ) + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class RegularNotification( + /** The title of the notification. */ + val title: String, + /** The body of the notification. */ + val body: String, + /** The id of the channel to show the notification on. */ + val channelId: String, + /** The id for notification grouping. */ + val groupId: String? = null, + /** The id of the notification. */ + val id: Long, + /** The icon to use. */ + val icon: NotificationIcon, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): RegularNotification { + val title = list[0] as String + val body = list[1] as String + val channelId = list[2] as String + val groupId = list[3] as String? + val id = list[4].let { if (it is Int) it.toLong() else it as Long } + val icon = NotificationIcon.ofRaw(list[5] as Int)!! + return RegularNotification(title, body, channelId, groupId, id, icon) + } + } + fun toList(): List { + return listOf( + title, + body, + channelId, + groupId, + id, + icon.raw, + ) + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class NotificationEvent( + /** The notification id. */ + val id: Long, + /** The JID the notification was for. */ + val jid: String, + /** The type of event. */ + val type: NotificationEventType, + /** + * An optional payload. + * - type == NotificationType.reply: The reply message text. + * Otherwise: undefined. + */ + val payload: String? = null, + /** Extra data. Only set when type == NotificationType.reply. */ + val extra: Map? = null, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NotificationEvent { + val id = list[0].let { if (it is Int) it.toLong() else it as Long } + val jid = list[1] as String + val type = NotificationEventType.ofRaw(list[2] as Int)!! + val payload = list[3] as String? + val extra = list[4] as Map? + return NotificationEvent(id, jid, type, payload, extra) + } + } + fun toList(): List { + return listOf( + id, + jid, + type.raw, + payload, + extra, + ) + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class NotificationI18nData( + /** The content of the reply button. */ + val reply: String, + /** The content of the "mark as read" button. */ + val markAsRead: String, + /** The text to show when *you* reply. */ + val you: String, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NotificationI18nData { + val reply = list[0] as String + val markAsRead = list[1] as String + val you = list[2] as String + return NotificationI18nData(reply, markAsRead, you) + } + } + fun toList(): List { + return listOf( + reply, + markAsRead, + you, + ) + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class NotificationGroup( + val id: String, + val description: String, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NotificationGroup { + val id = list[0] as String + val description = list[1] as String + return NotificationGroup(id, description) + } + } + fun toList(): List { + return listOf( + id, + description, + ) + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class NotificationChannel( + val title: String, + val description: String, + val id: String, + val importance: NotificationChannelImportance, + val showBadge: Boolean, + val groupId: String? = null, + val vibration: Boolean, + val enableLights: Boolean, + +) { + companion object { + @Suppress("UNCHECKED_CAST") + fun fromList(list: List): NotificationChannel { + val title = list[0] as String + val description = list[1] as String + val id = list[2] as String + val importance = NotificationChannelImportance.ofRaw(list[3] as Int)!! + val showBadge = list[4] as Boolean + val groupId = list[5] as String? + val vibration = list[6] as Boolean + val enableLights = list[7] as Boolean + return NotificationChannel(title, description, id, importance, showBadge, groupId, vibration, enableLights) + } + } + fun toList(): List { + return listOf( + title, + description, + id, + importance.raw, + showBadge, + groupId, + vibration, + enableLights, + ) + } +} + +@Suppress("UNCHECKED_CAST") +private object MoxxyNotificationsApiCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 128.toByte() -> { + return (readValue(buffer) as? List)?.let { + MessagingNotification.fromList(it) + } + } + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + NotificationChannel.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + NotificationEvent.fromList(it) + } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { + NotificationGroup.fromList(it) + } + } + 132.toByte() -> { + return (readValue(buffer) as? List)?.let { + NotificationI18nData.fromList(it) + } + } + 133.toByte() -> { + return (readValue(buffer) as? List)?.let { + NotificationMessage.fromList(it) + } + } + 134.toByte() -> { + return (readValue(buffer) as? List)?.let { + NotificationMessageContent.fromList(it) + } + } + 135.toByte() -> { + return (readValue(buffer) as? List)?.let { + RegularNotification.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is MessagingNotification -> { + stream.write(128) + writeValue(stream, value.toList()) + } + is NotificationChannel -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is NotificationEvent -> { + stream.write(130) + writeValue(stream, value.toList()) + } + is NotificationGroup -> { + stream.write(131) + writeValue(stream, value.toList()) + } + is NotificationI18nData -> { + stream.write(132) + writeValue(stream, value.toList()) + } + is NotificationMessage -> { + stream.write(133) + writeValue(stream, value.toList()) + } + is NotificationMessageContent -> { + stream.write(134) + writeValue(stream, value.toList()) + } + is RegularNotification -> { + stream.write(135) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface MoxxyNotificationsApi { + /** Notification APIs */ + fun createNotificationGroups(groups: List) + fun deleteNotificationGroups(ids: List) + fun createNotificationChannels(channels: List) + fun deleteNotificationChannels(ids: List) + fun showMessagingNotification(notification: MessagingNotification) + fun showNotification(notification: RegularNotification) + fun dismissNotification(id: Long) + fun setNotificationSelfAvatar(path: String) + fun setNotificationI18n(data: NotificationI18nData) + fun notificationStub(event: NotificationEvent) + + companion object { + /** The codec used by MoxxyNotificationsApi. */ + val codec: MessageCodec by lazy { + MoxxyNotificationsApiCodec + } + + /** Sets up an instance of `MoxxyNotificationsApi` to handle messages through the `binaryMessenger`. */ + @Suppress("UNCHECKED_CAST") + fun setUp(binaryMessenger: BinaryMessenger, api: MoxxyNotificationsApi?) { + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.createNotificationGroups", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val groupsArg = args[0] as List + var wrapped: List + try { + api.createNotificationGroups(groupsArg) + wrapped = listOf(null) + } catch (exception: Throwable) { + wrapped = wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.deleteNotificationGroups", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val idsArg = args[0] as List + var wrapped: List + try { + api.deleteNotificationGroups(idsArg) + wrapped = listOf(null) + } catch (exception: Throwable) { + wrapped = wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.createNotificationChannels", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val channelsArg = args[0] as List + var wrapped: List + try { + api.createNotificationChannels(channelsArg) + wrapped = listOf(null) + } catch (exception: Throwable) { + wrapped = wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.deleteNotificationChannels", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val idsArg = args[0] as List + var wrapped: List + try { + api.deleteNotificationChannels(idsArg) + wrapped = listOf(null) + } catch (exception: Throwable) { + wrapped = wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.showMessagingNotification", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val notificationArg = args[0] as MessagingNotification + var wrapped: List + try { + api.showMessagingNotification(notificationArg) + wrapped = listOf(null) + } catch (exception: Throwable) { + wrapped = wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.showNotification", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val notificationArg = args[0] as RegularNotification + var wrapped: List + try { + api.showNotification(notificationArg) + wrapped = listOf(null) + } catch (exception: Throwable) { + wrapped = wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.dismissNotification", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val idArg = args[0].let { if (it is Int) it.toLong() else it as Long } + var wrapped: List + try { + api.dismissNotification(idArg) + wrapped = listOf(null) + } catch (exception: Throwable) { + wrapped = wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.setNotificationSelfAvatar", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val pathArg = args[0] as String + var wrapped: List + try { + api.setNotificationSelfAvatar(pathArg) + wrapped = listOf(null) + } catch (exception: Throwable) { + wrapped = wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.setNotificationI18n", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val dataArg = args[0] as NotificationI18nData + var wrapped: List + try { + api.setNotificationI18n(dataArg) + wrapped = listOf(null) + } catch (exception: Throwable) { + wrapped = wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.notificationStub", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val eventArg = args[0] as NotificationEvent + var wrapped: List + try { + api.notificationStub(eventArg) + wrapped = listOf(null) + } catch (exception: Throwable) { + wrapped = wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/android/src/main/kotlin/org/moxxy/moxxy_native/picker/PickerApi.kt b/android/src/main/kotlin/org/moxxy/moxxy_native/picker/PickerApi.kt new file mode 100644 index 0000000..6ef0e5f --- /dev/null +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/picker/PickerApi.kt @@ -0,0 +1,134 @@ +// Autogenerated from Pigeon (v11.0.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +package org.moxxy.moxxy_native.picker + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMessageCodec + +private fun wrapResult(result: Any?): List { + return listOf(result) +} + +private fun wrapError(exception: Throwable): List { + if (exception is FlutterError) { + return listOf( + exception.code, + exception.message, + exception.details, + ) + } else { + return listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception), + ) + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError( + val code: String, + override val message: String? = null, + val details: Any? = null, +) : Throwable() + +enum class FilePickerType(val raw: Int) { + /** Pick only image(s) */ + IMAGE(0), + + /** Pick only video(s) */ + VIDEO(1), + + /** Pick image(s) and video(s) */ + IMAGEANDVIDEO(2), + + /** Pick any kind of file(s) */ + GENERIC(3), + ; + + companion object { + fun ofRaw(raw: Int): FilePickerType? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface MoxxyPickerApi { + /** + * Open either the photo picker or the generic file picker to get a list of paths that were + * selected and are accessable. If the list is empty, then the user dismissed the picker without + * selecting anything. + * + * [type] specifies what kind of file(s) should be picked. + * + * [multiple] controls whether multiple files can be picked (true) or just a single file + * is enough (false). + */ + fun pickFiles(type: FilePickerType, multiple: Boolean, callback: (Result>) -> Unit) + + /** Like [pickFiles] but sets multiple to false and returns the raw binary data from the file. */ + fun pickFileWithData(type: FilePickerType, callback: (Result) -> Unit) + + companion object { + /** The codec used by MoxxyPickerApi. */ + val codec: MessageCodec by lazy { + StandardMessageCodec() + } + + /** Sets up an instance of `MoxxyPickerApi` to handle messages through the `binaryMessenger`. */ + @Suppress("UNCHECKED_CAST") + fun setUp(binaryMessenger: BinaryMessenger, api: MoxxyPickerApi?) { + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyPickerApi.pickFiles", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val typeArg = FilePickerType.ofRaw(args[0] as Int)!! + val multipleArg = args[1] as Boolean + api.pickFiles(typeArg, multipleArg) { result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyPickerApi.pickFileWithData", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val typeArg = FilePickerType.ofRaw(args[0] as Int)!! + api.pickFileWithData(typeArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/android/src/main/kotlin/org/moxxy/moxxy_native/picker/PickerResultListener.kt b/android/src/main/kotlin/org/moxxy/moxxy_native/picker/PickerResultListener.kt index 1e714f8..25d2358 100644 --- a/android/src/main/kotlin/org/moxxy/moxxy_native/picker/PickerResultListener.kt +++ b/android/src/main/kotlin/org/moxxy/moxxy_native/picker/PickerResultListener.kt @@ -174,4 +174,4 @@ class PickerResultListener(private val context: Context) : ActivityResultListene result!!(Result.success(pickedFiles)) return true } -} \ No newline at end of file +} diff --git a/android/src/main/res/drawable-hdpi/ic_service.png b/android/src/main/res/drawable-hdpi/ic_service.png new file mode 100644 index 0000000..e678754 Binary files /dev/null and b/android/src/main/res/drawable-hdpi/ic_service.png differ diff --git a/android/src/main/res/drawable-mdpi/ic_service.png b/android/src/main/res/drawable-mdpi/ic_service.png new file mode 100644 index 0000000..fd69fab Binary files /dev/null and b/android/src/main/res/drawable-mdpi/ic_service.png differ diff --git a/android/src/main/res/drawable-xhdpi/ic_service.png b/android/src/main/res/drawable-xhdpi/ic_service.png new file mode 100644 index 0000000..f7e77cd Binary files /dev/null and b/android/src/main/res/drawable-xhdpi/ic_service.png differ diff --git a/android/src/main/res/drawable-xxhdpi/ic_service.png b/android/src/main/res/drawable-xxhdpi/ic_service.png new file mode 100644 index 0000000..92db591 Binary files /dev/null and b/android/src/main/res/drawable-xxhdpi/ic_service.png differ diff --git a/android/src/main/res/drawable-xxxhdpi/ic_service.png b/android/src/main/res/drawable-xxxhdpi/ic_service.png new file mode 100644 index 0000000..ce7c110 Binary files /dev/null and b/android/src/main/res/drawable-xxxhdpi/ic_service.png differ diff --git a/android/src/main/res/drawable/error.xml b/android/src/main/res/drawable/error.xml new file mode 100644 index 0000000..1757571 --- /dev/null +++ b/android/src/main/res/drawable/error.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/src/main/res/drawable/mark_as_read.xml b/android/src/main/res/drawable/mark_as_read.xml new file mode 100644 index 0000000..5b80636 --- /dev/null +++ b/android/src/main/res/drawable/mark_as_read.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/src/main/res/drawable/reply.xml b/android/src/main/res/drawable/reply.xml new file mode 100644 index 0000000..fb8b6df --- /dev/null +++ b/android/src/main/res/drawable/reply.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/src/main/res/drawable/warning.xml b/android/src/main/res/drawable/warning.xml new file mode 100644 index 0000000..3c9a4b3 --- /dev/null +++ b/android/src/main/res/drawable/warning.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/src/main/res/xml/file_paths.xml b/android/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..ab7ff51 --- /dev/null +++ b/android/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/example/android/app/src/main/kotlin/org/moxxy/moxxy_native_example/MainActivity.kt b/example/android/app/src/main/kotlin/org/moxxy/moxxy_native_example/MainActivity.kt index bb43767..6ac1445 100644 --- a/example/android/app/src/main/kotlin/org/moxxy/moxxy_native_example/MainActivity.kt +++ b/example/android/app/src/main/kotlin/org/moxxy/moxxy_native_example/MainActivity.kt @@ -1,6 +1,20 @@ package org.moxxy.moxxy_native_example +import android.content.Intent +import android.os.Bundle +import android.util.Log import io.flutter.embedding.android.FlutterActivity class MainActivity: FlutterActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + Log.d("moxxy_native", "onCreate intent ${intent?.action}") + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + Log.d("moxxy_native", "New intent ${intent.action}") + } } diff --git a/example/lib/main.dart b/example/lib/main.dart index 5735ad9..715e251 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,5 +1,3 @@ -import 'dart:developer'; - import 'package:flutter/material.dart'; import 'package:moxxy_native/moxxy_native.dart'; @@ -21,7 +19,8 @@ class MyApp extends StatelessWidget { children: [ TextButton( onPressed: () async { - final result = await MoxxyPickerApi().pickFiles(FilePickerType.image, false); + final result = await MoxxyPickerApi() + .pickFiles(FilePickerType.image, false); // ignore: avoid_print print('User picked: $result'); }, @@ -29,7 +28,8 @@ class MyApp extends StatelessWidget { ), TextButton( onPressed: () async { - final result = await MoxxyPickerApi().pickFiles(FilePickerType.imageAndVideo, true); + final result = await MoxxyPickerApi() + .pickFiles(FilePickerType.imageAndVideo, true); // ignore: avoid_print print('User picked: $result'); }, @@ -37,7 +37,8 @@ class MyApp extends StatelessWidget { ), TextButton( onPressed: () async { - final result = await MoxxyPickerApi().pickFiles(FilePickerType.generic, true); + final result = await MoxxyPickerApi() + .pickFiles(FilePickerType.generic, true); // ignore: avoid_print print('User picked: $result'); }, diff --git a/example/pubspec.lock b/example/pubspec.lock index 14fd041..9d7235d 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -130,6 +130,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.2" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc56bfe9d3f44c3c612d8d393bd9b174eb796d706759f9b495ac254e4294baa5 + url: "https://pub.dev" + source: hosted + version: "10.4.5" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "59c6322171c29df93a22d150ad95f3aa19ed86542eaec409ab2691b8f35f9a47" + url: "https://pub.dev" + source: hosted + version: "10.3.6" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" + url: "https://pub.dev" + source: hosted + version: "9.1.4" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: f2343e9fa9c22ae4fd92d4732755bfe452214e7189afcc097380950cf567b4b2 + url: "https://pub.dev" + source: hosted + version: "3.11.5" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 + url: "https://pub.dev" + source: hosted + version: "0.1.3" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d + url: "https://pub.dev" + source: hosted + version: "2.1.6" sky_engine: dependency: transitive description: flutter @@ -193,4 +241,4 @@ packages: version: "2.1.4" sdks: dart: ">=2.19.6 <3.0.0" - flutter: ">=2.5.0" + flutter: ">=2.8.0" diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart deleted file mode 100644 index 71fe1db..0000000 --- a/example/test/widget_test.dart +++ /dev/null @@ -1,27 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:moxxy_native_example/main.dart'; - -void main() { - testWidgets('Verify Platform version', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that platform version is retrieved. - expect( - find.byWidgetPredicate( - (Widget widget) => widget is Text && - widget.data!.startsWith('Running on:'), - ), - findsOneWidget, - ); - }); -} diff --git a/lib/moxxy_native.dart b/lib/moxxy_native.dart index ad85162..f49b8b5 100644 --- a/lib/moxxy_native.dart +++ b/lib/moxxy_native.dart @@ -1 +1,2 @@ -export 'pigeon/picker.dart'; +export 'pigeon/notifications.g.dart'; +export 'pigeon/picker.g.dart'; diff --git a/lib/pigeon/notifications.g.dart b/lib/pigeon/notifications.g.dart new file mode 100644 index 0000000..1d23271 --- /dev/null +++ b/lib/pigeon/notifications.g.dart @@ -0,0 +1,695 @@ +// Autogenerated from Pigeon (v11.0.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +enum NotificationIcon { + warning, + error, + none, +} + +enum NotificationEventType { + markAsRead, + reply, + open, +} + +enum NotificationChannelImportance { + MIN, + HIGH, + DEFAULT, +} + +class NotificationMessageContent { + NotificationMessageContent({ + this.body, + this.mime, + this.path, + }); + + /// The textual body of the message. + String? body; + + /// The path and mime type of the media to show. + String? mime; + + String? path; + + Object encode() { + return [ + body, + mime, + path, + ]; + } + + static NotificationMessageContent decode(Object result) { + result as List; + return NotificationMessageContent( + body: result[0] as String?, + mime: result[1] as String?, + path: result[2] as String?, + ); + } +} + +class NotificationMessage { + NotificationMessage({ + this.groupId, + this.sender, + this.jid, + required this.content, + required this.timestamp, + this.avatarPath, + }); + + /// The grouping key for the notification. + String? groupId; + + /// The sender of the message. + String? sender; + + /// The jid of the sender. + String? jid; + + /// The body of the message. + NotificationMessageContent content; + + /// Milliseconds since epoch. + int timestamp; + + /// The path to the avatar to use + String? avatarPath; + + Object encode() { + return [ + groupId, + sender, + jid, + content.encode(), + timestamp, + avatarPath, + ]; + } + + static NotificationMessage decode(Object result) { + result as List; + return NotificationMessage( + groupId: result[0] as String?, + sender: result[1] as String?, + jid: result[2] as String?, + content: NotificationMessageContent.decode(result[3]! as List), + timestamp: result[4]! as int, + avatarPath: result[5] as String?, + ); + } +} + +class MessagingNotification { + MessagingNotification({ + required this.title, + required this.id, + required this.channelId, + required this.jid, + required this.messages, + required this.isGroupchat, + this.groupId, + this.extra, + }); + + /// The title of the conversation. + String title; + + /// The id of the notification. + int id; + + /// The id of the notification channel the notification should appear on. + String channelId; + + /// The JID of the chat in which the notifications happen. + String jid; + + /// Messages to show. + List messages; + + /// Flag indicating whether this notification is from a groupchat or not. + bool isGroupchat; + + /// The id for notification grouping. + String? groupId; + + /// Additional data to include. + Map? extra; + + Object encode() { + return [ + title, + id, + channelId, + jid, + messages, + isGroupchat, + groupId, + extra, + ]; + } + + static MessagingNotification decode(Object result) { + result as List; + return MessagingNotification( + title: result[0]! as String, + id: result[1]! as int, + channelId: result[2]! as String, + jid: result[3]! as String, + messages: (result[4] as List?)!.cast(), + isGroupchat: result[5]! as bool, + groupId: result[6] as String?, + extra: (result[7] as Map?)?.cast(), + ); + } +} + +class RegularNotification { + RegularNotification({ + required this.title, + required this.body, + required this.channelId, + this.groupId, + required this.id, + required this.icon, + }); + + /// The title of the notification. + String title; + + /// The body of the notification. + String body; + + /// The id of the channel to show the notification on. + String channelId; + + /// The id for notification grouping. + String? groupId; + + /// The id of the notification. + int id; + + /// The icon to use. + NotificationIcon icon; + + Object encode() { + return [ + title, + body, + channelId, + groupId, + id, + icon.index, + ]; + } + + static RegularNotification decode(Object result) { + result as List; + return RegularNotification( + title: result[0]! as String, + body: result[1]! as String, + channelId: result[2]! as String, + groupId: result[3] as String?, + id: result[4]! as int, + icon: NotificationIcon.values[result[5]! as int], + ); + } +} + +class NotificationEvent { + NotificationEvent({ + required this.id, + required this.jid, + required this.type, + this.payload, + this.extra, + }); + + /// The notification id. + int id; + + /// The JID the notification was for. + String jid; + + /// The type of event. + NotificationEventType type; + + /// An optional payload. + /// - type == NotificationType.reply: The reply message text. + /// Otherwise: undefined. + String? payload; + + /// Extra data. Only set when type == NotificationType.reply. + Map? extra; + + Object encode() { + return [ + id, + jid, + type.index, + payload, + extra, + ]; + } + + static NotificationEvent decode(Object result) { + result as List; + return NotificationEvent( + id: result[0]! as int, + jid: result[1]! as String, + type: NotificationEventType.values[result[2]! as int], + payload: result[3] as String?, + extra: (result[4] as Map?)?.cast(), + ); + } +} + +class NotificationI18nData { + NotificationI18nData({ + required this.reply, + required this.markAsRead, + required this.you, + }); + + /// The content of the reply button. + String reply; + + /// The content of the "mark as read" button. + String markAsRead; + + /// The text to show when *you* reply. + String you; + + Object encode() { + return [ + reply, + markAsRead, + you, + ]; + } + + static NotificationI18nData decode(Object result) { + result as List; + return NotificationI18nData( + reply: result[0]! as String, + markAsRead: result[1]! as String, + you: result[2]! as String, + ); + } +} + +class NotificationGroup { + NotificationGroup({ + required this.id, + required this.description, + }); + + String id; + + String description; + + Object encode() { + return [ + id, + description, + ]; + } + + static NotificationGroup decode(Object result) { + result as List; + return NotificationGroup( + id: result[0]! as String, + description: result[1]! as String, + ); + } +} + +class NotificationChannel { + NotificationChannel({ + required this.title, + required this.description, + required this.id, + required this.importance, + required this.showBadge, + this.groupId, + required this.vibration, + required this.enableLights, + }); + + String title; + + String description; + + String id; + + NotificationChannelImportance importance; + + bool showBadge; + + String? groupId; + + bool vibration; + + bool enableLights; + + Object encode() { + return [ + title, + description, + id, + importance.index, + showBadge, + groupId, + vibration, + enableLights, + ]; + } + + static NotificationChannel decode(Object result) { + result as List; + return NotificationChannel( + title: result[0]! as String, + description: result[1]! as String, + id: result[2]! as String, + importance: NotificationChannelImportance.values[result[3]! as int], + showBadge: result[4]! as bool, + groupId: result[5] as String?, + vibration: result[6]! as bool, + enableLights: result[7]! as bool, + ); + } +} + +class _MoxxyNotificationsApiCodec extends StandardMessageCodec { + const _MoxxyNotificationsApiCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is MessagingNotification) { + buffer.putUint8(128); + writeValue(buffer, value.encode()); + } else if (value is NotificationChannel) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is NotificationEvent) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else if (value is NotificationGroup) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else if (value is NotificationI18nData) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is NotificationMessage) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is NotificationMessageContent) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else if (value is RegularNotification) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 128: + return MessagingNotification.decode(readValue(buffer)!); + case 129: + return NotificationChannel.decode(readValue(buffer)!); + case 130: + return NotificationEvent.decode(readValue(buffer)!); + case 131: + return NotificationGroup.decode(readValue(buffer)!); + case 132: + return NotificationI18nData.decode(readValue(buffer)!); + case 133: + return NotificationMessage.decode(readValue(buffer)!); + case 134: + return NotificationMessageContent.decode(readValue(buffer)!); + case 135: + return RegularNotification.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class MoxxyNotificationsApi { + /// Constructor for [MoxxyNotificationsApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + MoxxyNotificationsApi({BinaryMessenger? binaryMessenger}) + : _binaryMessenger = binaryMessenger; + final BinaryMessenger? _binaryMessenger; + + static const MessageCodec codec = _MoxxyNotificationsApiCodec(); + + /// Notification APIs + Future createNotificationGroups( + List arg_groups) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.createNotificationGroups', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_groups]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } + + Future deleteNotificationGroups(List arg_ids) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.deleteNotificationGroups', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_ids]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } + + Future createNotificationChannels( + List arg_channels) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.createNotificationChannels', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_channels]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } + + Future deleteNotificationChannels(List arg_ids) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.deleteNotificationChannels', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_ids]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } + + Future showMessagingNotification( + MessagingNotification arg_notification) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.showMessagingNotification', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_notification]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } + + Future showNotification(RegularNotification arg_notification) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.showNotification', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_notification]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } + + Future dismissNotification(int arg_id) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.dismissNotification', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_id]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } + + Future setNotificationSelfAvatar(String arg_path) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.setNotificationSelfAvatar', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_path]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } + + Future setNotificationI18n(NotificationI18nData arg_data) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.setNotificationI18n', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_data]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } + + Future notificationStub(NotificationEvent arg_event) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.moxxy_native.MoxxyNotificationsApi.notificationStub', + codec, + binaryMessenger: _binaryMessenger); + final List? replyList = + await channel.send([arg_event]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else { + return; + } + } +} diff --git a/lib/pigeon/picker.dart b/lib/pigeon/picker.g.dart similarity index 93% rename from lib/pigeon/picker.dart rename to lib/pigeon/picker.g.dart index c542809..8e19cd7 100644 --- a/lib/pigeon/picker.dart +++ b/lib/pigeon/picker.g.dart @@ -11,10 +11,13 @@ import 'package:flutter/services.dart'; enum FilePickerType { /// Pick only image(s) image, + /// Pick only video(s) video, + /// Pick image(s) and video(s) imageAndVideo, + /// Pick any kind of file(s) generic, } @@ -37,12 +40,13 @@ class MoxxyPickerApi { /// /// [multiple] controls whether multiple files can be picked (true) or just a single file /// is enough (false). - Future> pickFiles(FilePickerType arg_type, bool arg_multiple) async { + Future> pickFiles( + FilePickerType arg_type, bool arg_multiple) async { final BasicMessageChannel channel = BasicMessageChannel( 'dev.flutter.pigeon.moxxy_native.MoxxyPickerApi.pickFiles', codec, binaryMessenger: _binaryMessenger); - final List? replyList = - await channel.send([arg_type.index, arg_multiple]) as List?; + final List? replyList = await channel + .send([arg_type.index, arg_multiple]) as List?; if (replyList == null) { throw PlatformException( code: 'channel-error', @@ -67,7 +71,8 @@ class MoxxyPickerApi { /// Like [pickFiles] but sets multiple to false and returns the raw binary data from the file. Future pickFileWithData(FilePickerType arg_type) async { final BasicMessageChannel channel = BasicMessageChannel( - 'dev.flutter.pigeon.moxxy_native.MoxxyPickerApi.pickFileWithData', codec, + 'dev.flutter.pigeon.moxxy_native.MoxxyPickerApi.pickFileWithData', + codec, binaryMessenger: _binaryMessenger); final List? replyList = await channel.send([arg_type.index]) as List?; diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart new file mode 100644 index 0000000..03407d1 --- /dev/null +++ b/pigeon/notifications.dart @@ -0,0 +1,206 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/pigeon/notifications.g.dart', + kotlinOut: 'android/src/main/kotlin/org/moxxy/moxxy_native/notifications/NotificationsApi.kt', + kotlinOptions: KotlinOptions( + package: 'org.moxxy.moxxy_native.notifications', + ), + ), +) +class NotificationMessageContent { + const NotificationMessageContent( + this.body, + this.mime, + this.path, + ); + + /// The textual body of the message. + final String? body; + + /// The path and mime type of the media to show. + final String? mime; + final String? path; +} + +class NotificationMessage { + const NotificationMessage( + this.sender, + this.content, + this.jid, + this.timestamp, + this.avatarPath, { + this.groupId, + }); + + /// The grouping key for the notification. + final String? groupId; + + /// The sender of the message. + final String? sender; + + /// The jid of the sender. + final String? jid; + + /// The body of the message. + final NotificationMessageContent content; + + /// Milliseconds since epoch. + final int timestamp; + + /// The path to the avatar to use + final String? avatarPath; +} + +class MessagingNotification { + const MessagingNotification(this.title, this.id, this.jid, this.messages, + this.channelId, this.isGroupchat, this.extra, + {this.groupId}); + + /// The title of the conversation. + final String title; + + /// The id of the notification. + final int id; + + /// The id of the notification channel the notification should appear on. + final String channelId; + + /// The JID of the chat in which the notifications happen. + final String jid; + + /// Messages to show. + final List messages; + + /// Flag indicating whether this notification is from a groupchat or not. + final bool isGroupchat; + + /// The id for notification grouping. + final String? groupId; + + /// Additional data to include. + final Map? extra; +} + +enum NotificationIcon { + warning, + error, + none, +} + +class RegularNotification { + const RegularNotification( + this.title, this.body, this.channelId, this.id, this.icon, + {this.groupId}); + + /// The title of the notification. + final String title; + + /// The body of the notification. + final String body; + + /// The id of the channel to show the notification on. + final String channelId; + + /// The id for notification grouping. + final String? groupId; + + /// The id of the notification. + final int id; + + /// The icon to use. + final NotificationIcon icon; +} + +enum NotificationEventType { + markAsRead, + reply, + open, +} + +class NotificationEvent { + const NotificationEvent( + this.id, + this.jid, + this.type, + this.payload, + this.extra, + ); + + /// The notification id. + final int id; + + /// The JID the notification was for. + final String jid; + + /// The type of event. + final NotificationEventType type; + + /// An optional payload. + /// - type == NotificationType.reply: The reply message text. + /// Otherwise: undefined. + final String? payload; + + /// Extra data. Only set when type == NotificationType.reply. + final Map? extra; +} + +class NotificationI18nData { + const NotificationI18nData(this.reply, this.markAsRead, this.you); + + /// The content of the reply button. + final String reply; + + /// The content of the "mark as read" button. + final String markAsRead; + + /// The text to show when *you* reply. + final String you; +} + +class NotificationGroup { + const NotificationGroup(this.id, this.description); + final String id; + final String description; +} + +enum NotificationChannelImportance { MIN, HIGH, DEFAULT } + +class NotificationChannel { + const NotificationChannel( + this.id, + this.title, + this.description, { + this.importance = NotificationChannelImportance.DEFAULT, + this.showBadge = true, + this.groupId, + this.vibration = true, + this.enableLights = true, + }); + final String title; + final String description; + final String id; + final NotificationChannelImportance importance; + final bool showBadge; + final String? groupId; + final bool vibration; + final bool enableLights; +} + +@HostApi() +abstract class MoxxyNotificationsApi { + /// Notification APIs + void createNotificationGroups(List groups); + void deleteNotificationGroups(List ids); + void createNotificationChannels(List channels); + void deleteNotificationChannels(List ids); + void showMessagingNotification(MessagingNotification notification); + void showNotification(RegularNotification notification); + void dismissNotification(int id); + void setNotificationSelfAvatar(String path); + void setNotificationI18n(NotificationI18nData data); + + // Stubs for generating event classes + void notificationStub(NotificationEvent event); +} diff --git a/pigeon/picker.dart b/pigeon/picker.dart index c15e434..15b1c61 100644 --- a/pigeon/picker.dart +++ b/pigeon/picker.dart @@ -2,10 +2,10 @@ import 'package:pigeon/pigeon.dart'; @ConfigurePigeon( PigeonOptions( - dartOut: 'lib/pigeon/picker.dart', - kotlinOut: 'android/src/main/kotlin/org/moxxy/moxxy_native/generated/PickerApi.kt', + dartOut: 'lib/pigeon/picker.g.dart', + kotlinOut: 'android/src/main/kotlin/org/moxxy/moxxy_native/picker/PickerApi.kt', kotlinOptions: KotlinOptions( - package: 'org.moxxy.moxxy_native.generated', + package: 'org.moxxy.moxxy_native.picker', ), ), )