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:
agra
2026-05-30 13:39:49 +03:00
parent e8f8882f2e
commit 27cfc87def
9 changed files with 628 additions and 16 deletions

View File

@@ -1,6 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.swipelab.ux">
<!-- NotificationsPlugin posts notifications on Android 13+. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<provider
android:name="androidx.core.content.FileProvider"

View 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))
}
}
}

View 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))
}
}

View File

@@ -19,6 +19,8 @@ class XPlugin : FlutterPlugin, ActivityAware {
VideoPlayerPlugin(),
CrashPlugin(),
UrlPlugin(),
NotificationsPlugin(),
WindowPlugin(),
)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =