diff --git a/agent-roadmap/phase/messaging-runtime/milestones/flutter-web-notification-smoke.md b/agent-roadmap/archive/phase/messaging-runtime/milestones/flutter-web-notification-smoke.md similarity index 91% rename from agent-roadmap/phase/messaging-runtime/milestones/flutter-web-notification-smoke.md rename to agent-roadmap/archive/phase/messaging-runtime/milestones/flutter-web-notification-smoke.md index 8acf7bc4..2aaabe66 100644 --- a/agent-roadmap/phase/messaging-runtime/milestones/flutter-web-notification-smoke.md +++ b/agent-roadmap/archive/phase/messaging-runtime/milestones/flutter-web-notification-smoke.md @@ -12,7 +12,7 @@ Flutter Web foreground browser notification이 실제 브라우저 환경에서 ## 상태 -[검토중] +[완료] ## 승격 조건 @@ -54,17 +54,18 @@ VM 단위 테스트와 Chrome/web 테스트가 서로 다른 platform expectatio ## 완료 리뷰 -- 상태: 요청됨 +- 상태: 승인됨 - 요청일: 2026-06-08 +- 승인일: 2026-06-09 - 완료 근거: - `agent-task/archive/2026/06/m-flutter-web-notification-smoke/01_web_scaffold/complete.log` - `agent-task/archive/2026/06/m-flutter-web-notification-smoke/02+01_web_runtime/complete.log` - `agent-task/archive/2026/06/m-flutter-web-notification-smoke/03_chrome_tests/complete.log` - `agent-task/archive/2026/06/m-flutter-web-notification-smoke/04+03_manual_evidence_closeout/complete.log` - 리뷰 필요: - - [ ] 사용자가 완료 결과를 확인했다 - - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 없음 + - [x] 사용자가 완료 결과를 확인했다 + - [x] archive 이동을 승인했다 +- 리뷰 코멘트: 2026-06-09 코드 관점 재검토에서 foreground web notification permission result와 notification별 click routing data 보강 후 `flutter analyze --no-fatal-infos`, `flutter test`, Chrome web test, 기본/opt-in `bin/test`가 통과했다. ## 범위 제외 diff --git a/agent-roadmap/phase/messaging-runtime/PHASE.md b/agent-roadmap/phase/messaging-runtime/PHASE.md index 333ed5ea..ca1445e8 100644 --- a/agent-roadmap/phase/messaging-runtime/PHASE.md +++ b/agent-roadmap/phase/messaging-runtime/PHASE.md @@ -40,8 +40,8 @@ Mattermost server/webapp/push-proxy는 upstream-followable runtime으로 두고, - [완료] Flutter Web 알림 - 경로: `agent-roadmap/archive/phase/messaging-runtime/milestones/flutter-web-notification.md` - 요약: `packages/messaging_flutter`를 Flutter Web/Chrome foreground browser notification까지 확장하고, Full Web Push는 별도 후속 계약으로 분리한다. -- [검토중] Flutter Web 알림 Smoke 보완 - - 경로: `agent-roadmap/phase/messaging-runtime/milestones/flutter-web-notification-smoke.md` +- [완료] Flutter Web 알림 Smoke 보완 + - 경로: `agent-roadmap/archive/phase/messaging-runtime/milestones/flutter-web-notification-smoke.md` - 요약: Flutter Web foreground browser notification smoke가 원격 web runner와 forwarded localhost URL에서 실제로 재현되도록 host scaffold, Chrome 테스트, 수동 evidence 기준을 보완한다. - [계획] iOS 알림 테스트 - 경로: `agent-roadmap/phase/messaging-runtime/milestones/ios-notification-test.md` diff --git a/packages/messaging_flutter/lib/src/nexo_messaging_plugin.dart b/packages/messaging_flutter/lib/src/nexo_messaging_plugin.dart index 5dd22403..e24cca24 100644 --- a/packages/messaging_flutter/lib/src/nexo_messaging_plugin.dart +++ b/packages/messaging_flutter/lib/src/nexo_messaging_plugin.dart @@ -206,10 +206,18 @@ class NexoMessagingPlugin { } // Forward routing fields so click callback can reconstruct the event. - if (payload['server_url'] != null) options['server_url'] = payload['server_url']; - if (payload['channel_id'] != null) options['channel_id'] = payload['channel_id']; - if (payload['root_id'] != null) options['root_id'] = payload['root_id']; - if (payload['is_crt_enabled'] != null) options['is_crt_enabled'] = payload['is_crt_enabled']; + if (payload['server_url'] != null) { + options['server_url'] = payload['server_url']; + } + if (payload['channel_id'] != null) { + options['channel_id'] = payload['channel_id']; + } + if (payload['root_id'] != null) { + options['root_id'] = payload['root_id']; + } + if (payload['is_crt_enabled'] != null) { + options['is_crt_enabled'] = payload['is_crt_enabled']; + } // Show the notification; set up click routing once on first call. if (!_clickHandlerRegistered) { @@ -218,6 +226,8 @@ class NexoMessagingPlugin { } final success = _interop.show(options); + final permissionGranted = _interop.permission.toLowerCase() == 'granted'; + if (success) { // Forward the raw notification to onNotification stream. final normalized = normalizeWebForegroundMessage(payload); @@ -229,7 +239,7 @@ class NexoMessagingPlugin { return NexoMessagingWebNotificationDisplayResult( success: success, isSupported: true, - permissionGranted: true, + permissionGranted: permissionGranted, ); } diff --git a/packages/messaging_flutter/lib/src/web/browser_notification_interop_web.dart b/packages/messaging_flutter/lib/src/web/browser_notification_interop_web.dart index 49ae6e3a..628c79f4 100644 --- a/packages/messaging_flutter/lib/src/web/browser_notification_interop_web.dart +++ b/packages/messaging_flutter/lib/src/web/browser_notification_interop_web.dart @@ -33,10 +33,6 @@ class BrowserNotificationInteropImpl extends BrowserNotificationInterop { /// Captured click callback set via [setClickHandler]. void Function(Map)? _clickHandler; - /// Routing data captured at the moment the notification was displayed - /// so that the click event listener can reconstruct it. - Map _lastRoutingData = const {}; - @override void setClickHandler(void Function(Map data) onClick) { _clickHandler = onClick; @@ -49,8 +45,8 @@ class BrowserNotificationInteropImpl extends BrowserNotificationInterop { final title = options['title'] as String?; if (title == null) return false; - // Capture routing fields for the click handler. - _lastRoutingData = {}; + // Capture routing fields for this notification. + final routingData = {}; final routingKeys = [ 'server_url', 'channel_id', @@ -60,7 +56,7 @@ class BrowserNotificationInteropImpl extends BrowserNotificationInterop { for (final key in routingKeys) { final val = options[key]; if (val != null) { - _lastRoutingData[key] = val; + routingData[key] = val; } } @@ -69,15 +65,11 @@ class BrowserNotificationInteropImpl extends BrowserNotificationInterop { final icon = options['icon'] as String? ?? ''; final tag = options['tag'] as String? ?? ''; - final opts = web.NotificationOptions( - body: body, - icon: icon, - tag: tag, - ); + final opts = web.NotificationOptions(body: body, icon: icon, tag: tag); final notification = web.Notification(title, opts); // Attach the click handler immediately after creation. - _attachClickHandler(notification); + _attachClickHandler(notification, routingData); return true; } catch (_) { return false; @@ -86,17 +78,15 @@ class BrowserNotificationInteropImpl extends BrowserNotificationInterop { /// Attaches a click listener on [notification] that forwards the /// stored routing data to the [_clickHandler] callback. - void _attachClickHandler(web.Notification notification) { - final data = _lastRoutingData; + void _attachClickHandler( + web.Notification notification, + Map routingData, + ) { final clickListener = ((web.Event event) { - _clickHandler?.call(data); + _clickHandler?.call(routingData); }).toJS; try { - notification.addEventListener( - 'click', - clickListener, - false.toJS, - ); + notification.addEventListener('click', clickListener, false.toJS); } catch (_) { // Best-effort: failure here will not crash the notification display. } diff --git a/packages/messaging_flutter/test/nexo_messaging_test.dart b/packages/messaging_flutter/test/nexo_messaging_test.dart index e2c64f6f..d3db4eab 100644 --- a/packages/messaging_flutter/test/nexo_messaging_test.dart +++ b/packages/messaging_flutter/test/nexo_messaging_test.dart @@ -10,14 +10,18 @@ import 'package:nexo_messaging/src/web/foreground_message_mapper.dart'; class FakeBrowserNotificationInterop implements BrowserNotificationInterop { FakeBrowserNotificationInterop({ this.isSupportedParam = true, - this.permissionParam = 'granted', + String permissionParam = 'granted', + this.showReturnValue = true, + String? permissionAfterShow, }) : _isSupported = isSupportedParam, - _permission = permissionParam; + _permission = permissionParam, + _permissionAfterShow = permissionAfterShow; final bool _isSupported; - final String _permission; + String _permission; + final String? _permissionAfterShow; final bool isSupportedParam; - final String permissionParam; + final bool showReturnValue; bool showCalled = false; Map? lastShowOptions; @@ -33,7 +37,10 @@ class FakeBrowserNotificationInterop implements BrowserNotificationInterop { if (!isSupported) return false; showCalled = true; lastShowOptions = Map.from(options); - return true; + if (_permissionAfterShow != null) { + _permission = _permissionAfterShow; + } + return showReturnValue; } @override @@ -563,13 +570,20 @@ void main() { late NexoMessagingPlugin plugin; late FakeBrowserNotificationInterop fake; - void setUpWith({bool isSupported = true, String perm = 'granted'}) { + void setUpWith({ + bool isSupported = true, + String perm = 'granted', + bool showReturnValue = true, + String? permissionAfterShow, + }) { final ac = MethodChannel(NexoMessagingPlugin.actionChannelName); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(ac, (call) async => null); fake = FakeBrowserNotificationInterop( isSupportedParam: isSupported, permissionParam: perm, + showReturnValue: showReturnValue, + permissionAfterShow: permissionAfterShow, ); plugin = NexoMessagingPlugin.instance; plugin.resetForTesting(); @@ -615,6 +629,21 @@ void main() { expect(fake.lastShowOptions!['body'], 'B'); }); + test('show failure reads latest permission before returning', () { + setUpWith( + isSupported: true, + perm: 'granted', + showReturnValue: false, + permissionAfterShow: 'denied', + ); + + final r = plugin.showWebForegroundNotification({'title': 'H'}); + + expect(r.success, isFalse); + expect(r.isSupported, isTrue); + expect(r.permissionGranted, isFalse); + }); + test('interop.show() receives routing fields', () { setUpWith(); plugin.showWebForegroundNotification({