update mattermost push plugin implementation and examples

This commit is contained in:
toki 2026-05-25 21:16:56 +09:00
parent c13bb40b68
commit 557f2e1fc1
8 changed files with 257 additions and 14 deletions

View file

@ -215,6 +215,10 @@ cd example/android
## 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.
Use the repository tests as the source of truth for plugin behavior:
1. Root `test/` covers Dart API behavior, event parsing, callback routing, and
@ -222,9 +226,9 @@ Use the repository tests as the source of truth for plugin behavior:
2. `android/src/test/` should cover native payload parsing, signing-key lookup,
local storage, notification behavior, and ACK request behavior.
3. `example/test/` covers the thin Flutter harness app.
4. `example/integration_test/` should cover real plugin registration,
4. `example/integration_test/` covers real plugin registration,
method-channel behavior, event-channel behavior, notification-open routing,
and manifest integration on a device or emulator.
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 Mattermost ACK
flows because they depend on external infrastructure.

View file

@ -136,6 +136,18 @@ class MattermostPushPlugin :
override fun onMethodCall(call: MethodCall, result: Result) {
val context = applicationContext
when (call.method) {
"debugSendNativeEvent" -> {
val arguments = call.arguments as? Map<*, *>
if (arguments != null) {
val payload = arguments.entries
.filter { it.key is String }
.associate { it.key as String to it.value }
PushNotificationEvents.send(payload)
result.success(null)
} else {
result.error("INVALID_ARG", "event payload is null", null)
}
}
"saveDeviceToken" -> {
val token = call.argument<String>("token")
if (token != null) {

View file

@ -50,6 +50,10 @@ Firebase configuration, including `android/app/google-services.json`.
## Verification
The integration test uses the plugin's test/debug event injection path for
method-channel, event-channel, and opened-routing checks. Real FCM delivery,
ACK, inline reply, and dismiss behavior remain manual smoke checks.
Run checks from this directory:
```sh

View file

@ -1,14 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:mattermost_push_plugin/mattermost_push_plugin.dart';
import 'package:mattermost_push_plugin_example/main.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('singleton instance is accessible', (WidgetTester tester) async {
expect(MattermostPushPlugin.instance, isNotNull);
expect(MattermostPushPlugin.notificationChannelName,
'com.tokilabs.mattermost/notifications');
expect(
MattermostPushPlugin.notificationChannelName,
'com.tokilabs.mattermost/notifications',
);
});
testWidgets('native debug event reaches harness routing UI (channel)', (
WidgetTester tester,
) async {
await tester.pumpWidget(const MyApp());
MattermostPushPlugin.instance.listenNativeChannelForTesting();
await MattermostPushPlugin.instance.debugSendNativeEventForTesting(const {
'type': PushNotificationType.opened,
'server_url': 'https://mm.example.com',
'channel_id': 'channel-integration',
});
await tester.pumpAndSettle();
expect(find.textContaining('Last notification:'), findsOneWidget);
expect(find.textContaining('channel-integration'), findsWidgets);
expect(
find.text(
'Last navigation: channel:https://mm.example.com/channel-integration',
),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('native debug event reaches harness routing UI (thread/CRT)', (
WidgetTester tester,
) async {
await tester.pumpWidget(const MyApp());
MattermostPushPlugin.instance.listenNativeChannelForTesting();
await MattermostPushPlugin.instance.debugSendNativeEventForTesting(const {
'type': PushNotificationType.opened,
'server_url': 'https://mm.example.com',
'channel_id': 'channel-integration',
'root_id': 'root-integration',
'is_crt_enabled': 'true',
});
await tester.pumpAndSettle();
expect(find.textContaining('Last notification:'), findsOneWidget);
expect(find.textContaining('root-integration'), findsWidgets);
expect(
find.text(
'Last navigation: thread:https://mm.example.com/root-integration',
),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
});
}

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:mattermost_push_plugin/mattermost_push_plugin.dart';
@ -14,14 +16,55 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> {
String? _deviceToken;
String? _lastNotification;
String? _lastOpened;
String? _lastNavigation;
StreamSubscription<Map<String, dynamic>>? _notificationSubscription;
StreamSubscription<NotificationOpenedEvent>? _openedSubscription;
@override
void initState() {
super.initState();
MattermostPushPlugin.instance.onDeviceTokenReady = (token) {
final plugin = MattermostPushPlugin.instance;
plugin.onDeviceTokenReady = (token) {
if (!mounted) return;
setState(() => _deviceToken = token);
};
plugin.onNavigateToChannel = (serverUrl, channelId) {
if (!mounted) return;
setState(() => _lastNavigation = 'channel:$serverUrl/$channelId');
};
plugin.onNavigateToThread = (serverUrl, rootId) {
if (!mounted) return;
setState(() => _lastNavigation = 'thread:$serverUrl/$rootId');
};
_notificationSubscription = plugin.onNotification.listen((event) {
if (!mounted) return;
setState(() => _lastNotification = _formatMap(event));
});
_openedSubscription = plugin.onNotificationOpened.listen((event) {
if (!mounted) return;
setState(() => _lastOpened = _formatOpened(event));
});
}
@override
void dispose() {
_notificationSubscription?.cancel();
_openedSubscription?.cancel();
final plugin = MattermostPushPlugin.instance;
plugin.onDeviceTokenReady = null;
plugin.onNavigateToChannel = null;
plugin.onNavigateToThread = null;
super.dispose();
}
String _formatMap(Map<String, dynamic> map) {
return map.entries.map((e) => '${e.key}: ${e.value}').join(', ');
}
String _formatOpened(NotificationOpenedEvent event) {
return 'server_url: ${event.serverUrl}, channel_id: ${event.channelId}, root_id: ${event.rootId}, is_crt_enabled: ${event.isCRTEnabled}';
}
@override
@ -29,8 +72,14 @@ class _MyAppState extends State<MyApp> {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Mattermost push plugin example')),
body: Center(
child: Text('Device token: ${_deviceToken ?? 'pending'}'),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Device token: ${_deviceToken ?? 'pending'}'),
Text('Last notification: ${_lastNotification ?? 'none'}'),
Text('Last opened: ${_lastOpened ?? 'none'}'),
Text('Last navigation: ${_lastNavigation ?? 'none'}'),
],
),
),
);

View file

@ -1,19 +1,100 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mattermost_push_plugin/mattermost_push_plugin.dart';
import 'package:mattermost_push_plugin_example/main.dart';
void main() {
testWidgets('renders pending device token placeholder',
(WidgetTester tester) async {
testWidgets('renders initial harness state', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('Device token: pending'), findsOneWidget);
expect(find.text('Last notification: none'), findsOneWidget);
expect(find.text('Last opened: none'), findsOneWidget);
expect(find.text('Last navigation: none'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('updates opened and navigation state from plugin events (channel)', (
WidgetTester tester,
) async {
await tester.pumpWidget(const MyApp());
MattermostPushPlugin.instance.handleNativeEvent(const {
'type': PushNotificationType.opened,
'server_url': 'https://mm.example.com',
'channel_id': 'channel-123',
'is_crt_enabled': 'false',
});
await tester.pump();
expect(
find.byWidgetPredicate(
(Widget widget) =>
widget is Text && (widget.data?.startsWith('Device token:') ?? false),
find.text(
'Last notification: type: opened, server_url: https://mm.example.com, channel_id: channel-123, is_crt_enabled: false',
),
findsOneWidget,
);
expect(
find.text(
'Last opened: server_url: https://mm.example.com, channel_id: channel-123, root_id: null, is_crt_enabled: false',
),
findsOneWidget,
);
expect(
find.text('Last navigation: channel:https://mm.example.com/channel-123'),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets(
'updates opened and navigation state from plugin events (thread/CRT)',
(WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
MattermostPushPlugin.instance.handleNativeEvent(const {
'type': PushNotificationType.opened,
'server_url': 'https://mm.example.com',
'channel_id': 'channel-123',
'root_id': 'thread-456',
'is_crt_enabled': 'true',
});
await tester.pump();
expect(
find.text(
'Last notification: type: opened, server_url: https://mm.example.com, channel_id: channel-123, root_id: thread-456, is_crt_enabled: true',
),
findsOneWidget,
);
expect(
find.text(
'Last opened: server_url: https://mm.example.com, channel_id: channel-123, root_id: thread-456, is_crt_enabled: true',
),
findsOneWidget,
);
expect(
find.text('Last navigation: thread:https://mm.example.com/thread-456'),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
},
);
testWidgets('updates device token from plugin callback', (
WidgetTester tester,
) async {
await tester.pumpWidget(const MyApp());
MattermostPushPlugin.instance.onDeviceTokenReady?.call(
'test-device-token-123',
);
await tester.pump();
expect(find.text('Device token: test-device-token-123'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
}

View file

@ -58,6 +58,7 @@ class MattermostPushPlugin {
}
void _listenNativeChannel() {
if (_channelSubscription != null) return;
_channelSubscription = _notificationChannel.receiveBroadcastStream().listen(
(dynamic event) {
if (event is Map) {
@ -69,6 +70,16 @@ class MattermostPushPlugin {
);
}
@visibleForTesting
void listenNativeChannelForTesting() {
_listenNativeChannel();
}
@visibleForTesting
Future<void> debugSendNativeEventForTesting(Map<String, dynamic> data) async {
await _actionChannel.invokeMethod('debugSendNativeEvent', data);
}
void _listenFcmTokenRefresh() {
FirebaseMessaging.instance.onTokenRefresh.listen((token) async {
await _saveDeviceToken(token);

View file

@ -162,6 +162,31 @@ void main() {
expect(callbackToken, 'android_rn-v2:raw-token');
},
);
test(
'debugSendNativeEventForTesting invokes native debug method',
() async {
MethodCall? capturedCall;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(actionChannel, (call) async {
capturedCall = call;
return null;
});
await plugin.debugSendNativeEventForTesting({
'type': PushNotificationType.opened,
'server_url': 'https://mm.example.com',
'channel_id': 'channel-debug',
});
expect(capturedCall?.method, 'debugSendNativeEvent');
expect(capturedCall?.arguments, {
'type': PushNotificationType.opened,
'server_url': 'https://mm.example.com',
'channel_id': 'channel-debug',
});
},
);
});
group('Channel constants', () {