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,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