nexo/packages/messaging_flutter/README.md
toki 3620da91b7 fix(messaging): Web 알림 클릭 라우팅을 연결한다
실제 browser Notification 인스턴스에 click listener가 없어 foreground 알림 클릭이 opened routing으로 이어지지 않았다.

리뷰 루프를 PASS로 종결하고 task archive evidence를 남긴다.
2026-06-07 19:04:08 +09:00

708 lines
34 KiB
Markdown

# nexo_messaging
Android-first Flutter plugin for the nexo messaging and push-notification
runtime.
The plugin owns the push-notification domain that should not be duplicated in
each consuming app: native FCM handling, notification display, notification tap
events, inline replies, dismiss handling, ACK delivery, device-token storage,
auth-token storage, and server signing-key storage.
## SDK Ownership Boundary
This package is the nexo-owned embedded messaging SDK. Its Android
implementation follows the nexo server/push contract, Android and FCM platform
changes, and Flutter plugin registration behavior. It does not follow or mirror
the Mattermost mobile app source tree; that app remains under `apps/mattermost/`
as an upstream clone.
## Runtime Tracking Boundary
The SDK tracks the server/push contract fields required for embedded
messaging: push payload routing fields, ACK/reply endpoints, signing-key and
device-token compatibility, Android/FCM delivery behavior, and Flutter plugin
registration. The Android runtime may keep compatibility with Mattermost server
and push-proxy payload conventions where those contracts remain part of nexo
messaging.
The SDK does not track Mattermost mobile implementation details such as React
Native screens, stores, navigation state, product UI, or app-specific business
logic. Host apps own their own screens and routers. Future typed notification
wrappers should be added as nexo SDK API after the raw event-shape contract is
stable; they are not a reason to mirror the Mattermost mobile source tree.
Platform-follow criteria define what a platform change review must re-check
instead of merging Mattermost mobile source. Track and re-verify against:
- Server/push payload changes: the fields in [Push Payload Fields](#push-payload-fields)
and the [ACK Contract](#ack-contract) request/response shape.
- FCM delivery changes: how `NexoFirebaseMessagingService` is woken, the data
payload shape, and signature/ACK ordering.
- Android notification platform changes: notification permission, channel
configuration, group/summary decision, inline reply, and dismiss behavior.
When any of those shift, the platform-follow review updates the relevant
contract section and the regression tests under `android/src/test/` rather than
copying upstream mobile code.
## Platform Status
| Platform | Status | Notes |
| --- | --- | --- |
| Android | Production target | Native FCM service, Room storage, JJWT verification, OkHttp ACK client, notification builder, inline reply receiver, dismiss receiver |
| iOS | Stub | Plugin scaffold exists, behavior is not implemented yet |
| macOS | Stub | Plugin scaffold exists; user-provided signing, machine, APNs/Firebase scope, and evidence handoff requirements are documented in [`../../docs/macos-notification-test-guide.md`](../../docs/macos-notification-test-guide.md) |
| Windows | Not registered | No Flutter plugin scaffold yet; user-provided Windows runner, packaging, identity, signing, and evidence handoff requirements are documented in [`../../docs/windows-notification-test-guide.md`](../../docs/windows-notification-test-guide.md) |
| Web | Foreground display | Plugin registration (`NexoMessagingWeb.registerWith()`), `package:web` dependency, browser Notification interop boundary (`BrowserNotificationInterop`), foreground payload mapper (`normalizeWebForegroundMessage`), and public display API (`showWebForegroundNotification`) are in place. Click events are wired to `handleNativeEvent()``onNotificationOpened`/`onNavigateToChannel`/`onNavigateToThread` callbacks. Full Web Push (service worker, VAPID, server device-token registration) follows. |
## Repository Layout
```text
packages/messaging_flutter/
lib/ # Public Dart API and Flutter-facing behavior
android/ # Android native plugin implementation
ios/ # iOS plugin scaffold
macos/ # macOS plugin scaffold
# windows/ # Not registered yet; see Windows preflight docs
test/ # Dart unit tests for the plugin contract
../../apps/flutter-test/ # Flutter test host
```
The workspace `apps/flutter-test/` app validates plugin integration without
turning this repository into a product-app workspace. The original Mattermost app
belongs under `apps/mattermost/`, not in the test host.
## How It Works
```mermaid
graph TD
A[nexo Server] -->|Secure JWT Push| B(Android FCM Service)
B -->|Verify signing key| C{Valid?}
C -->|Yes| D[Store payload and build notification]
C -->|No| E[Drop push]
D -->|Send ACK| A
D -->|Display notification| F(Android status bar)
F -->|Tap| G[Flutter opened event]
F -->|Inline reply| H[Reply receiver]
H -->|Send reply| A
G -->|Stream and callbacks| I(Host app navigation)
```
## Host App Responsibilities
The plugin isolates push handling, but the host application still owns app-level
integration:
1. Configure Firebase for the host app, including `google-services.json` under
`android/app/`.
2. Initialize Firebase as required by the host app before push registration.
3. Call `NexoMessagingPlugin.instance.initialize()` during app startup.
4. Save auth tokens and signing keys after login.
5. Clear auth tokens on logout.
6. Send the formatted device token to the nexo server.
7. Connect notification-open callbacks to the host app router.
## Host Integration Contract
Host apps must keep product navigation and login state outside the plugin while
calling the SDK hooks at stable lifecycle points:
- Startup: initialize Firebase for the host app, then call
`NexoMessagingPlugin.instance.initialize()`.
- Login or session restore: call `setAuthToken(serverUrl, token,
identifier: optionalServerId)` and `setSigningKey(serverUrl, signingKey)` when
those values become available.
- Device token handoff: send values received from `onDeviceTokenReady` or
`getDeviceToken()` to the nexo server; host apps should not parse the token
prefix as app state.
- Routing: subscribe to `onNotificationOpened` and/or set
`onNavigateToChannel` and `onNavigateToThread` to bridge notification opens to
host-owned screens.
- Logout: call `clearAuthToken(serverUrl)` and clear host-owned routing/session
state. Device-token storage and signing-key storage are plugin compatibility
state and are not product UI state.
True FCM delivery, server ACK/reply responses, inline reply, and dismiss action
evidence remain manual smoke checks because they require external Firebase,
server, and device state.
## Installation
For local development or another app in the same workspace, use a path
dependency:
```yaml
dependencies:
nexo_messaging:
path: ../../packages/messaging_flutter
```
For a private Git dependency, point the consuming app at the repository and pin
the branch, tag, or commit according to your release process.
## Dart Usage
Import the public API:
```dart
import 'package:nexo_messaging/nexo_messaging.dart';
```
Initialize the plugin:
```dart
await NexoMessagingPlugin.instance.initialize();
```
The plugin initializes with default options that match the previous behaviour
(subscribes to native events, listens for FCM token refreshes, requests
notification permission, and registers the device token).
To control which initialisation steps run, pass
`NexoMessagingInitializeOptions`:
```dart
await NexoMessagingPlugin.instance.initialize(
options: const NexoMessagingInitializeOptions(
listenForTokenRefresh: true,
requestNotificationPermission: true,
registerDeviceToken: true,
),
);
```
Listen to raw notification events:
```dart
NexoMessagingPlugin.instance.onNotification.listen((data) {
// data includes the native payload plus a push type.
});
```
Listen to notification-open events:
```dart
NexoMessagingPlugin.instance.onNotificationOpened.listen((event) {
final serverUrl = event.serverUrl;
final channelId = event.channelId;
final rootId = event.rootId;
});
```
Register navigation callbacks:
```dart
NexoMessagingPlugin.instance.onNavigateToChannel = (
String serverUrl,
String channelId,
) {
// Navigate to the channel in the host app.
};
NexoMessagingPlugin.instance.onNavigateToThread = (
String serverUrl,
String rootId,
) {
// Navigate to the CRT thread in the host app.
};
```
Handle device-token registration:
```dart
NexoMessagingPlugin.instance.onDeviceTokenReady = (String deviceToken) {
// Android implemented format: android_rn-v2:YOUR_FCM_TOKEN
// iOS preflight server format: apple_rn-v2:YOUR_APNS_TOKEN
// Send this token to the nexo server.
};
```
Store credentials and signing keys:
```dart
await NexoMessagingPlugin.instance.setAuthToken(
'https://nexo.example.com',
'USER_AUTH_TOKEN',
identifier: 'user_session_id',
);
await NexoMessagingPlugin.instance.setSigningKey(
'https://nexo.example.com',
'SERVER_PUBLIC_SIGNING_KEY',
);
await NexoMessagingPlugin.instance.clearAuthToken(
'https://nexo.example.com',
);
final token = await NexoMessagingPlugin.instance.getDeviceToken();
```
### Web Foreground Notifications
On Flutter Web, the plugin can display browser notifications and route clicks
to the same Dart streams as native events:
```dart
// Request permission first.
final status = await NexoMessagingPlugin.instance
.requestWebNotificationPermission();
if (status.isGranted) {
// Display a foreground notification.
final result = NexoMessagingPlugin.instance.showWebForegroundNotification({
'title': 'New message',
'body': 'Hello from Nexo Messaging',
'server_url': 'https://nexo.example.com',
'channel_id': 'channel-abc',
'root_id': 'root-123',
'is_crt_enabled': 'true',
});
// result.success == true means the browser displayed it.
// Click events are automatically routed through onNotificationOpened
// and the onNavigateToChannel / onNavigateToThread callbacks.
}
```
## Token And Credential Flow
The host app owns login, logout, and server registration. The SDK owns local
native storage and native push operations that need those values.
1. The host app configures Firebase, initializes Firebase for its app, and calls
`NexoMessagingPlugin.instance.initialize()` during startup.
2. `initialize()` subscribes to native events, listens for FCM token refreshes,
requests notification permission, and reads the current FCM token.
3. Android currently formats each raw FCM token as
`android_rn-v2:<raw-fcm-token>` before calling the native `saveDeviceToken`
action. `onDeviceTokenReady` receives the formatted token, and
`getDeviceToken()` returns the last formatted token stored by native code.
iOS preflight reserves `apple_rn-v2:<raw-apns-token>` for Mattermost-compatible
APNs push-proxy registration. An iOS FCM registration token is useful for
Firebase direct-send smoke, but it is not the server registration token unless
a future server/push-proxy path explicitly supports FCM-backed iOS delivery.
Web (Full Push) reserves `web_rn-v2:<raw-push-token>` for service-worker
registration; see the boundary note below.
4. The host app sends the formatted device token to the nexo server. Native
signature verification uses the stored device token and compares the raw FCM
token portion with signed push claims.
5. After login, the host app calls `setAuthToken(serverUrl, token,
identifier: optionalServerId)`. Native code stores the HTTP token for
ACK/reply requests and stores the server URL with the optional identifier so
push payloads containing `server_id` can be resolved to a server URL.
6. The host app calls `setSigningKey(serverUrl, signingKey)` when the server
signing key is available. Native verification first checks the per-server DB
config signing key, then falls back to the signing key stored through this
API.
7. On logout, `clearAuthToken(serverUrl)` clears the native HTTP token used for
ACK/reply delivery. It is not a server URL mapping deletion API and does not
clear the stored device token or signing key.
If a push payload omits `server_url`, Android may resolve it from `server_id`
using the identifier saved by `setAuthToken`, or from the only stored server URL
when exactly one server is known. Host apps should still register device tokens
with the server after `onDeviceTokenReady` and should not parse secret values or
raw native storage details from README examples.
## Web Token Boundary
Web support is delivered in two milestones. Understanding the boundary between
them is important for server registration and token-format expectations.
**Browser foreground notification** (the first web milestone) is a display/open
routing API for when the tab is active. It does **not** create a server device-
token registration because the browser already knows how to reach the client
through the open connection.
**Full Web Push** (a future milestone) will introduce service-worker handling,
VAPID keys, and a `core/push-proxy` contract. That milestone will use the
reserved token prefix `web_rn-v2:<raw-push-token>` for server device-token
registration.
`02+01_web_plugin_baseline` has delivered:
- Flutter web plugin registration (`NexoMessagingWeb.registerWith()`).
- `package:web: ^1.1.1` as a dependency.
- A web-only browser Notification interop boundary
(`BrowserNotificationInterop` / `BrowserNotificationInteropImpl`) exposing
`permission` and `requestPermission()`.
- `lib/src/web/` imports are confined to web-only files; common Dart code does
not import `package:web`.
The foreground Epic has delivered:
- Public display / click routing APIs (`showWebForegroundNotification`).
- Click-to-open routing wired through `handleNativeEvent()``onNotificationOpened` / `onNavigateToChannel` / `onNavigateToThread`.
- Browser notification display with permission gating (unsupported/denied/granted).
Foreground browser permission APIs are available on the plugin singleton:
- `getWebNotificationPermissionStatus()` returns support and current browser
permission state.
- `requestWebNotificationPermission()` requests browser notification permission
on Flutter Web and returns the resulting state. On non-web platforms it
returns an unsupported status without touching native permission APIs.
## Public API Contract
Apps should import only `package:nexo_messaging/nexo_messaging.dart`. The
exported Dart surface is the nexo SDK contract; method-channel names, native
payload internals, and token formatting remain compatibility details unless
they are listed here.
| Surface | Status | Contract |
| --- | --- | --- |
| `NexoMessagingPlugin.instance` | Keep | Singleton entry point for host app startup, event subscription, credential storage, and token lookup. |
| `initialize()` / `dispose()` | Keep | Plugin lifecycle hooks. Host apps call `initialize()` during startup and `dispose()` only when intentionally tearing down the SDK instance. |
| `onNotification` | Keep, compatibility | Raw native event stream for advanced diagnostics and compatibility. Host apps should avoid product routing decisions that depend on untyped map fields. |
| `onNotificationOpened` | Keep | Typed stream for notification-open routing through `NotificationOpenedEvent`. |
| `onNavigateToChannel` / `onNavigateToThread` | Keep | Optional host-router callbacks. Screen ownership and product navigation remain outside this package. |
| `onDeviceTokenReady` / `getDeviceToken()` | Keep | Device-token handoff to the host app and server registration flow. The `android_rn-v2:` prefix is a server compatibility detail, not an app parsing contract. |
| `setAuthToken()` / `clearAuthToken()` | Keep | Auth-token persistence bridge for native ACK and reply delivery. |
| `setSigningKey()` | Keep | Server signing-key persistence bridge for native push verification. |
| `NotificationOpenedEvent` | Keep | Stable Dart value object for opened notification routing fields. |
| `PushNotificationType` | Compatibility | Native event string constants. Future typed event wrappers should reduce direct app branching on these constants. |
| `notificationChannelName`, `actionChannelName`, `listenNativeChannelForTesting()`, `debugSendNativeEventForTesting()`, `handleNativeEvent()` | Testing-only | Exposed for repository-owned tests through `@visibleForTesting`; consuming apps should not depend on them. |
| `NexoMessagingInitializeOptions` | New | Options class for `initialize()`. Default values preserve backward-compatible behaviour. |
| `NexoMessagingTokenPrefix` | New | Static constants (`androidV2`, `appleV2`, `webV2`) documenting the token-prefix format for each platform target. |
| `showWebForegroundNotification()` | New | Displays a foreground notification via the browser Notification API. Returns `NexoMessagingWebNotificationDisplayResult`. On non-web or denied platforms it returns unsupported/no-op. |
| `NexoMessagingWebNotificationDisplayResult` | New | Result value object for `showWebForegroundNotification()`. Contains `success`, `isSupported`, and `permissionGranted` fields. |
Deprecated candidates are compatibility-only app dependencies on raw
`onNotification` maps, direct branching on `PushNotificationType`, and parsing
the formatted device-token prefix. Nexo wrapper candidates are typed
notification-event models and a token-registration facade, to be finalized after
the event-shape and token-flow contracts are documented.
## Event Shape Contract
Native events are delivered to Dart as codec-safe maps on `onNotification`.
Apps may log additional native fields for diagnostics, but app behavior should
only depend on the stable fields listed here or on typed SDK objects such as
`NotificationOpenedEvent`.
| Event | Required fields | Optional stable fields | Compatibility |
| --- | --- | --- | --- |
| `opened` | `type` | `server_url`, `channel_id`, `root_id`, `is_crt_enabled`, `userInteraction` | Launch intents with missing native `type` are treated as `opened`; Android adds `userInteraction: true` for intent-opened payloads. |
| `message` | `type` | `server_url`, `server_id`, `channel_id`, `root_id`, `is_crt_enabled`, `ack_id`, `post_id`, `signature`, `id_loaded`, `data`, `userInteraction` | `userInteraction: true` routes through opened handling in Dart. If `server_url` is missing, Android may resolve it from `server_id` or the only stored server URL. |
| `clear` | `type` | `server_url`, `server_id`, `channel_id`, `root_id` | Used for native notification clear or dismiss handling; extra native fields are forwarded as compatibility data only. |
| `session` | `type` | `server_url`, `server_id` | Used for session-related native events; host apps should treat raw fields as compatibility data unless promoted to typed API. |
| `token_refresh` | `type`, `token` | `platform_token_type` | `token` is the raw platform token from native or FlutterFire. Android formats it as `android_rn-v2:<raw-fcm-token>`. iOS preflight reserves `apple_rn-v2:<raw-apns-token>` for server registration and treats FCM registration tokens as Firebase direct-send smoke tokens unless a future server path supports them. |
`NotificationOpenedEvent` parses `server_url`, `channel_id`, `root_id`, and
`is_crt_enabled` from opened-compatible events. The parser treats boolean `true`,
the string `"true"`, and the string `"1"` as CRT-enabled. If CRT is enabled and
`root_id` is present, the plugin calls `onNavigateToThread`; otherwise it calls
`onNavigateToChannel` when `channel_id` and `server_url` are available.
## Event Types
The Dart API currently recognizes these native event types:
| Type | Meaning |
| --- | --- |
| `message` | A nexo message notification |
| `clear` | Notification clear or dismiss event |
| `session` | Session-related event |
| `token_refresh` | Device-token refresh event |
| `opened` | User opened a notification |
| `action_failure` | Permanent native ACK or reply action failure forwarded for host diagnostics |
`NotificationOpenedEvent` parses `server_url`, `channel_id`, `root_id`, and
`is_crt_enabled`. If CRT is enabled and `root_id` is present, the plugin calls
`onNavigateToThread`; otherwise it calls `onNavigateToChannel` when a channel is
available.
## Server And Push Runtime Contract
This package consumes the Mattermost-compatible push runtime documented from the
server side in
[`services/core/compose/README.md`](../../services/core/compose/README.md#sdk-contract-anchors).
The plugin keeps compatibility with those fields, while typed app-facing API
stays in the Dart surface above.
### Push Payload Fields
Server-side source anchor:
`services/core/server/public/model/push_notification.go`.
Plugin-side source anchors:
`android/src/main/kotlin/com/tokilabs/nexo/messaging/NexoFirebaseMessagingService.kt`,
`android/src/main/java/com/tokilabs/nexo/messaging/helpers/CustomPushNotificationHelper.java`,
and `android/src/main/kotlin/com/tokilabs/nexo/messaging/LaunchIntentHelper.kt`.
| Field | Required for | Plugin handling |
| --- | --- | --- |
| `type` | Message, clear, session dispatch | Must match the native `PushNotificationType` constants. Unknown types are forwarded only as raw compatibility events. |
| `ack_id` | ACK and signature validation | Sent back to `/api/v4/notifications/ack` and required by signed push JWT validation when a signature is present. |
| `server_url` | ACK/reply delivery, signature lookup, routing | Preferred server identity. If omitted, Android may resolve it from `server_id` or the single stored server URL. |
| `server_id` | Server URL fallback | Resolved through the identifier stored by `setAuthToken(serverUrl, token, identifier: ...)`. |
| `channel_id` | Notification grouping and channel routing | Used for notification grouping, clear handling, and `onNavigateToChannel`. |
| `root_id` | CRT/thread routing | When CRT is enabled, this groups thread notifications and drives `onNavigateToThread`. |
| `is_crt_enabled` | Thread behavior | Boolean `true`, string `"true"`, or string `"1"` enables thread routing; other values are treated as false. |
| `post_id` | ACK id-loaded response and notification identity | Included in ACK requests and used as the Android notification id source. |
| `signature` | Payload verification | Verified against the server signing key and stored device token. Missing signatures are accepted for backward compatibility. |
| `id_loaded` | ACK response enrichment | The string value `"true"` asks the server to return post data that Android can merge into the notification bundle. |
| `category` | Inline reply affordance | `CAN_REPLY` enables the Android inline reply action when `post_id` and `server_url` are present. |
Validation criteria:
- A signed payload passes only when the JWT contains the expected `ack_id` and
raw device token, and the signing key resolves from the server database or the
`setSigningKey()` fallback.
- Signature verification runs before the ACK. An invalid signature payload is
dropped without being acknowledged and without rendering a notification.
- A payload without `server_url` is valid only when `server_id` or the one-server
fallback resolves to a server URL before ACK, reply, or signature lookup.
- Payload fields not listed here may pass through raw `onNotification` maps for
diagnostics, but consuming apps should not treat them as stable API.
Display criteria:
- A notification groups by `root_id` when CRT is enabled and a `root_id` is
present, otherwise by `channel_id`; a payload without `channel_id` is rendered
ungrouped.
- The first notification in a group is posted as the group summary; later
notifications in the same group are not.
- The `channel_id`, `post_id`, `root_id`, `is_crt_enabled`, and `server_url`
fields are preserved into the notification `userInfo` extras so the opened
event can recover channel and thread identity.
### ACK Contract
Plugin-side source anchor:
`android/src/main/java/com/tokilabs/nexo/messaging/ReceiptDelivery.java`.
Server-side source anchors:
`services/core/server/public/model/push_notification.go` and
`services/core/server/channels/api4/system.go`.
The plugin POSTs ACKs to `/api/v4/notifications/ack` with:
| Request field | Value |
| --- | --- |
| `id` | Push payload `ack_id`. |
| `received_at` | Client receipt timestamp in milliseconds. |
| `platform` | `android`. |
| `type` | Push payload `type`. |
| `post_id` | Push payload `post_id`, when present. |
| `is_id_loaded` | Boolean derived from payload `id_loaded == "true"`. |
The server returns `OK` for ordinary ACKs. When `is_id_loaded` is true and the
ACK references a message post, the server may return message fields such as
`post_id`, `root_id`, `category`, `message`, `team_id`, `channel_id`,
`channel_name`, `type`, `sender_id`, `sender_name`, and `version`; Android merges
those fields into the notification bundle before rendering.
Failure handling criteria:
- Signature verification precedes ACK delivery, so an invalid signature payload
never reaches the ACK request.
- If ACK delivery fails for an already-verified payload because of an offline,
timeout, 408, 429, or 5xx condition, Android stores a local pending action and
retries it opportunistically with a bounded three-attempt limit. Notification
processing continues with the original payload.
- If an id-loaded ACK response cannot be parsed, Android treats it as absent and
continues with the original payload.
- If the server rejects the ACK because push notifications are disabled,
unauthenticated, or the post is unavailable, Android treats it as permanent,
removes any pending action, and forwards an `action_failure` native event for
host diagnostics. This is a runtime/server state failure, not a Dart API
contract change.
### Reply And Dismiss Contract
Plugin-side source anchors:
`android/src/main/java/com/tokilabs/nexo/messaging/NotificationReplyBroadcastReceiver.java`
and
`android/src/main/java/com/tokilabs/nexo/messaging/NotificationDismissService.java`.
Inline replies are posted to `/api/v4/posts?set_online=false` using the server
URL and auth token stored by `setAuthToken()`.
Failure handling criteria:
- A successful 2xx reply updates the Android notification with the sent message.
- Offline, timeout, 408, 429, and 5xx reply failures are stored as local pending
actions and retried opportunistically with the same three-attempt bound as
ACK delivery.
- 401, 403, and other non-retryable 4xx reply failures are permanent. Android
removes the pending action if present and forwards `action_failure` for host
diagnostics.
- Dismiss has no server endpoint in the current nexo contract. The plugin
cancels local notification state only and does not invent a network request or
failure telemetry for a successful local dismiss.
### Reference Front Compatibility
The Mattermost webapp under `services/core/webapp` remains the reference front
for browser messaging features such as posts, replies, post acknowledgements,
channel navigation, and thread state. The Flutter SDK embedded flow must not
replace or fork that webapp behavior inside this repository.
Compatibility criteria:
- Webapp routes, Redux state, and product UI stay upstream-followable in
`services/core/webapp`.
- Flutter consuming apps own their own screens and routers; this plugin emits
opened events and optional channel/thread callbacks only.
- Server compatibility belongs to push payload fields, ACK/reply endpoints,
signing-key verification, and device-token format, not to Mattermost mobile or
webapp UI implementation details.
- If a future Flutter app needs product UI parity with the reference webapp, that
work belongs in the consuming app or a separate roadmap item, not in this
plugin contract milestone.
## Android Notes
The Android manifest declares the plugin's own
`NexoFirebaseMessagingService` and removes the default FlutterFire
messaging components with `tools:node="remove"`. This avoids duplicate FCM
service registration when the host app also depends on `firebase_messaging`.
Host apps should not redeclare duplicate FCM services unless they intentionally
coordinate that behavior with this plugin.
Android Gradle tests require a configured Android SDK through `ANDROID_HOME` or
`apps/flutter-test/android/local.properties`.
## Development Commands
Run Dart analysis and unit tests from the repository root:
```sh
flutter analyze
flutter test
```
Run `flutter-test` checks from the app path:
```sh
cd apps/flutter-test
flutter analyze
flutter test
flutter test integration_test
```
Run Android native unit tests:
```sh
cd apps/flutter-test/android
./gradlew testDebugUnitTest
```
If the local machine does not have an Android SDK or Android device/emulator,
use the separate SSH-based Android test environment documented in
[`docs/android-test-environment.md`](docs/android-test-environment.md).
## macOS Preflight Notes
macOS remains a scaffold, but its preflight contract is fixed before native
implementation work starts. Dart expects the macOS plugin to register
`com.tokilabs.nexo.messaging/notifications` and
`com.tokilabs.nexo.messaging/notification_actions`, preserve the legacy
`nexo_messaging` `getPlatformVersion` compatibility channel, and map
notification `userInfo` fields to `NotificationOpenedEvent`.
Use [`../../docs/macos-notification-test-guide.md`](../../docs/macos-notification-test-guide.md)
to identify the Apple signing, macOS runner, APNs/Firebase scope, and private
handoff values the user must provide before an agent can complete macOS
notification smoke work.
## Windows Preflight Notes
Windows is not registered in `pubspec.yaml` and has no plugin scaffold yet. Do
not add a partial Windows platform entry without a matching implementation plan
that creates the plugin source, test host scaffold, channel registration, and
debug injection path together.
Dart still expects any future Windows bridge to use
`com.tokilabs.nexo.messaging/notifications` and
`com.tokilabs.nexo.messaging/notification_actions`, then map toast activation
or debug injection payloads to `NotificationOpenedEvent`.
Use [`../../docs/windows-notification-test-guide.md`](../../docs/windows-notification-test-guide.md)
to identify the Windows runner, packaging mode, notification API path, package
identity, signing, and private handoff values the user must provide before an
agent can complete Windows notification smoke work.
## Test Strategy
Use deterministic tests for repository-owned behavior. The example integration
test can inject a debug native event through the plugin method channel so the
EventChannel and opened-routing path can run without Firebase FCM.
The Android implementation remains a nexo-owned embedded SDK surface in these
tests. Test coverage should follow the server/push contract, Android and FCM
platform behavior, and Flutter plugin registration rather than the Mattermost
mobile app source tree.
Use the repository tests as the source of truth for plugin behavior:
1. Root `test/` covers Dart API behavior, event parsing, callback routing, and
device-token formatting.
2. `android/src/test/` should cover native payload parsing, signing-key lookup,
local storage, notification behavior, and ACK request behavior.
3. `apps/flutter-test/test/` covers the thin Flutter test app.
4. `apps/flutter-test/integration_test/` covers real plugin registration,
method-channel behavior, event-channel behavior, notification-open routing,
and manifest integration on a device or emulator using the test/debug
injection path.
5. Manual smoke tests cover true Firebase FCM delivery and real nexo server ACK
flows because they depend on external infrastructure.
## Manual Smoke Checklist
Use this checklist before promoting a plugin change to a consuming app. These
checks require external Firebase and nexo server infrastructure, so they are not
expected to pass in a headless repository test run.
Prerequisites:
- Firebase is configured for the host app with `android/app/google-services.json`.
- The host app has called `NexoMessagingPlugin.instance.initialize()`.
- The test user has a server URL, auth token, and signing key saved through the
plugin API.
- The nexo server can send a push payload with `ack_id`, `server_url` or
resolvable `server_id`, `channel_id`, and a valid signature.
Evidence to capture:
- `logcat` lines from `NexoFirebaseMessagingService` for receipt, ACK, and
signature handling.
- A screenshot or device recording of the displayed notification.
- Server-side ACK/reply request logs showing response status.
- Host app UI evidence for `onNotificationOpened`, `onNavigateToChannel`, and
`onNavigateToThread` routing.
- [ ] FCM payload wakes `NexoFirebaseMessagingService`.
- [ ] Valid nexo push payload passes signing-key verification.
- [ ] Invalid signature payload is dropped before ACK (no ACK request is sent and
no notification is shown).
- [ ] Unsigned payload is accepted for backward compatibility.
- [ ] First notification in a channel/thread group shows the group summary and
later notifications in the same group do not.
- [ ] System notification displays the expected title, message, avatar, and CRT
threading behavior.
- [ ] ACK request is sent to the nexo server and receives a successful
response.
- [ ] Tapping a notification launches or resumes the app and emits
`onNotificationOpened`.
- [ ] Channel notifications call `onNavigateToChannel`.
- [ ] CRT thread notifications call `onNavigateToThread`.
- [ ] Inline reply action sends the reply through the native receiver.
- [ ] Dismiss action clears native notification records as expected.
- [ ] Device token is stored with the `android_rn-v2:` prefix.
## Current Limitations
- Android is the only implemented runtime target.
- iOS and macOS are scaffolded but no-op.
- Web baseline (`NexoMessagingWeb.registerWith()`, `BrowserNotificationInterop`,
`package:web` dependency) is implemented, and the foreground display API
(`showWebForegroundNotification`) with click routing is implemented.
- Notification events are forwarded through the main plugin stream.
- Click events are wired to `handleNativeEvent()``onNotificationOpened` / `onNavigateToChannel` / `onNavigateToThread`. The underlying `BrowserNotificationInteropImpl` now attaches a native `click` event listener on the `web.Notification` instance, so actual browser notification click events trigger the full routing pipeline (not just fake tests anymore).
- Full Web Push (service worker, VAPID, server device-token registration) is
not implemented.
- Full FCM delivery cannot be reliably automated in a headless test runner.
- The `apps/flutter-test/` app should stay focused on package integration behavior.