feat(messaging): Flutter Web 알림 마일스톤을 완료 상태로 승격하고 알림 라우팅을 개선한다

- PHASE.md에서 flutter-web-notification-smoke 마일스톤 상태를 완료로 변경하고 archive로 이동
- browser_notification_interop_web.dart에서 라우팅 데이터 캡처 방식을 인스턴스 변수에서 인자 전달로 변경하여 각 알림별 데이터 격리
- nexo_messaging_plugin.dart에서 permissionGranted를 고정 값에서 실제 권한 상태 기반으로 변경
- 코드 스타일 개선: if문 블록 포맷팅, 단일 줄 최적화
- 테스트 추가: show 실패 시 최신 권한 상태를 반환하는 검증
This commit is contained in:
toki 2026-06-10 20:40:17 +09:00
parent f3bba0a1ca
commit 079c2984bd
5 changed files with 69 additions and 39 deletions

View file

@ -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`가 통과했다.
## 범위 제외

View file

@ -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`

View file

@ -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,
);
}

View file

@ -33,10 +33,6 @@ class BrowserNotificationInteropImpl extends BrowserNotificationInterop {
/// Captured click callback set via [setClickHandler].
void Function(Map<String, Object?>)? _clickHandler;
/// Routing data captured at the moment the notification was displayed
/// so that the click event listener can reconstruct it.
Map<String, Object?> _lastRoutingData = const <String, Object?>{};
@override
void setClickHandler(void Function(Map<String, Object?> 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 = <String, Object?>{};
// Capture routing fields for this notification.
final routingData = <String, Object?>{};
final routingKeys = <String>[
'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<String, Object?> 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.
}

View file

@ -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<String, Object?>? lastShowOptions;
@ -33,7 +37,10 @@ class FakeBrowserNotificationInterop implements BrowserNotificationInterop {
if (!isSupported) return false;
showCalled = true;
lastShowOptions = Map<String, Object?>.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({