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) =

View File

@@ -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/) |
| `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/) |
| `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
decoders trip over. See
[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.

View File

@@ -1,6 +1,7 @@
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 '../core/emitter.dart';
@@ -9,9 +10,10 @@ import '../log.dart' show Log;
final _log = Log.tag('notifications');
/// OS-notification primitive. Domain-agnostic — the caller decides when
/// to emit and how to format the payload. Implemented on macOS via
/// `UNUserNotificationCenter` (see `macos/Classes/NotificationsPlugin.swift`).
/// Window-focus state lives in [XWindow], not here.
/// to emit and how to format the payload. Backed natively on macOS
/// (`UNUserNotificationCenter`) and Android (`NotificationManagerCompat`);
/// see the platform `NotificationsPlugin`. No handler on iOS, where calls
/// no-op. Window-focus state lives in [XWindow], not here.
abstract interface class XNotifications {
/// Production singleton — talks to the native plugin via MethodChannel
/// + EventChannel. Tests inject a fake `XNotifications` directly into
@@ -38,11 +40,18 @@ abstract interface class XNotifications {
class MethodChannelXNotifications implements XNotifications {
MethodChannelXNotifications() {
if (!_hasNativeHandler) return;
_eventsChannel.receiveBroadcastStream().listen(_onEvent, onError: (e, 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 _eventsChannel = EventChannel('ux/notifications/events');
@@ -90,8 +99,7 @@ class MethodChannelXNotifications implements XNotifications {
@override
Future<void> cancelByThread(String threadId) async {
try {
await _channel
.invokeMethod('cancelByThread', {'threadId': threadId});
await _channel.invokeMethod('cancelByThread', {'threadId': threadId});
} on PlatformException catch (e) {
_log.w('cancelByThread failed: ${e.message}');
} on MissingPluginException {

View File

@@ -1,6 +1,5 @@
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 '../core/emitter.dart';
@@ -17,22 +16,28 @@ abstract interface class XWindow {
/// `true` when this app currently has user focus.
///
/// - macOS: tracks `NSApplication.didBecomeActiveNotification` /
/// `didResignActiveNotification` via the native plugin.
/// - iOS / Android: no native plugin registers, so the emitter stays
/// at its `true` default. That's correct because socket frames only
/// reach the Dart isolate while the process is alive — by the time
/// the OS suspends the app, deliveries have stopped.
/// - macOS: tracks `NSApplication` active / resign via the native plugin.
/// - Android: tracks app foreground / background via `ProcessLifecycleOwner`,
/// so a backgrounded-but-alive process reports `false`.
/// - iOS: no native plugin registers, so the emitter stays at its `true`
/// default.
ValueListenable<bool> get focused;
}
class MethodChannelXWindow implements XWindow {
MethodChannelXWindow() {
if (!_hasNativeHandler) return;
_events.receiveBroadcastStream().listen(_onEvent, onError: (e, 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');
@override

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