From 27cfc87def8b42131c4e789b7e2eb46788d31632 Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 30 May 2026 13:39:49 +0300 Subject: [PATCH] notifications + window: add Android native plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- android/src/main/AndroidManifest.xml | 3 + .../io/swipelab/ux/NotificationsPlugin.kt | 323 ++++++++++++++++++ .../kotlin/io/swipelab/ux/WindowPlugin.kt | 57 ++++ .../src/main/kotlin/io/swipelab/ux/XPlugin.kt | 2 + docs/architecture.md | 30 +- lib/src/notifications/x_notifications.dart | 20 +- lib/src/window/x_window.dart | 23 +- test/notifications_test.dart | 123 +++++++ test/window_test.dart | 63 ++++ 9 files changed, 628 insertions(+), 16 deletions(-) create mode 100644 android/src/main/kotlin/io/swipelab/ux/NotificationsPlugin.kt create mode 100644 android/src/main/kotlin/io/swipelab/ux/WindowPlugin.kt create mode 100644 test/notifications_test.dart create mode 100644 test/window_test.dart diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index be27b0b..dd909f6 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + ? = 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("id") + val title = call.argument("title") + val body = call.argument("body") + if (id == null || title == null || body == null) { + return result.error("bad_args", "show expects id/title/body", null) + } + val threadId = call.argument("threadId") + val data = call.argument>("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, + ) { + 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("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() + 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): 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) { + 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)) + } + } +} diff --git a/android/src/main/kotlin/io/swipelab/ux/WindowPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/WindowPlugin.kt new file mode 100644 index 0000000..bda742f --- /dev/null +++ b/android/src/main/kotlin/io/swipelab/ux/WindowPlugin.kt @@ -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)) + } +} diff --git a/android/src/main/kotlin/io/swipelab/ux/XPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/XPlugin.kt index ee57f23..4d627b3 100644 --- a/android/src/main/kotlin/io/swipelab/ux/XPlugin.kt +++ b/android/src/main/kotlin/io/swipelab/ux/XPlugin.kt @@ -19,6 +19,8 @@ class XPlugin : FlutterPlugin, ActivityAware { VideoPlayerPlugin(), CrashPlugin(), UrlPlugin(), + NotificationsPlugin(), + WindowPlugin(), ) override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) = diff --git a/docs/architecture.md b/docs/architecture.md index 9fc14b7..27ac0e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/lib/src/notifications/x_notifications.dart b/lib/src/notifications/x_notifications.dart index 304f9bf..101ca0b 100644 --- a/lib/src/notifications/x_notifications.dart +++ b/lib/src/notifications/x_notifications.dart @@ -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 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 { diff --git a/lib/src/window/x_window.dart b/lib/src/window/x_window.dart index 789b9cf..e2e1932 100644 --- a/lib/src/window/x_window.dart +++ b/lib/src/window/x_window.dart @@ -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 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 diff --git a/test/notifications_test.dart b/test/notifications_test.dart new file mode 100644 index 0000000..74dcdc3 --- /dev/null +++ b/test/notifications_test.dart @@ -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 = []; + + 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 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 = []; + 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 = []; + 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 = >[]; + 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'}, + ]); + }); +} diff --git a/test/window_test.dart b/test/window_test.dart new file mode 100644 index 0000000..ac69d58 --- /dev/null +++ b/test/window_test.dart @@ -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 = []; + + setUp(() { + listenCalls.clear(); + messenger.setMockMethodCallHandler(channel, (call) async { + listenCalls.add(call.method); + return null; + }); + }); + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + debugDefaultTargetPlatformOverride = null; + }); + + Future 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); + }); +}