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:
parent
f3bba0a1ca
commit
079c2984bd
5 changed files with 69 additions and 39 deletions
|
|
@ -12,7 +12,7 @@ Flutter Web foreground browser notification이 실제 브라우저 환경에서
|
||||||
|
|
||||||
## 상태
|
## 상태
|
||||||
|
|
||||||
[검토중]
|
[완료]
|
||||||
|
|
||||||
## 승격 조건
|
## 승격 조건
|
||||||
|
|
||||||
|
|
@ -54,17 +54,18 @@ VM 단위 테스트와 Chrome/web 테스트가 서로 다른 platform expectatio
|
||||||
|
|
||||||
## 완료 리뷰
|
## 완료 리뷰
|
||||||
|
|
||||||
- 상태: 요청됨
|
- 상태: 승인됨
|
||||||
- 요청일: 2026-06-08
|
- 요청일: 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/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/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/03_chrome_tests/complete.log`
|
||||||
- `agent-task/archive/2026/06/m-flutter-web-notification-smoke/04+03_manual_evidence_closeout/complete.log`
|
- `agent-task/archive/2026/06/m-flutter-web-notification-smoke/04+03_manual_evidence_closeout/complete.log`
|
||||||
- 리뷰 필요:
|
- 리뷰 필요:
|
||||||
- [ ] 사용자가 완료 결과를 확인했다
|
- [x] 사용자가 완료 결과를 확인했다
|
||||||
- [ ] archive 이동을 승인했다
|
- [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`가 통과했다.
|
||||||
|
|
||||||
## 범위 제외
|
## 범위 제외
|
||||||
|
|
||||||
|
|
@ -40,8 +40,8 @@ Mattermost server/webapp/push-proxy는 upstream-followable runtime으로 두고,
|
||||||
- [완료] Flutter Web 알림
|
- [완료] Flutter Web 알림
|
||||||
- 경로: `agent-roadmap/archive/phase/messaging-runtime/milestones/flutter-web-notification.md`
|
- 경로: `agent-roadmap/archive/phase/messaging-runtime/milestones/flutter-web-notification.md`
|
||||||
- 요약: `packages/messaging_flutter`를 Flutter Web/Chrome foreground browser notification까지 확장하고, Full Web Push는 별도 후속 계약으로 분리한다.
|
- 요약: `packages/messaging_flutter`를 Flutter Web/Chrome foreground browser notification까지 확장하고, Full Web Push는 별도 후속 계약으로 분리한다.
|
||||||
- [검토중] Flutter Web 알림 Smoke 보완
|
- [완료] Flutter Web 알림 Smoke 보완
|
||||||
- 경로: `agent-roadmap/phase/messaging-runtime/milestones/flutter-web-notification-smoke.md`
|
- 경로: `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 기준을 보완한다.
|
- 요약: Flutter Web foreground browser notification smoke가 원격 web runner와 forwarded localhost URL에서 실제로 재현되도록 host scaffold, Chrome 테스트, 수동 evidence 기준을 보완한다.
|
||||||
- [계획] iOS 알림 테스트
|
- [계획] iOS 알림 테스트
|
||||||
- 경로: `agent-roadmap/phase/messaging-runtime/milestones/ios-notification-test.md`
|
- 경로: `agent-roadmap/phase/messaging-runtime/milestones/ios-notification-test.md`
|
||||||
|
|
|
||||||
|
|
@ -206,10 +206,18 @@ class NexoMessagingPlugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward routing fields so click callback can reconstruct the event.
|
// Forward routing fields so click callback can reconstruct the event.
|
||||||
if (payload['server_url'] != null) options['server_url'] = payload['server_url'];
|
if (payload['server_url'] != null) {
|
||||||
if (payload['channel_id'] != null) options['channel_id'] = payload['channel_id'];
|
options['server_url'] = payload['server_url'];
|
||||||
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['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.
|
// Show the notification; set up click routing once on first call.
|
||||||
if (!_clickHandlerRegistered) {
|
if (!_clickHandlerRegistered) {
|
||||||
|
|
@ -218,6 +226,8 @@ class NexoMessagingPlugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
final success = _interop.show(options);
|
final success = _interop.show(options);
|
||||||
|
final permissionGranted = _interop.permission.toLowerCase() == 'granted';
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Forward the raw notification to onNotification stream.
|
// Forward the raw notification to onNotification stream.
|
||||||
final normalized = normalizeWebForegroundMessage(payload);
|
final normalized = normalizeWebForegroundMessage(payload);
|
||||||
|
|
@ -229,7 +239,7 @@ class NexoMessagingPlugin {
|
||||||
return NexoMessagingWebNotificationDisplayResult(
|
return NexoMessagingWebNotificationDisplayResult(
|
||||||
success: success,
|
success: success,
|
||||||
isSupported: true,
|
isSupported: true,
|
||||||
permissionGranted: true,
|
permissionGranted: permissionGranted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,6 @@ class BrowserNotificationInteropImpl extends BrowserNotificationInterop {
|
||||||
/// Captured click callback set via [setClickHandler].
|
/// Captured click callback set via [setClickHandler].
|
||||||
void Function(Map<String, Object?>)? _clickHandler;
|
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
|
@override
|
||||||
void setClickHandler(void Function(Map<String, Object?> data) onClick) {
|
void setClickHandler(void Function(Map<String, Object?> data) onClick) {
|
||||||
_clickHandler = onClick;
|
_clickHandler = onClick;
|
||||||
|
|
@ -49,8 +45,8 @@ class BrowserNotificationInteropImpl extends BrowserNotificationInterop {
|
||||||
final title = options['title'] as String?;
|
final title = options['title'] as String?;
|
||||||
if (title == null) return false;
|
if (title == null) return false;
|
||||||
|
|
||||||
// Capture routing fields for the click handler.
|
// Capture routing fields for this notification.
|
||||||
_lastRoutingData = <String, Object?>{};
|
final routingData = <String, Object?>{};
|
||||||
final routingKeys = <String>[
|
final routingKeys = <String>[
|
||||||
'server_url',
|
'server_url',
|
||||||
'channel_id',
|
'channel_id',
|
||||||
|
|
@ -60,7 +56,7 @@ class BrowserNotificationInteropImpl extends BrowserNotificationInterop {
|
||||||
for (final key in routingKeys) {
|
for (final key in routingKeys) {
|
||||||
final val = options[key];
|
final val = options[key];
|
||||||
if (val != null) {
|
if (val != null) {
|
||||||
_lastRoutingData[key] = val;
|
routingData[key] = val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,15 +65,11 @@ class BrowserNotificationInteropImpl extends BrowserNotificationInterop {
|
||||||
final icon = options['icon'] as String? ?? '';
|
final icon = options['icon'] as String? ?? '';
|
||||||
final tag = options['tag'] as String? ?? '';
|
final tag = options['tag'] as String? ?? '';
|
||||||
|
|
||||||
final opts = web.NotificationOptions(
|
final opts = web.NotificationOptions(body: body, icon: icon, tag: tag);
|
||||||
body: body,
|
|
||||||
icon: icon,
|
|
||||||
tag: tag,
|
|
||||||
);
|
|
||||||
final notification = web.Notification(title, opts);
|
final notification = web.Notification(title, opts);
|
||||||
|
|
||||||
// Attach the click handler immediately after creation.
|
// Attach the click handler immediately after creation.
|
||||||
_attachClickHandler(notification);
|
_attachClickHandler(notification, routingData);
|
||||||
return true;
|
return true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -86,17 +78,15 @@ class BrowserNotificationInteropImpl extends BrowserNotificationInterop {
|
||||||
|
|
||||||
/// Attaches a click listener on [notification] that forwards the
|
/// Attaches a click listener on [notification] that forwards the
|
||||||
/// stored routing data to the [_clickHandler] callback.
|
/// stored routing data to the [_clickHandler] callback.
|
||||||
void _attachClickHandler(web.Notification notification) {
|
void _attachClickHandler(
|
||||||
final data = _lastRoutingData;
|
web.Notification notification,
|
||||||
|
Map<String, Object?> routingData,
|
||||||
|
) {
|
||||||
final clickListener = ((web.Event event) {
|
final clickListener = ((web.Event event) {
|
||||||
_clickHandler?.call(data);
|
_clickHandler?.call(routingData);
|
||||||
}).toJS;
|
}).toJS;
|
||||||
try {
|
try {
|
||||||
notification.addEventListener(
|
notification.addEventListener('click', clickListener, false.toJS);
|
||||||
'click',
|
|
||||||
clickListener,
|
|
||||||
false.toJS,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Best-effort: failure here will not crash the notification display.
|
// Best-effort: failure here will not crash the notification display.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,18 @@ import 'package:nexo_messaging/src/web/foreground_message_mapper.dart';
|
||||||
class FakeBrowserNotificationInterop implements BrowserNotificationInterop {
|
class FakeBrowserNotificationInterop implements BrowserNotificationInterop {
|
||||||
FakeBrowserNotificationInterop({
|
FakeBrowserNotificationInterop({
|
||||||
this.isSupportedParam = true,
|
this.isSupportedParam = true,
|
||||||
this.permissionParam = 'granted',
|
String permissionParam = 'granted',
|
||||||
|
this.showReturnValue = true,
|
||||||
|
String? permissionAfterShow,
|
||||||
}) : _isSupported = isSupportedParam,
|
}) : _isSupported = isSupportedParam,
|
||||||
_permission = permissionParam;
|
_permission = permissionParam,
|
||||||
|
_permissionAfterShow = permissionAfterShow;
|
||||||
|
|
||||||
final bool _isSupported;
|
final bool _isSupported;
|
||||||
final String _permission;
|
String _permission;
|
||||||
|
final String? _permissionAfterShow;
|
||||||
final bool isSupportedParam;
|
final bool isSupportedParam;
|
||||||
final String permissionParam;
|
final bool showReturnValue;
|
||||||
|
|
||||||
bool showCalled = false;
|
bool showCalled = false;
|
||||||
Map<String, Object?>? lastShowOptions;
|
Map<String, Object?>? lastShowOptions;
|
||||||
|
|
@ -33,7 +37,10 @@ class FakeBrowserNotificationInterop implements BrowserNotificationInterop {
|
||||||
if (!isSupported) return false;
|
if (!isSupported) return false;
|
||||||
showCalled = true;
|
showCalled = true;
|
||||||
lastShowOptions = Map<String, Object?>.from(options);
|
lastShowOptions = Map<String, Object?>.from(options);
|
||||||
return true;
|
if (_permissionAfterShow != null) {
|
||||||
|
_permission = _permissionAfterShow;
|
||||||
|
}
|
||||||
|
return showReturnValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -563,13 +570,20 @@ void main() {
|
||||||
late NexoMessagingPlugin plugin;
|
late NexoMessagingPlugin plugin;
|
||||||
late FakeBrowserNotificationInterop fake;
|
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);
|
final ac = MethodChannel(NexoMessagingPlugin.actionChannelName);
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(ac, (call) async => null);
|
.setMockMethodCallHandler(ac, (call) async => null);
|
||||||
fake = FakeBrowserNotificationInterop(
|
fake = FakeBrowserNotificationInterop(
|
||||||
isSupportedParam: isSupported,
|
isSupportedParam: isSupported,
|
||||||
permissionParam: perm,
|
permissionParam: perm,
|
||||||
|
showReturnValue: showReturnValue,
|
||||||
|
permissionAfterShow: permissionAfterShow,
|
||||||
);
|
);
|
||||||
plugin = NexoMessagingPlugin.instance;
|
plugin = NexoMessagingPlugin.instance;
|
||||||
plugin.resetForTesting();
|
plugin.resetForTesting();
|
||||||
|
|
@ -615,6 +629,21 @@ void main() {
|
||||||
expect(fake.lastShowOptions!['body'], 'B');
|
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', () {
|
test('interop.show() receives routing fields', () {
|
||||||
setUpWith();
|
setUpWith();
|
||||||
plugin.showWebForegroundNotification({
|
plugin.showWebForegroundNotification({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue