Compare commits

..

13 Commits

Author SHA1 Message Date
2ba5674850 chore(all): Release version 0.3.2 2023-09-20 22:13:50 +02:00
ed2a928d12 feat(android): Use .jpg instead of .jpeg 2023-09-20 22:12:53 +02:00
3c0db620c8 fix(android): Prevent filename.jpg.png 2023-09-20 22:11:49 +02:00
c14b3a7f58 fix(android): Use the BUFFER_SIZE constant 2023-09-20 19:26:34 +02:00
53b6b65e70 chore(all): Bump version to 0.3.1 2023-09-20 14:19:26 +02:00
2adefecc92 fix(android): Fix multiple notification issues
- Fix updating the notification causing another vibration
- Fix the "mark as read" intent being stuck
2023-09-20 14:18:08 +02:00
4ec44ab3e1 fix(android): Send notification events again 2023-09-20 13:55:03 +02:00
cab031bd07 fix(android): Fix keyboard height events being sent over the notification stream 2023-09-20 13:45:13 +02:00
6b4b15bb87 fix(android): "Fix" wrong file extension on image picker usage 2023-09-20 13:44:52 +02:00
c905b3242d chore(all): Bump version to 0.3.0 2023-09-18 20:50:50 +02:00
f949b008b3 feat(android): Add the keyboard height code 2023-09-18 20:50:08 +02:00
1852f2d198 fix(android): Fix wrong file provider id 2023-09-18 18:40:37 +02:00
44187675c7 feat(android): Implement sharing internal files and text 2023-09-18 17:58:16 +02:00
22 changed files with 1080 additions and 666 deletions

View File

