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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user