notifications + window: add Android native plugins
`ux/notifications/events` and `ux/window/events` only had macOS stream handlers, so on Android/iOS the unconditional Dart subscription threw MissingPluginException at startup (EventChannel reports activation failures straight to FlutterError.onError, bypassing the `onError:` callback). - Gate each Dart event-channel subscription to platforms that register a native handler (`defaultTargetPlatform`), silencing iOS. - `WindowPlugin`: report app foreground/background as host focus via `ProcessLifecycleOwner` ON_START/ON_STOP, so a backgrounded-but-alive process reports `focused = false`. - `NotificationsPlugin`: local notifications (show/cancel by thread/all), POST_NOTIFICATIONS request on 13+, and tap routing back over the event channel — a tap that cold-starts the process is buffered until Dart subscribes. - Regression tests for the subscription gate plus contract tests for the method/event payloads.
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="io.swipelab.ux">
|
package="io.swipelab.ux">
|
||||||
|
|
||||||
|
<!-- NotificationsPlugin posts notifications on Android 13+. -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
323
android/src/main/kotlin/io/swipelab/ux/NotificationsPlugin.kt
Normal file
323
android/src/main/kotlin/io/swipelab/ux/NotificationsPlugin.kt
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
package io.swipelab.ux
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import io.flutter.plugin.common.PluginRegistry
|
||||||
|
|
||||||
|
/// `ux/notifications` + `ux/notifications/events`. Domain-agnostic OS
|
||||||
|
/// notification surface — show / cancel via the method channel, tap +
|
||||||
|
/// authorization changes via the event channel. The Apple counterpart is
|
||||||
|
/// `NotificationsPlugin.swift` (macOS only). App foreground/background state
|
||||||
|
/// lives in `WindowPlugin`, not here.
|
||||||
|
class NotificationsPlugin :
|
||||||
|
NativePlugin,
|
||||||
|
MethodChannel.MethodCallHandler,
|
||||||
|
EventChannel.StreamHandler {
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "messages"
|
||||||
|
private const val CHANNEL_NAME = "Messages"
|
||||||
|
private const val PERMISSION_REQUEST_CODE = 0xC3A0
|
||||||
|
|
||||||
|
/// Host app points this at a white-silhouette status drawable
|
||||||
|
/// (a coloured launcher icon renders as a white square). Falls
|
||||||
|
/// back to the launcher icon when absent.
|
||||||
|
private const val ICON_META = "io.swipelab.ux.notification_icon"
|
||||||
|
|
||||||
|
/// Tap payload is carried back on the launch intent: a boolean
|
||||||
|
/// marker plus one string extra per `data` entry, namespaced so it
|
||||||
|
/// can't collide with the host app's own extras.
|
||||||
|
private const val EXTRA_MARKER = "io.swipelab.ux.notif"
|
||||||
|
private const val EXTRA_PREFIX = "io.swipelab.ux.notif."
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private var context: Context? = null
|
||||||
|
private var methodChannel: MethodChannel? = null
|
||||||
|
private var eventChannel: EventChannel? = null
|
||||||
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
|
|
||||||
|
private var activity: Activity? = null
|
||||||
|
private var activityBinding: ActivityPluginBinding? = null
|
||||||
|
private var pendingPermissionResult: MethodChannel.Result? = null
|
||||||
|
|
||||||
|
/// A tap that cold-started the process arrives before Dart subscribes.
|
||||||
|
/// Hold it until [onListen] gives us a sink, else the launch tap is lost.
|
||||||
|
private var pendingTap: Map<String, String>? = null
|
||||||
|
|
||||||
|
private val permissionListener =
|
||||||
|
PluginRegistry.RequestPermissionsResultListener { code, _, results ->
|
||||||
|
if (code != PERMISSION_REQUEST_CODE) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
val granted = results.isNotEmpty() &&
|
||||||
|
results[0] == PackageManager.PERMISSION_GRANTED
|
||||||
|
pendingPermissionResult?.success(granted)
|
||||||
|
pendingPermissionResult = null
|
||||||
|
emitAuthorization(granted)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val newIntentListener = PluginRegistry.NewIntentListener { intent ->
|
||||||
|
handleTapIntent(intent)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Engine lifecycle
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
context = binding.applicationContext
|
||||||
|
|
||||||
|
val mc = MethodChannel(binding.binaryMessenger, "ux/notifications")
|
||||||
|
mc.setMethodCallHandler(this)
|
||||||
|
methodChannel = mc
|
||||||
|
|
||||||
|
val ec = EventChannel(binding.binaryMessenger, "ux/notifications/events")
|
||||||
|
ec.setStreamHandler(this)
|
||||||
|
eventChannel = ec
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
methodChannel?.setMethodCallHandler(null)
|
||||||
|
methodChannel = null
|
||||||
|
eventChannel?.setStreamHandler(null)
|
||||||
|
eventChannel = null
|
||||||
|
eventSink = null
|
||||||
|
context = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Activity lifecycle
|
||||||
|
|
||||||
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
activity = binding.activity
|
||||||
|
activityBinding = binding
|
||||||
|
binding.addRequestPermissionsResultListener(permissionListener)
|
||||||
|
binding.addOnNewIntentListener(newIntentListener)
|
||||||
|
// The activity may have been launched by a notification tap.
|
||||||
|
handleTapIntent(binding.activity.intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivity() {
|
||||||
|
activityBinding?.removeRequestPermissionsResultListener(permissionListener)
|
||||||
|
activityBinding?.removeOnNewIntentListener(newIntentListener)
|
||||||
|
activity = null
|
||||||
|
activityBinding = null
|
||||||
|
pendingPermissionResult?.success(false)
|
||||||
|
pendingPermissionResult = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - EventChannel
|
||||||
|
|
||||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||||
|
eventSink = events
|
||||||
|
val tap = pendingTap
|
||||||
|
if (tap != null) {
|
||||||
|
pendingTap = null
|
||||||
|
events?.success(mapOf("type" to "tap", "data" to tap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
eventSink = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MethodChannel
|
||||||
|
|
||||||
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
when (call.method) {
|
||||||
|
"requestPermission" -> handleRequestPermission(result)
|
||||||
|
"show" -> handleShow(call, result)
|
||||||
|
"cancelByThread" -> handleCancelByThread(call, result)
|
||||||
|
"cancelAll" -> handleCancelAll(result)
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleRequestPermission(result: MethodChannel.Result) {
|
||||||
|
val ctx = context
|
||||||
|
?: return result.error("no_context", "engine detached", null)
|
||||||
|
// Below 13 there is no runtime permission — notifications are on
|
||||||
|
// unless the user disabled them in system settings.
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
val enabled = NotificationManagerCompat.from(ctx).areNotificationsEnabled()
|
||||||
|
emitAuthorization(enabled)
|
||||||
|
return result.success(enabled)
|
||||||
|
}
|
||||||
|
val act = activity
|
||||||
|
?: return result.error("no_activity", "plugin not attached to an activity", null)
|
||||||
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
|
act, Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
emitAuthorization(true)
|
||||||
|
return result.success(true)
|
||||||
|
}
|
||||||
|
if (pendingPermissionResult != null) {
|
||||||
|
return result.error("in_progress", "another permission request is in flight", null)
|
||||||
|
}
|
||||||
|
pendingPermissionResult = result
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
act,
|
||||||
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
PERMISSION_REQUEST_CODE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleShow(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val ctx = context
|
||||||
|
?: return result.error("no_context", "engine detached", null)
|
||||||
|
val id = call.argument<String>("id")
|
||||||
|
val title = call.argument<String>("title")
|
||||||
|
val body = call.argument<String>("body")
|
||||||
|
if (id == null || title == null || body == null) {
|
||||||
|
return result.error("bad_args", "show expects id/title/body", null)
|
||||||
|
}
|
||||||
|
val threadId = call.argument<String>("threadId")
|
||||||
|
val data = call.argument<Map<String, Any?>>("data")
|
||||||
|
?.mapValues { it.value?.toString() ?: "" } ?: emptyMap()
|
||||||
|
|
||||||
|
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) {
|
||||||
|
emitAuthorization(false)
|
||||||
|
return result.success(null)
|
||||||
|
}
|
||||||
|
post(ctx, id, title, body, threadId, data)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(
|
||||||
|
ctx: Context,
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
threadId: String?,
|
||||||
|
data: Map<String, String>,
|
||||||
|
) {
|
||||||
|
ensureChannel(ctx)
|
||||||
|
|
||||||
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
val pending = PendingIntent.getActivity(
|
||||||
|
ctx, id.hashCode(), buildLaunchIntent(ctx, data), flags,
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
|
||||||
|
.setSmallIcon(smallIcon(ctx))
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pending)
|
||||||
|
if (threadId != null) builder.setGroup(threadId)
|
||||||
|
|
||||||
|
// Same (tag, id) replaces — `id` is stable per message, so a fresh
|
||||||
|
// preview supersedes the older toast. areNotificationsEnabled() was
|
||||||
|
// checked by the caller, so POST_NOTIFICATIONS is satisfied.
|
||||||
|
NotificationManagerCompat.from(ctx).notify(id, 0, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCancelByThread(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val ctx = context
|
||||||
|
?: return result.error("no_context", "engine detached", null)
|
||||||
|
val threadId = call.argument<String>("threadId")
|
||||||
|
?: return result.error("bad_args", "cancelByThread expects threadId", null)
|
||||||
|
// getActiveNotifications (used to recover the group key per notification)
|
||||||
|
// is API 23+; below that there is no way to enumerate, so it's a no-op.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
val mgr = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
for (sbn in mgr.activeNotifications) {
|
||||||
|
if (sbn.notification.group == threadId) {
|
||||||
|
val tag = sbn.tag
|
||||||
|
if (tag != null) mgr.cancel(tag, sbn.id) else mgr.cancel(sbn.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCancelAll(result: MethodChannel.Result) {
|
||||||
|
val ctx = context
|
||||||
|
?: return result.error("no_context", "engine detached", null)
|
||||||
|
NotificationManagerCompat.from(ctx).cancelAll()
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tap routing
|
||||||
|
|
||||||
|
private fun handleTapIntent(intent: Intent?) {
|
||||||
|
val i = intent ?: return
|
||||||
|
if (!i.getBooleanExtra(EXTRA_MARKER, false)) return
|
||||||
|
val extras = i.extras ?: return
|
||||||
|
val data = HashMap<String, String>()
|
||||||
|
for (key in extras.keySet()) {
|
||||||
|
if (key.startsWith(EXTRA_PREFIX)) {
|
||||||
|
data[key.removePrefix(EXTRA_PREFIX)] = extras.getString(key) ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitTap(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLaunchIntent(ctx: Context, data: Map<String, String>): Intent {
|
||||||
|
val launch = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
|
||||||
|
?: Intent(Intent.ACTION_MAIN)
|
||||||
|
launch.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
launch.putExtra(EXTRA_MARKER, true)
|
||||||
|
for ((k, v) in data) launch.putExtra(EXTRA_PREFIX + k, v)
|
||||||
|
return launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private fun ensureChannel(ctx: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val mgr = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (mgr.getNotificationChannel(CHANNEL_ID) != null) return
|
||||||
|
mgr.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun smallIcon(ctx: Context): Int {
|
||||||
|
val ai = ctx.packageManager.getApplicationInfo(
|
||||||
|
ctx.packageName, PackageManager.GET_META_DATA,
|
||||||
|
)
|
||||||
|
val meta = ai.metaData?.getInt(ICON_META, 0) ?: 0
|
||||||
|
return if (meta != 0) meta else ai.icon
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitTap(data: Map<String, String>) {
|
||||||
|
val sink = eventSink
|
||||||
|
if (sink == null) {
|
||||||
|
pendingTap = data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mainHandler.post { sink.success(mapOf("type" to "tap", "data" to data)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitAuthorization(granted: Boolean) {
|
||||||
|
mainHandler.post {
|
||||||
|
eventSink?.success(mapOf("type" to "authorization", "granted" to granted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
android/src/main/kotlin/io/swipelab/ux/WindowPlugin.kt
Normal file
57
android/src/main/kotlin/io/swipelab/ux/WindowPlugin.kt
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package io.swipelab.ux
|
||||||
|
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
|
||||||
|
/// `ux/window/events`. Reports app foreground / background as host-focus
|
||||||
|
/// state — emits {"type":"focus","focused":Bool} on `ProcessLifecycleOwner`
|
||||||
|
/// ON_START / ON_STOP. The Apple counterpart is `WindowPlugin.swift`
|
||||||
|
/// (NSApplication active/resign); together they back Dart's `XWindow`.
|
||||||
|
class WindowPlugin : NativePlugin, EventChannel.StreamHandler {
|
||||||
|
private var eventChannel: EventChannel? = null
|
||||||
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
|
|
||||||
|
/// ProcessLifecycleOwner delivers ON_START/ON_STOP on the main thread,
|
||||||
|
/// which is also where EventSink.success must run — no Handler bounce.
|
||||||
|
private val observer = LifecycleEventObserver { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_START -> emitFocus(true)
|
||||||
|
Lifecycle.Event.ON_STOP -> emitFocus(false)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
val ec = EventChannel(binding.binaryMessenger, "ux/window/events")
|
||||||
|
ec.setStreamHandler(this)
|
||||||
|
eventChannel = ec
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.removeObserver(observer)
|
||||||
|
eventChannel?.setStreamHandler(null)
|
||||||
|
eventChannel = null
|
||||||
|
eventSink = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||||
|
eventSink = events
|
||||||
|
// Seed the current state so a late subscriber gets the right value
|
||||||
|
// immediately instead of waiting for the next ON_START / ON_STOP.
|
||||||
|
val foreground = ProcessLifecycleOwner.get().lifecycle.currentState
|
||||||
|
.isAtLeast(Lifecycle.State.STARTED)
|
||||||
|
emitFocus(foreground)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
eventSink = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitFocus(focused: Boolean) {
|
||||||
|
eventSink?.success(mapOf("type" to "focus", "focused" to focused))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ class XPlugin : FlutterPlugin, ActivityAware {
|
|||||||
VideoPlayerPlugin(),
|
VideoPlayerPlugin(),
|
||||||
CrashPlugin(),
|
CrashPlugin(),
|
||||||
UrlPlugin(),
|
UrlPlugin(),
|
||||||
|
NotificationsPlugin(),
|
||||||
|
WindowPlugin(),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ Conventions:
|
|||||||
| `XCamera` — CameraX (Android) / AVCaptureSession (Apple) | [lib/src/camera/](../lib/src/camera/) | [android/src/main/kotlin/io/swipelab/ux/camera/](../android/src/main/kotlin/io/swipelab/ux/camera/) | [darwin/Camera/](../darwin/Camera/) |
|
| `XCamera` — CameraX (Android) / AVCaptureSession (Apple) | [lib/src/camera/](../lib/src/camera/) | [android/src/main/kotlin/io/swipelab/ux/camera/](../android/src/main/kotlin/io/swipelab/ux/camera/) | [darwin/Camera/](../darwin/Camera/) |
|
||||||
| `XScanner` — ZXing QR scanner (Android) / VNDetect (Apple) | [lib/src/scanner/](../lib/src/scanner/) | [android/src/main/kotlin/io/swipelab/ux/scanner/](../android/src/main/kotlin/io/swipelab/ux/scanner/) | [darwin/Scanner/](../darwin/Scanner/) |
|
| `XScanner` — ZXing QR scanner (Android) / VNDetect (Apple) | [lib/src/scanner/](../lib/src/scanner/) | [android/src/main/kotlin/io/swipelab/ux/scanner/](../android/src/main/kotlin/io/swipelab/ux/scanner/) | [darwin/Scanner/](../darwin/Scanner/) |
|
||||||
| `XVideoPlayer` — ExoPlayer (Android) / AVPlayer (Apple) | [lib/src/video/](../lib/src/video/) | [android/src/main/kotlin/io/swipelab/ux/video/](../android/src/main/kotlin/io/swipelab/ux/video/) | [darwin/Video/](../darwin/Video/) |
|
| `XVideoPlayer` — ExoPlayer (Android) / AVPlayer (Apple) | [lib/src/video/](../lib/src/video/) | [android/src/main/kotlin/io/swipelab/ux/video/](../android/src/main/kotlin/io/swipelab/ux/video/) | [darwin/Video/](../darwin/Video/) |
|
||||||
| `XFile`, `XNotifications`, `XWindow`, navi — see source | [lib/src/](../lib/src/) | mixed | mixed |
|
| `XNotifications` — OS notifications: show / cancel / tap / authorization | [lib/src/notifications/](../lib/src/notifications/) | [android/.../NotificationsPlugin.kt](../android/src/main/kotlin/io/swipelab/ux/NotificationsPlugin.kt) | [macos/Classes/NotificationsPlugin.swift](../macos/Classes/NotificationsPlugin.swift) (macOS only) |
|
||||||
|
| `XWindow` — host focus state (`focused`) | [lib/src/window/](../lib/src/window/) | [android/.../WindowPlugin.kt](../android/src/main/kotlin/io/swipelab/ux/WindowPlugin.kt) | [macos/Classes/WindowPlugin.swift](../macos/Classes/WindowPlugin.swift) (macOS only) |
|
||||||
|
| `XFile`, navi — see source | [lib/src/](../lib/src/) | mixed | mixed |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -165,3 +167,29 @@ fallback needed — `AVFoundation` handles iOS-produced H.264 (and HEVC)
|
|||||||
directly without the DPB-cap / full-range quirks the Android platform
|
directly without the DPB-cap / full-range quirks the Android platform
|
||||||
decoders trip over. See
|
decoders trip over. See
|
||||||
[darwin/Video/VideoPlayerInstance.swift](../darwin/Video/VideoPlayerInstance.swift).
|
[darwin/Video/VideoPlayerInstance.swift](../darwin/Video/VideoPlayerInstance.swift).
|
||||||
|
|
||||||
|
## Notifications + window focus
|
||||||
|
|
||||||
|
`XNotifications` and `XWindow` share a two-channel shape: a `MethodChannel`
|
||||||
|
for commands and an `EventChannel` for native-pushed events. Each Dart
|
||||||
|
constructor only subscribes to its `EventChannel` on platforms that
|
||||||
|
register a native handler (`defaultTargetPlatform` gate) — otherwise
|
||||||
|
activating the stream throws `MissingPluginException`, which Flutter
|
||||||
|
reports straight to `FlutterError.onError`.
|
||||||
|
|
||||||
|
Handler coverage:
|
||||||
|
|
||||||
|
| | macOS | Android | iOS |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `XWindow` (`ux/window/events`) | `NSApplication` active/resign | `ProcessLifecycleOwner` `ON_START`/`ON_STOP` | none — `focused` stays `true` |
|
||||||
|
| `XNotifications` (`ux/notifications` + `…/events`) | `UNUserNotificationCenter` | `NotificationManagerCompat` + `POST_NOTIFICATIONS` | none |
|
||||||
|
|
||||||
|
On Android the two are coupled: `XWindow.focused` flipping to `false`
|
||||||
|
when the app backgrounds is what lets a consumer (Banlu's
|
||||||
|
`MessageNotifier`) post a local notification for a live socket message
|
||||||
|
while the process is alive but unfocused — the gap FCM intentionally
|
||||||
|
skips for socket-connected devices. The notification's tap `PendingIntent`
|
||||||
|
relaunches the app's launcher activity (resolved generically via
|
||||||
|
`getLaunchIntentForPackage`, no app class hard-coded) carrying the `data`
|
||||||
|
payload, which the plugin re-emits as a `tap` event; a tap that cold-starts
|
||||||
|
the process is buffered until Dart subscribes.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
import 'package:flutter/foundation.dart'
|
||||||
|
show ValueListenable, defaultTargetPlatform, TargetPlatform;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../core/emitter.dart';
|
import '../core/emitter.dart';
|
||||||
@@ -9,9 +10,10 @@ import '../log.dart' show Log;
|
|||||||
final _log = Log.tag('notifications');
|
final _log = Log.tag('notifications');
|
||||||
|
|
||||||
/// OS-notification primitive. Domain-agnostic — the caller decides when
|
/// OS-notification primitive. Domain-agnostic — the caller decides when
|
||||||
/// to emit and how to format the payload. Implemented on macOS via
|
/// to emit and how to format the payload. Backed natively on macOS
|
||||||
/// `UNUserNotificationCenter` (see `macos/Classes/NotificationsPlugin.swift`).
|
/// (`UNUserNotificationCenter`) and Android (`NotificationManagerCompat`);
|
||||||
/// Window-focus state lives in [XWindow], not here.
|
/// see the platform `NotificationsPlugin`. No handler on iOS, where calls
|
||||||
|
/// no-op. Window-focus state lives in [XWindow], not here.
|
||||||
abstract interface class XNotifications {
|
abstract interface class XNotifications {
|
||||||
/// Production singleton — talks to the native plugin via MethodChannel
|
/// Production singleton — talks to the native plugin via MethodChannel
|
||||||
/// + EventChannel. Tests inject a fake `XNotifications` directly into
|
/// + EventChannel. Tests inject a fake `XNotifications` directly into
|
||||||
@@ -38,11 +40,18 @@ abstract interface class XNotifications {
|
|||||||
|
|
||||||
class MethodChannelXNotifications implements XNotifications {
|
class MethodChannelXNotifications implements XNotifications {
|
||||||
MethodChannelXNotifications() {
|
MethodChannelXNotifications() {
|
||||||
|
if (!_hasNativeHandler) return;
|
||||||
_eventsChannel.receiveBroadcastStream().listen(_onEvent, onError: (e, st) {
|
_eventsChannel.receiveBroadcastStream().listen(_onEvent, onError: (e, st) {
|
||||||
_log.w('event channel error', error: e, stackTrace: st);
|
_log.w('event channel error', error: e, stackTrace: st);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only macOS and Android register a native handler; activating the events
|
||||||
|
/// channel elsewhere throws `MissingPluginException`.
|
||||||
|
static bool get _hasNativeHandler =>
|
||||||
|
defaultTargetPlatform == TargetPlatform.macOS ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.android;
|
||||||
|
|
||||||
static const _channel = MethodChannel('ux/notifications');
|
static const _channel = MethodChannel('ux/notifications');
|
||||||
static const _eventsChannel = EventChannel('ux/notifications/events');
|
static const _eventsChannel = EventChannel('ux/notifications/events');
|
||||||
|
|
||||||
@@ -90,8 +99,7 @@ class MethodChannelXNotifications implements XNotifications {
|
|||||||
@override
|
@override
|
||||||
Future<void> cancelByThread(String threadId) async {
|
Future<void> cancelByThread(String threadId) async {
|
||||||
try {
|
try {
|
||||||
await _channel
|
await _channel.invokeMethod('cancelByThread', {'threadId': threadId});
|
||||||
.invokeMethod('cancelByThread', {'threadId': threadId});
|
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
_log.w('cancelByThread failed: ${e.message}');
|
_log.w('cancelByThread failed: ${e.message}');
|
||||||
} on MissingPluginException {
|
} on MissingPluginException {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'package:flutter/foundation.dart'
|
||||||
|
show ValueListenable, defaultTargetPlatform, TargetPlatform;
|
||||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../core/emitter.dart';
|
import '../core/emitter.dart';
|
||||||
@@ -17,22 +16,28 @@ abstract interface class XWindow {
|
|||||||
|
|
||||||
/// `true` when this app currently has user focus.
|
/// `true` when this app currently has user focus.
|
||||||
///
|
///
|
||||||
/// - macOS: tracks `NSApplication.didBecomeActiveNotification` /
|
/// - macOS: tracks `NSApplication` active / resign via the native plugin.
|
||||||
/// `didResignActiveNotification` via the native plugin.
|
/// - Android: tracks app foreground / background via `ProcessLifecycleOwner`,
|
||||||
/// - iOS / Android: no native plugin registers, so the emitter stays
|
/// so a backgrounded-but-alive process reports `false`.
|
||||||
/// at its `true` default. That's correct because socket frames only
|
/// - iOS: no native plugin registers, so the emitter stays at its `true`
|
||||||
/// reach the Dart isolate while the process is alive — by the time
|
/// default.
|
||||||
/// the OS suspends the app, deliveries have stopped.
|
|
||||||
ValueListenable<bool> get focused;
|
ValueListenable<bool> get focused;
|
||||||
}
|
}
|
||||||
|
|
||||||
class MethodChannelXWindow implements XWindow {
|
class MethodChannelXWindow implements XWindow {
|
||||||
MethodChannelXWindow() {
|
MethodChannelXWindow() {
|
||||||
|
if (!_hasNativeHandler) return;
|
||||||
_events.receiveBroadcastStream().listen(_onEvent, onError: (e, st) {
|
_events.receiveBroadcastStream().listen(_onEvent, onError: (e, st) {
|
||||||
_log.w('event channel error', error: e, stackTrace: st);
|
_log.w('event channel error', error: e, stackTrace: st);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only macOS and Android register a native stream handler for the events
|
||||||
|
/// channel; activating it elsewhere throws `MissingPluginException`.
|
||||||
|
static bool get _hasNativeHandler =>
|
||||||
|
defaultTargetPlatform == TargetPlatform.macOS ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.android;
|
||||||
|
|
||||||
static const _events = EventChannel('ux/window/events');
|
static const _events = EventChannel('ux/window/events');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
123
test/notifications_test.dart
Normal file
123
test/notifications_test.dart
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ux/ux.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final messenger =
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger;
|
||||||
|
const methods = MethodChannel('ux/notifications');
|
||||||
|
const events = MethodChannel('ux/notifications/events');
|
||||||
|
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
calls.clear();
|
||||||
|
messenger.setMockMethodCallHandler(methods, (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return call.method == 'requestPermission' ? true : null;
|
||||||
|
});
|
||||||
|
messenger.setMockMethodCallHandler(events, (_) async => null);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
messenger.setMockMethodCallHandler(methods, null);
|
||||||
|
messenger.setMockMethodCallHandler(events, null);
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> sendEvent(Object? event) => messenger.handlePlatformMessage(
|
||||||
|
'ux/notifications/events',
|
||||||
|
const StandardMethodCodec().encodeSuccessEnvelope(event),
|
||||||
|
(_) {},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Regression: iOS has no native handler, so activating the broadcast
|
||||||
|
// stream throws MissingPluginException straight to FlutterError.onError.
|
||||||
|
test('iOS does not activate the events channel', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
final eventCalls = <String>[];
|
||||||
|
messenger.setMockMethodCallHandler(events, (call) async {
|
||||||
|
eventCalls.add(call.method);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
MethodChannelXNotifications();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(eventCalls, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Android activates the events channel', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
final eventCalls = <String>[];
|
||||||
|
messenger.setMockMethodCallHandler(events, (call) async {
|
||||||
|
eventCalls.add(call.method);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
MethodChannelXNotifications();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(eventCalls, contains('listen'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show forwards the exact argument map', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
final n = MethodChannelXNotifications();
|
||||||
|
await n.show(XNotification(
|
||||||
|
id: 'd1:7',
|
||||||
|
title: 'Alice',
|
||||||
|
body: 'hi',
|
||||||
|
threadId: 'd1',
|
||||||
|
data: const {'dialogId': 'd1', 'messageId': '7'},
|
||||||
|
));
|
||||||
|
final show = calls.firstWhere((c) => c.method == 'show');
|
||||||
|
expect(show.arguments, {
|
||||||
|
'id': 'd1:7',
|
||||||
|
'title': 'Alice',
|
||||||
|
'body': 'hi',
|
||||||
|
'threadId': 'd1',
|
||||||
|
'data': {'dialogId': 'd1', 'messageId': '7'},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requestPermission invokes the method and updates authorized', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
final n = MethodChannelXNotifications();
|
||||||
|
expect(await n.requestPermission(), isTrue);
|
||||||
|
expect(calls.any((c) => c.method == 'requestPermission'), isTrue);
|
||||||
|
expect(n.authorized.value, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelByThread and cancelAll invoke their methods', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
final n = MethodChannelXNotifications();
|
||||||
|
await n.cancelByThread('d1');
|
||||||
|
await n.cancelAll();
|
||||||
|
expect(
|
||||||
|
calls.any((c) =>
|
||||||
|
c.method == 'cancelByThread' &&
|
||||||
|
(c.arguments as Map)['threadId'] == 'd1'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(calls.any((c) => c.method == 'cancelAll'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authorization and tap events reach the Dart side', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
final n = MethodChannelXNotifications();
|
||||||
|
await pumpEventQueue();
|
||||||
|
final taps = <Map<String, String>>[];
|
||||||
|
n.onTap.listen(taps.add);
|
||||||
|
|
||||||
|
await sendEvent({'type': 'authorization', 'granted': true});
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(n.authorized.value, isTrue);
|
||||||
|
|
||||||
|
await sendEvent({
|
||||||
|
'type': 'tap',
|
||||||
|
'data': {'dialogId': 'd1', 'messageId': '7'},
|
||||||
|
});
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(taps, [
|
||||||
|
{'dialogId': 'd1', 'messageId': '7'},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
63
test/window_test.dart
Normal file
63
test/window_test.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ux/ux.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final messenger =
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized().defaultBinaryMessenger;
|
||||||
|
const channel = MethodChannel('ux/window/events');
|
||||||
|
|
||||||
|
final listenCalls = <String>[];
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
listenCalls.clear();
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
listenCalls.add(call.method);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
messenger.setMockMethodCallHandler(channel, null);
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> sendEvent(Object? event) => messenger.handlePlatformMessage(
|
||||||
|
'ux/window/events',
|
||||||
|
const StandardMethodCodec().encodeSuccessEnvelope(event),
|
||||||
|
(_) {},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Regression: iOS has no native handler, so activating the broadcast
|
||||||
|
// stream throws MissingPluginException straight to FlutterError.onError.
|
||||||
|
test('iOS does not activate the events channel', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||||
|
final w = MethodChannelXWindow();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(listenCalls, isEmpty);
|
||||||
|
expect(w.focused.value, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Android activates the events channel', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
MethodChannelXWindow();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(listenCalls, contains('listen'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a focus event flips focused', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
final w = MethodChannelXWindow();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(w.focused.value, isTrue);
|
||||||
|
|
||||||
|
await sendEvent({'type': 'focus', 'focused': false});
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(w.focused.value, isFalse);
|
||||||
|
|
||||||
|
await sendEvent({'type': 'focus', 'focused': true});
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(w.focused.value, isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user