@@ -27,3 +27,7 @@ Thanks to [ekasetiawans](https://github.com/ekasetiawans) for [flutter_backgroun
was essentially the blueprint for the service and background service APIs. They were reimplemented was essentially the blueprint for the service and background service APIs. They were reimplemented
to allow the root isolate to pass some additional data to the service, which `flutter_background_service` to allow the root isolate to pass some additional data to the service, which `flutter_background_service`
did not support. did not support.
Thanks to [nschairer](https://github.com/nschairer) for [flutter_keyboard_height](https://github.com/nschairer/keyboard_height_plugin), which was the base for keeping track of the keyboard height.
Due to having an issue with the height calculation if the Android device uses gesture navigation, I
[forked the package](https://git.polynom.me/moxxy/keyboard_height_plugin) and modified the height calculation.

View File

@@ -2,6 +2,12 @@ package org.moxxy.moxxy_native
const val TAG = "moxxy_native" const val TAG = "moxxy_native"
// The event channel name for the keyboard height
const val KEYBOARD_HEIGHT_EVENT_CHANNEL_NAME = "org.moxxy.moxxyv2/keyboard_stream"
// The event channel name for notification events
const val NOTIFICATION_EVENT_CHANNEL_NAME = "org.moxxy.moxxyv2/notification_stream"
// The size of buffers to use for various operations // The size of buffers to use for various operations
const val BUFFER_SIZE = 4096 const val BUFFER_SIZE = 4096

View File

@@ -25,10 +25,12 @@ import org.moxxy.moxxy_native.media.MediaImplementation
import org.moxxy.moxxy_native.media.MoxxyMediaApi import org.moxxy.moxxy_native.media.MoxxyMediaApi
import org.moxxy.moxxy_native.notifications.MoxxyNotificationsApi import org.moxxy.moxxy_native.notifications.MoxxyNotificationsApi
import org.moxxy.moxxy_native.notifications.NotificationEvent import org.moxxy.moxxy_native.notifications.NotificationEvent
import org.moxxy.moxxy_native.notifications.NotificationStreamHandler
import org.moxxy.moxxy_native.notifications.NotificationsImplementation import org.moxxy.moxxy_native.notifications.NotificationsImplementation
import org.moxxy.moxxy_native.picker.FilePickerType import org.moxxy.moxxy_native.picker.FilePickerType
import org.moxxy.moxxy_native.picker.MoxxyPickerApi import org.moxxy.moxxy_native.picker.MoxxyPickerApi
import org.moxxy.moxxy_native.picker.PickerResultListener import org.moxxy.moxxy_native.picker.PickerResultListener
import org.moxxy.moxxy_native.platform.KeyboardStreamHandler
import org.moxxy.moxxy_native.platform.MoxxyPlatformApi import org.moxxy.moxxy_native.platform.MoxxyPlatformApi
import org.moxxy.moxxy_native.platform.PlatformImplementation import org.moxxy.moxxy_native.platform.PlatformImplementation
import org.moxxy.moxxy_native.service.BackgroundService import org.moxxy.moxxy_native.service.BackgroundService
@@ -36,23 +38,6 @@ import org.moxxy.moxxy_native.service.MoxxyServiceApi
import org.moxxy.moxxy_native.service.PluginTracker import org.moxxy.moxxy_native.service.PluginTracker
import org.moxxy.moxxy_native.service.ServiceImplementation import org.moxxy.moxxy_native.service.ServiceImplementation
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. * Hold the last notification event in case we did a cold start.
*/ */
@@ -102,6 +87,14 @@ class MoxxyNativePlugin : FlutterPlugin, ActivityAware, ServiceAware, BroadcastR
IntentFilter(SERVICE_FOREGROUND_METHOD_CHANNEL_KEY), IntentFilter(SERVICE_FOREGROUND_METHOD_CHANNEL_KEY),
) )
// Special handling for the keyboard height
val keyboardChannel = EventChannel(flutterPluginBinding.getBinaryMessenger(), KEYBOARD_HEIGHT_EVENT_CHANNEL_NAME)
keyboardChannel?.setStreamHandler(KeyboardStreamHandler)
// Special handling from notification events
val notificationChannel = EventChannel(flutterPluginBinding.getBinaryMessenger(), NOTIFICATION_EVENT_CHANNEL_NAME)
notificationChannel?.setStreamHandler(NotificationStreamHandler)
// Register the picker handler // Register the picker handler
pickerListener = PickerResultListener(context!!) pickerListener = PickerResultListener(context!!)
Log.d(TAG, "Attached to engine") Log.d(TAG, "Attached to engine")
@@ -118,20 +111,24 @@ class MoxxyNativePlugin : FlutterPlugin, ActivityAware, ServiceAware, BroadcastR
override fun onAttachedToActivity(binding: ActivityPluginBinding) { override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity activity = binding.activity
binding.addActivityResultListener(pickerListener) binding.addActivityResultListener(pickerListener)
KeyboardStreamHandler.activity = activity
Log.d(TAG, "Attached to activity") Log.d(TAG, "Attached to activity")
} }
override fun onDetachedFromActivityForConfigChanges() { override fun onDetachedFromActivityForConfigChanges() {
activity = null activity = null
KeyboardStreamHandler.activity = null
Log.d(TAG, "Detached from activity") Log.d(TAG, "Detached from activity")
} }
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity activity = binding.activity
KeyboardStreamHandler.activity = activity
} }
override fun onDetachedFromActivity() { override fun onDetachedFromActivity() {
activity = null activity = null
KeyboardStreamHandler.activity = null
Log.d(TAG, "Detached from activity") Log.d(TAG, "Detached from activity")
} }

View File

@@ -1,6 +1,24 @@
package org.moxxy.moxxy_native.content package org.moxxy.moxxy_native.content
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import org.moxxy.moxxy_native.MOXXY_FILEPROVIDER_ID
import org.moxxy.moxxy_native.R import org.moxxy.moxxy_native.R
import java.io.File
class MoxxyFileProvider : FileProvider(R.xml.file_paths) class MoxxyFileProvider : FileProvider(R.xml.file_paths) {
companion object {
/*
* Convert a path @path inside a sharable storage directory into a content URI, given
* the application's context @context.
* */
fun getUriForPath(context: Context, path: String): Uri {
return getUriForFile(
context,
MOXXY_FILEPROVIDER_ID,
File(path),
)
}
}
}

View File

@@ -1,8 +1,6 @@
package org.moxxy.moxxy_native.notifications package org.moxxy.moxxy_native.notifications
import android.content.Intent import android.content.Intent
import android.util.Log
import org.moxxy.moxxy_native.TAG
/* /*
* Extract all user-added extra key-value pairs from @intent. * Extract all user-added extra key-value pairs from @intent.
@@ -10,9 +8,7 @@ import org.moxxy.moxxy_native.TAG
fun extractPayloadMapFromIntent(intent: Intent): Map<String?, String?> { fun extractPayloadMapFromIntent(intent: Intent): Map<String?, String?> {
val extras = mutableMapOf<String?, String?>() val extras = mutableMapOf<String?, String?>()
intent.extras?.keySet()!!.forEach { intent.extras?.keySet()!!.forEach {
Log.d(TAG, "Checking $it -> ${intent.extras!!.get(it)}")
if (it.startsWith("payload_")) { if (it.startsWith("payload_")) {
Log.d(TAG, "Adding $it")
extras[it.substring(8)] = intent.extras!!.getString(it) extras[it.substring(8)] = intent.extras!!.getString(it)
} }
} }

View File

@@ -15,7 +15,6 @@ import androidx.core.app.RemoteInput
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import org.moxxy.moxxy_native.MARK_AS_READ_ACTION import org.moxxy.moxxy_native.MARK_AS_READ_ACTION
import org.moxxy.moxxy_native.MOXXY_FILEPROVIDER_ID 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_ID_KEY
import org.moxxy.moxxy_native.NOTIFICATION_EXTRA_JID_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_MIME
@@ -50,7 +49,7 @@ class NotificationReceiver : BroadcastReceiver() {
} }
private fun handleMarkAsRead(context: Context, intent: Intent) { private fun handleMarkAsRead(context: Context, intent: Intent) {
MoxxyEventChannels.notificationEventSink?.success( NotificationStreamHandler.sink?.success(
NotificationEvent( NotificationEvent(
intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1), intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1),
intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!, intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!,
@@ -65,7 +64,7 @@ class NotificationReceiver : BroadcastReceiver() {
private fun handleReply(context: Context, intent: Intent) { private fun handleReply(context: Context, intent: Intent) {
val remoteInput = RemoteInput.getResultsFromIntent(intent) ?: return val remoteInput = RemoteInput.getResultsFromIntent(intent) ?: return
val replyPayload = remoteInput.getCharSequence(REPLY_TEXT_KEY) val replyPayload = remoteInput.getCharSequence(REPLY_TEXT_KEY)
MoxxyEventChannels.notificationEventSink?.success( NotificationStreamHandler.sink?.success(
NotificationEvent( NotificationEvent(
intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1), intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1),
intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!, intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!,
@@ -164,8 +163,8 @@ class NotificationReceiver : BroadcastReceiver() {
} }
} }
fun handleTap(context: Context, intent: Intent) { private fun handleTap(context: Context, intent: Intent) {
MoxxyEventChannels.notificationEventSink?.success( NotificationStreamHandler.sink?.success(
NotificationEvent( NotificationEvent(
intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1), intent.getLongExtra(NOTIFICATION_EXTRA_ID_KEY, -1),
intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!, intent.getStringExtra(NOTIFICATION_EXTRA_JID_KEY)!!,

View File

@@ -0,0 +1,22 @@
package org.moxxy.moxxy_native.notifications
import android.util.Log
import io.flutter.plugin.common.EventChannel
import org.moxxy.moxxy_native.TAG
object NotificationStreamHandler : EventChannel.StreamHandler {
// The event sink to use for sending notification events to the service.
var sink: EventChannel.EventSink? = null
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
// "register" the event sink
sink = events
Log.d(TAG, "NotificationStreamHandler: Attached stream")
}
override fun onCancel(arguments: Any?) {
sink = null
Log.d(TAG, "NotificationStreamHandler: Detached stream")
}
}

View File

@@ -14,10 +14,8 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.app.Person import androidx.core.app.Person
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import androidx.core.app.TaskStackBuilder import androidx.core.app.TaskStackBuilder
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import org.moxxy.moxxy_native.MARK_AS_READ_ACTION 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_ID_KEY
import org.moxxy.moxxy_native.NOTIFICATION_EXTRA_JID_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_MIME
@@ -27,7 +25,7 @@ import org.moxxy.moxxy_native.REPLY_ACTION
import org.moxxy.moxxy_native.REPLY_TEXT_KEY import org.moxxy.moxxy_native.REPLY_TEXT_KEY
import org.moxxy.moxxy_native.TAG import org.moxxy.moxxy_native.TAG
import org.moxxy.moxxy_native.TAP_ACTION import org.moxxy.moxxy_native.TAP_ACTION
import java.io.File import org.moxxy.moxxy_native.content.MoxxyFileProvider
class NotificationsImplementation(private val context: Context) : MoxxyNotificationsApi { class NotificationsImplementation(private val context: Context) : MoxxyNotificationsApi {
override fun createNotificationGroups(groups: List<NotificationGroup>) { override fun createNotificationGroups(groups: List<NotificationGroup>) {
@@ -96,7 +94,7 @@ class NotificationsImplementation(private val context: Context) : MoxxyNotificat
context.applicationContext, context.applicationContext,
0, 0,
replyIntent, replyIntent,
PendingIntent.FLAG_MUTABLE, PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
) )
val replyAction = NotificationCompat.Action.Builder( val replyAction = NotificationCompat.Action.Builder(
R.drawable.reply, R.drawable.reply,
@@ -115,13 +113,14 @@ class NotificationsImplementation(private val context: Context) : MoxxyNotificat
notification.extra?.forEach { notification.extra?.forEach {
putExtra("payload_${it.key}", it.value) putExtra("payload_${it.key}", it.value)
Log.d(TAG, "Adding payload_${it.key} -> ${it.value}")
} }
} }
val markAsReadPendingIntent = PendingIntent.getBroadcast( val markAsReadPendingIntent = PendingIntent.getBroadcast(
context.applicationContext, context.applicationContext,
0, 0,
markAsReadIntent, markAsReadIntent,
PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
) )
val markAsReadAction = NotificationCompat.Action.Builder( val markAsReadAction = NotificationCompat.Action.Builder(
R.drawable.mark_as_read, R.drawable.mark_as_read,
@@ -207,11 +206,7 @@ class NotificationsImplementation(private val context: Context) : MoxxyNotificat
) )
// If we got an image, turn it into a content URI and set it // If we got an image, turn it into a content URI and set it
if (message.content.mime != null && message.content.path != null) { if (message.content.mime != null && message.content.path != null) {
val fileUri = FileProvider.getUriForFile( val fileUri = MoxxyFileProvider.getUriForPath(context, message.content.path)
context,
MOXXY_FILEPROVIDER_ID,
File(message.content.path),
)
msg.apply { msg.apply {
setData(message.content.mime, fileUri) setData(message.content.mime, fileUri)
@@ -256,7 +251,7 @@ class NotificationsImplementation(private val context: Context) : MoxxyNotificat
setCategory(Notification.CATEGORY_MESSAGE) setCategory(Notification.CATEGORY_MESSAGE)
// Prevent no notification when we replied before // Prevent no notification when we replied before
setOnlyAlertOnce(false) setOnlyAlertOnce(true)
// Automatically dismiss the notification on tap // Automatically dismiss the notification on tap
setAutoCancel(true) setAutoCancel(true)

View File

@@ -0,0 +1,13 @@
package org.moxxy.moxxy_native.picker
object MimeUtils {
// A reverse-mapping of image mime types to their commonly used file extension.
val imageMimeTypesToFileExtension = mapOf(
"image/png" to ".png",
"image/apng" to ".apng",
"image/avif" to ".avif",
"image/gif" to ".gif",
"image/jpeg" to ".jpg",
"image/webp" to ".webp",
)
}

View File

@@ -6,10 +6,11 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.OpenableColumns import android.provider.MediaStore.Images
import android.util.Log import android.util.Log
import io.flutter.plugin.common.PluginRegistry.ActivityResultListener import io.flutter.plugin.common.PluginRegistry.ActivityResultListener
import org.moxxy.moxxy_native.AsyncRequestTracker import org.moxxy.moxxy_native.AsyncRequestTracker
import org.moxxy.moxxy_native.BUFFER_SIZE
import org.moxxy.moxxy_native.PICK_FILES_REQUEST import org.moxxy.moxxy_native.PICK_FILES_REQUEST
import org.moxxy.moxxy_native.PICK_FILE_REQUEST import org.moxxy.moxxy_native.PICK_FILE_REQUEST
import org.moxxy.moxxy_native.PICK_FILE_WITH_DATA_REQUEST import org.moxxy.moxxy_native.PICK_FILE_WITH_DATA_REQUEST
@@ -20,6 +21,26 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
/*
* Attempt to replace the file extension in @fileName with @newExtension. If @newExtension is null,
* then @fileName is returned verbatim.
* */
private fun maybeReplaceExtension(fileName: String, newExtension: String?): String {
if (newExtension == null) {
return fileName
}
assert(newExtension[0] == '.')
val parts = fileName.split(".")
return if (parts.size == 1) {
"$fileName$newExtension"
} else {
// Split at the ".", join all but the list end together and append the new extension
val fileNameWithoutExtension = parts.subList(0, parts.size - 1).joinToString(".")
"$fileNameWithoutExtension$newExtension"
}
}
class PickerResultListener(private val context: Context) : ActivityResultListener { class PickerResultListener(private val context: Context) : ActivityResultListener {
/* /*
* Attempt to deduce the filename for the URI @uri. * Attempt to deduce the filename for the URI @uri.
@@ -29,10 +50,22 @@ class PickerResultListener(private val context: Context) : ActivityResultListene
private fun queryFileName(context: Context, uri: Uri): String { private fun queryFileName(context: Context, uri: Uri): String {
var result: String? = null var result: String? = null
if (uri.scheme == "content") { if (uri.scheme == "content") {
val projection = arrayOf(
Images.Media._ID,
Images.Media.MIME_TYPE,
Images.Media.DISPLAY_NAME,
)
val cursor = context.contentResolver.query(uri, null, null, null, null) val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor.use { cursor -> cursor.use { cursor ->
if (cursor != null && cursor.moveToFirst()) { if (cursor != null && cursor.moveToFirst()) {
result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)) val mimeType = cursor.getString(cursor.getColumnIndex(Images.Media.MIME_TYPE))
val displayName = cursor.getString(cursor.getColumnIndex(Images.Media.DISPLAY_NAME))
val fileExtension = MimeUtils.imageMimeTypesToFileExtension[mimeType]
// Note: This is a workaround for the Dart image library failing to parse the file
// because displayName somehow is always ".jpg", which confuses image.
result = maybeReplaceExtension(displayName, fileExtension)
Log.d(TAG, "Returning $result as filename (MIME: $mimeType)")
} }
} }
} }
@@ -51,7 +84,7 @@ class PickerResultListener(private val context: Context) : ActivityResultListene
if (Build.VERSION.SDK_INT >= 33) { if (Build.VERSION.SDK_INT >= 33) {
android.os.FileUtils.copy(input, output) android.os.FileUtils.copy(input, output)
} else { } else {
val buffer = ByteArray(4096) val buffer = ByteArray(BUFFER_SIZE)
while (input.read(buffer).also {} != -1) { while (input.read(buffer).also {} != -1) {
output.write(buffer) output.write(buffer)
} }
@@ -94,7 +127,7 @@ class PickerResultListener(private val context: Context) : ActivityResultListene
} }
val returnBuffer = mutableListOf<Byte>() val returnBuffer = mutableListOf<Byte>()
val readBuffer = ByteArray(4096) val readBuffer = ByteArray(BUFFER_SIZE)
try { try {
val inputStream = context.contentResolver.openInputStream(data!!.data!!)!! val inputStream = context.contentResolver.openInputStream(data!!.data!!)!!
while (inputStream.read(readBuffer).also {} != -1) { while (inputStream.read(readBuffer).also {} != -1) {

View File

@@ -0,0 +1,72 @@
package org.moxxy.moxxy_native.platform
import android.app.Activity
import android.graphics.Rect
import android.util.Log
import android.view.View
import android.view.ViewTreeObserver
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import io.flutter.plugin.common.EventChannel
import org.moxxy.moxxy_native.TAG
object KeyboardStreamHandler : EventChannel.StreamHandler {
// The currently active activity. Set by @MoxxyNativePlugin.
var activity: Activity? = null
// The current bottom inset.
private var bottomInset: Int = 0
// The current event sink to use for sending events to the UI.
private var sink: EventChannel.EventSink? = null
private fun handleKeyboardHeightCheck(rootView: View?) {
rootView?.viewTreeObserver?.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
val r = Rect()
rootView.getWindowVisibleDisplayFrame(r)
val screenHeight = rootView.height
// Also subtract the height of the bottom inset as the SafeArea with "bottom: false"
// allows us to draw under the bottom system bar, if it is there.
val keypadHeight = screenHeight - r.bottom - bottomInset
val displayMetrics = activity?.resources?.displayMetrics
val logicalKeypadHeight = keypadHeight / (displayMetrics?.density ?: 1f)
if (keypadHeight > screenHeight * 0.15) {
sink?.success(logicalKeypadHeight.toDouble())
} else {
sink?.success(0.0)
}
}
})
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
// "register" the event sink
sink = events
val rootView = activity?.window?.decorView?.rootView
handleKeyboardHeightCheck(rootView)
if (rootView != null) {
ViewCompat.setOnApplyWindowInsetsListener(rootView!!) { _, windowInsets ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val triggerEvent = bottomInset != insets.bottom
bottomInset = insets.bottom
// Notify in case the inset changed
if (triggerEvent) handleKeyboardHeightCheck(rootView)
WindowInsetsCompat.CONSUMED
}
}
Log.d(TAG, "KeyboardStreamHandler: Attached stream")
}
override fun onCancel(arguments: Any?) {
sink = null
Log.d(TAG, "KeyboardStreamHandler: Detached stream")
}
}

View File

@@ -8,6 +8,8 @@ import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MessageCodec import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMessageCodec import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private fun wrapResult(result: Any?): List<Any?> { private fun wrapResult(result: Any?): List<Any?> {
return listOf(result) return listOf(result)
@@ -41,17 +43,66 @@ class FlutterError(
val details: Any? = null, val details: Any? = null,
) : Throwable() ) : Throwable()
/** Generated class from Pigeon that represents data sent in messages. */
data class ShareItem(
val path: String? = null,
val mime: String,
val text: String? = null,
) {
companion object {
@Suppress("UNCHECKED_CAST")
fun fromList(list: List<Any?>): ShareItem {
val path = list[0] as String?
val mime = list[1] as String
val text = list[2] as String?
return ShareItem(path, mime, text)
}
}
fun toList(): List<Any?> {
return listOf<Any?>(
path,
mime,
text,
)
}
}
@Suppress("UNCHECKED_CAST")
private object MoxxyPlatformApiCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
128.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ShareItem.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is ShareItem -> {
stream.write(128)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface MoxxyPlatformApi { interface MoxxyPlatformApi {
fun getPersistentDataPath(): String fun getPersistentDataPath(): String
fun getCacheDataPath(): String fun getCacheDataPath(): String
fun openBatteryOptimisationSettings() fun openBatteryOptimisationSettings()
fun isIgnoringBatteryOptimizations(): Boolean fun isIgnoringBatteryOptimizations(): Boolean
fun shareItems(items: List<ShareItem>, genericMimeType: String)
companion object { companion object {
/** The codec used by MoxxyPlatformApi. */ /** The codec used by MoxxyPlatformApi. */
val codec: MessageCodec<Any?> by lazy { val codec: MessageCodec<Any?> by lazy {
StandardMessageCodec() MoxxyPlatformApiCodec
} }
/** Sets up an instance of `MoxxyPlatformApi` to handle messages through the `binaryMessenger`. */ /** Sets up an instance of `MoxxyPlatformApi` to handle messages through the `binaryMessenger`. */
@@ -122,6 +173,26 @@ interface MoxxyPlatformApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxy_native.MoxxyPlatformApi.shareItems", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val itemsArg = args[0] as List<ShareItem>
val genericMimeTypeArg = args[1] as String
var wrapped: List<Any?>
try {
api.shareItems(itemsArg, genericMimeTypeArg)
wrapped = listOf<Any?>(null)
} catch (exception: Throwable) {
wrapped = wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
} }
} }
} }

View File

@@ -5,6 +5,8 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.PowerManager import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import androidx.core.app.ShareCompat
import org.moxxy.moxxy_native.content.MoxxyFileProvider
class PlatformImplementation(private val context: Context) : MoxxyPlatformApi { class PlatformImplementation(private val context: Context) : MoxxyPlatformApi {
override fun getPersistentDataPath(): String { override fun getPersistentDataPath(): String {
@@ -27,4 +29,28 @@ class PlatformImplementation(private val context: Context) : MoxxyPlatformApi {
val pm = context.getSystemService(PowerManager::class.java) val pm = context.getSystemService(PowerManager::class.java)
return pm.isIgnoringBatteryOptimizations(context.packageName) return pm.isIgnoringBatteryOptimizations(context.packageName)
} }
override fun shareItems(items: List<ShareItem>, genericMimeType: String) {
// Empty lists make no sense
assert(items.isNotEmpty())
// Convert the paths to content URIs
val builder = ShareCompat.IntentBuilder(context).setType(genericMimeType)
for (item in items) {
assert(item.text == null && item.path != null || item.text != null && item.path == null)
if (item.text != null) {
builder.setText(item.text)
} else if (item.path != null) {
builder.addStream(MoxxyFileProvider.getUriForPath(context, item.path))
}
}
// We cannot just use startChooser() because then Android complains that we're not attached
// to an Activity. So, we just ask it to start a new one.
val intent = builder.createChooserIntent().apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
} }

View File

@@ -26,6 +26,6 @@ subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')
} }
task clean(type: Delete) { tasks.register("clean", Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }

View File

@@ -1,10 +1,10 @@
// ignore_for_file: avoid_print // ignore_for_file: avoid_print
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxxy_native/moxxy_native.dart'; import 'package:moxxy_native/moxxy_native.dart';
import 'package:path/path.dart' as p;
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@pragma('vm:entrypoint') @pragma('vm:entrypoint')
@@ -50,6 +50,19 @@ class TestEvent extends BackgroundEvent {
class MyAppState extends State<MyApp> { class MyAppState extends State<MyApp> {
String? imagePath; String? imagePath;
@override
void initState() {
super.initState();
const EventChannel('org.moxxy.moxxyv2/notification_stream')
.receiveBroadcastStream()
.listen(
(event) {
print('Keyboard height: ${event as double}');
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
@@ -125,45 +138,102 @@ class MyAppState extends State<MyApp> {
), ),
if (imagePath != null) Image.file(File(imagePath!)), if (imagePath != null) Image.file(File(imagePath!)),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
// Create channel // Create channel
if (Platform.isAndroid) { if (Platform.isAndroid) {
await MoxxyNotificationsApi().createNotificationChannels( await MoxxyNotificationsApi().createNotificationChannels(
[ [
NotificationChannel( NotificationChannel(
id: 'foreground_service', id: 'foreground_service',
title: 'Foreground service', title: 'Foreground service',
description: 'lol', description: 'lol',
importance: NotificationChannelImportance.MIN, importance: NotificationChannelImportance.MIN,
showBadge: false, showBadge: false,
vibration: false, vibration: false,
enableLights: false, enableLights: false,
), ),
], ],
); );
await Permission.notification.request(); await Permission.notification.request();
} }
final srv = getForegroundService(); final srv = getForegroundService();
await srv.start( await srv.start(
const ServiceConfig( const ServiceConfig(
serviceEntrypoint, serviceEntrypoint,
serviceHandleData, serviceHandleData,
'en', 'en',
),
(data) async {
print('[FG] Received data $data');
},
);
await Future<void>.delayed(const Duration(milliseconds: 600));
await getForegroundService().send(
TestCommand(),
awaitable: false,
);
},
child: const Text('Start foreground service'),
),
TextButton(
onPressed: () async {
// Pick a file and copy it into the internal storage directory
final mediaDir = Directory(
p.join(
await MoxxyPlatformApi().getPersistentDataPath(),
'media',
),
);
if (!mediaDir.existsSync()) {
await mediaDir.create(recursive: true);
}
final pickResult = await MoxxyPickerApi()
.pickFiles(FilePickerType.image, true);
if (pickResult.isEmpty) return;
final shareItems = List<ShareItem>.empty(growable: true);
for (final result in pickResult) {
final mediaDirPath = p.join(
mediaDir.path,
p.basename(result!),
);
await File(result).copy(mediaDirPath);
shareItems.add(
ShareItem(
path: mediaDirPath,
mime: 'image/jpeg',
), ),
(data) async {
print('[FG] Received data $data');
},
); );
}
await Future<void>.delayed(const Duration(milliseconds: 600)); // Share with the system
await getForegroundService().send( await MoxxyPlatformApi().shareItems(
TestCommand(), shareItems,
awaitable: false, 'image/*',
); );
}, },
child: const Text('Start foreground service')), child: const Text('Share internal files'),
),
TextButton(
onPressed: () async {
// Share with the system
await MoxxyPlatformApi().shareItems(
[
ShareItem(
mime: 'text/plain',
text: 'Hello World!',
),
],
'text/*',
);
},
child: const Text('Share some text'),
),
const TextField(),
], ],
), ),
), ),

View File

@@ -5,10 +5,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: bfe67ef28df125b7dddcea62755991f807aa39a2492a23e1550161692950bbe0 sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.10.0" version: "2.11.0"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -21,10 +21,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: e6a326c8af69605aec75ed6c187d06b349707a27fbff8222ca9cc2cff167975c sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "1.3.0"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -37,10 +37,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: collection name: collection
sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0 sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.17.1"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -95,10 +95,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: js name: js
sha256: "5528c2f391ededb7775ec1daa69e65a2d61276f7552de2b5f7b8d34ee9fd4ab7" sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.5" version: "0.6.7"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -119,10 +119,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "16db949ceee371e9b99d22f88fa3a73c4e59fd0afed0bd25fc336eb76c198b72" sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.13" version: "0.12.15"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -135,10 +135,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "6c268b42ed578a53088d834796959e4a1814b5e9e164f147f580a386e5decf42" sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.8.0" version: "1.9.1"
moxlib: moxlib:
dependency: transitive dependency: transitive
description: description:
@@ -153,15 +153,15 @@ packages:
path: ".." path: ".."
relative: true relative: true
source: path source: path
version: "0.1.0" version: "0.2.0"
path: path:
dependency: transitive dependency: "direct main"
description: description:
name: path name: path
sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.8.2" version: "1.8.3"
permission_handler: permission_handler:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -267,10 +267,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ad540f65f92caa91bf21dfc8ffb8c589d6e4dc0c2267818b4cc2792857706206 sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.4.16" version: "0.5.1"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -296,5 +296,5 @@ packages:
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
sdks: sdks:
dart: ">=2.19.6 <3.0.0" dart: ">=3.0.0-0 <4.0.0"
flutter: ">=2.8.0" flutter: ">=2.8.0"

View File

@@ -31,6 +31,7 @@ dependencies:
permission_handler: ^10.4.5 permission_handler: ^10.4.5
get_it: ^7.6.0 get_it: ^7.6.0
logging: ^1.2.0 logging: ^1.2.0
path: ^1.8.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -44,7 +44,7 @@
]); ]);
lib = pkgs.lib; lib = pkgs.lib;
pinnedJDK = pkgs.jdk17; pinnedJDK = pkgs.jdk17;
flutterVersion = pkgs.flutter37; flutterVersion = pkgs.flutter;
in { in {
devShell = pkgs.mkShell { devShell = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [

View File

@@ -8,6 +8,60 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
class ShareItem {
ShareItem({
this.path,
required this.mime,
this.text,
});
String? path;
String mime;
String? text;
Object encode() {
return <Object?>[
path,
mime,
text,
];
}
static ShareItem decode(Object result) {
result as List<Object?>;
return ShareItem(
path: result[0] as String?,
mime: result[1]! as String,
text: result[2] as String?,
);
}
}
class _MoxxyPlatformApiCodec extends StandardMessageCodec {
const _MoxxyPlatformApiCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is ShareItem) {
buffer.putUint8(128);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 128:
return ShareItem.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
}
}
class MoxxyPlatformApi { class MoxxyPlatformApi {
/// Constructor for [MoxxyPlatformApi]. The [binaryMessenger] named argument is /// Constructor for [MoxxyPlatformApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default /// available for dependency injection. If it is left null, the default
@@ -16,7 +70,7 @@ class MoxxyPlatformApi {
: _binaryMessenger = binaryMessenger; : _binaryMessenger = binaryMessenger;
final BinaryMessenger? _binaryMessenger; final BinaryMessenger? _binaryMessenger;
static const MessageCodec<Object?> codec = StandardMessageCodec(); static const MessageCodec<Object?> codec = _MoxxyPlatformApiCodec();
Future<String> getPersistentDataPath() async { Future<String> getPersistentDataPath() async {
final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>( final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
@@ -120,4 +174,27 @@ class MoxxyPlatformApi {
return (replyList[0] as bool?)!; return (replyList[0] as bool?)!;
} }
} }
Future<void> shareItems(
List<ShareItem?> arg_items, String arg_genericMimeType) async {
final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.moxxy_native.MoxxyPlatformApi.shareItems', codec,
binaryMessenger: _binaryMessenger);
final List<Object?>? replyList = await channel
.send(<Object?>[arg_items, arg_genericMimeType]) as List<Object?>?;
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;
}
}
} }

View File

@@ -10,6 +10,13 @@ import 'package:pigeon/pigeon.dart';
), ),
), ),
) )
class ShareItem {
const ShareItem(this.path, this.mime, this.text);
final String? path;
final String mime;
final String? text;
}
@HostApi() @HostApi()
abstract class MoxxyPlatformApi { abstract class MoxxyPlatformApi {
String getPersistentDataPath(); String getPersistentDataPath();
@@ -19,4 +26,6 @@ abstract class MoxxyPlatformApi {
void openBatteryOptimisationSettings(); void openBatteryOptimisationSettings();
bool isIgnoringBatteryOptimizations(); bool isIgnoringBatteryOptimizations();
void shareItems(List<ShareItem> items, String genericMimeType);
} }

View File

@@ -1,6 +1,6 @@
name: moxxy_native name: moxxy_native
description: Interactions with the system for Moxxy description: Interactions with the system for Moxxy
version: 0.2.0 version: 0.3.2
publish_to: https://git.polynom.me/api/packages/Moxxy/pub publish_to: https://git.polynom.me/api/packages/Moxxy/pub
homepage: homepage: