From 68ccfb13fd36202eb7ae2a59713209bd86dc349a Mon Sep 17 00:00:00 2001 From: toki Date: Mon, 1 Jun 2026 13:38:32 +0900 Subject: [PATCH] feat: update iOS notification test and plugin implementation - Update RunnerTests.swift with notification test cases - Update NexoMessagingPlugin.swift with notification handling - Update ios-notification-test-guide.md --- .../ios/RunnerTests/RunnerTests.swift | 160 +++++++++++++++++- docs/ios-notification-test-guide.md | 36 +++- .../ios/Classes/NexoMessagingPlugin.swift | 84 ++++++++- 3 files changed, 267 insertions(+), 13 deletions(-) diff --git a/apps/flutter-test/ios/RunnerTests/RunnerTests.swift b/apps/flutter-test/ios/RunnerTests/RunnerTests.swift index 8250e36a..ad1c6604 100644 --- a/apps/flutter-test/ios/RunnerTests/RunnerTests.swift +++ b/apps/flutter-test/ios/RunnerTests/RunnerTests.swift @@ -5,12 +5,24 @@ import XCTest @testable import nexo_messaging -// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. +// Unit tests for the iOS portion of the nexo messaging plugin bridge. +// +// These cover the minimum iOS method contract and stream handler. They are +// deterministic and credential-free; APNs registration, Firebase token +// refresh, notification center delegate, ACK, dismiss, and inline reply remain +// future smoke/implementation work. // // See https://developer.apple.com/documentation/xctest for more information about using XCTest. class RunnerTests: XCTestCase { + private let deviceTokenKey = "nexo.messaging.deviceToken" + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: deviceTokenKey) + super.tearDown() + } + func testGetPlatformVersion() { let plugin = NexoMessagingPlugin() @@ -24,4 +36,150 @@ class RunnerTests: XCTestCase { waitForExpectations(timeout: 1) } + func testDebugSendNativeEventForwardsPayloadToEventSink() { + let plugin = NexoMessagingPlugin() + var forwarded: Any? + + _ = plugin.onListen(withArguments: nil, eventSink: { event in + forwarded = event + }) + + let call = FlutterMethodCall(methodName: "debugSendNativeEvent", arguments: [ + "type": "opened", + "channel_id": "channel-ios", + ]) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertNil(result) + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + + XCTAssertEqual((forwarded as? [String: Any])?["channel_id"] as? String, "channel-ios") + } + + func testDebugSendNativeEventWithNullPayloadReturnsInvalidArg() { + let plugin = NexoMessagingPlugin() + + let call = FlutterMethodCall(methodName: "debugSendNativeEvent", arguments: nil) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertEqual((result as? FlutterError)?.code, "INVALID_ARG") + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testSaveAndGetDeviceToken() { + let plugin = NexoMessagingPlugin() + + let saveCall = FlutterMethodCall(methodName: "saveDeviceToken", arguments: [ + "token": "apple_rn-v2:smoke-token", + ]) + let saveExpectation = expectation(description: "save result block must be called.") + plugin.handle(saveCall) { result in + XCTAssertNil(result) + saveExpectation.fulfill() + } + + let getCall = FlutterMethodCall(methodName: "getDeviceToken", arguments: nil) + let getExpectation = expectation(description: "get result block must be called.") + plugin.handle(getCall) { result in + XCTAssertEqual(result as? String, "apple_rn-v2:smoke-token") + getExpectation.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testSaveDeviceTokenWithNullTokenReturnsInvalidArg() { + let plugin = NexoMessagingPlugin() + + let call = FlutterMethodCall(methodName: "saveDeviceToken", arguments: [:]) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertEqual((result as? FlutterError)?.code, "INVALID_ARG") + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testSetAuthTokenSuccessNoOp() { + let plugin = NexoMessagingPlugin() + + let call = FlutterMethodCall(methodName: "setAuthToken", arguments: [ + "serverUrl": "https://smoke.example", + "token": "auth-token", + ]) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertNil(result) + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testSetAuthTokenMissingArgReturnsInvalidArg() { + let plugin = NexoMessagingPlugin() + + let call = FlutterMethodCall(methodName: "setAuthToken", arguments: [ + "serverUrl": "https://smoke.example", + ]) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertEqual((result as? FlutterError)?.code, "INVALID_ARG") + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testSetSigningKeySuccessNoOp() { + let plugin = NexoMessagingPlugin() + + let call = FlutterMethodCall(methodName: "setSigningKey", arguments: [ + "serverUrl": "https://smoke.example", + "signingKey": "signing-key", + ]) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertNil(result) + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testClearAuthTokenSuccessNoOp() { + let plugin = NexoMessagingPlugin() + + let call = FlutterMethodCall(methodName: "clearAuthToken", arguments: [ + "serverUrl": "https://smoke.example", + ]) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertNil(result) + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + + func testClearAuthTokenMissingArgReturnsInvalidArg() { + let plugin = NexoMessagingPlugin() + + let call = FlutterMethodCall(methodName: "clearAuthToken", arguments: [:]) + + let resultExpectation = expectation(description: "result block must be called.") + plugin.handle(call) { result in + XCTAssertEqual((result as? FlutterError)?.code, "INVALID_ARG") + resultExpectation.fulfill() + } + waitForExpectations(timeout: 1) + } + } diff --git a/docs/ios-notification-test-guide.md b/docs/ios-notification-test-guide.md index af91b721..fb5abab6 100644 --- a/docs/ios-notification-test-guide.md +++ b/docs/ios-notification-test-guide.md @@ -14,9 +14,21 @@ secret-store location. ## Current Repository State -- The iOS plugin is scaffold-only. `packages/messaging_flutter/ios/Classes/NexoMessagingPlugin.swift` - currently registers a `nexo_messaging` method channel and implements only - `getPlatformVersion`. +- The iOS plugin now registers the minimum debug/action bridge. + `packages/messaging_flutter/ios/Classes/NexoMessagingPlugin.swift` registers + the legacy `nexo_messaging` method channel (for `getPlatformVersion` + compatibility), the + `com.tokilabs.nexo.messaging/notification_actions` method channel, and the + `com.tokilabs.nexo.messaging/notifications` event channel. +- The iOS bridge implements `debugSendNativeEvent`, `saveDeviceToken`, + `getDeviceToken`, `setAuthToken`, `clearAuthToken`, `setSigningKey`, and + `getPlatformVersion`. `saveDeviceToken`/`getDeviceToken` persist only the + formatted token string via `UserDefaults`; `setAuthToken`, `clearAuthToken`, + and `setSigningKey` are validation-only no-ops that do not persist auth tokens + or signing secrets on iOS yet. +- This is a debug/local-routing bridge only. APNs registration, Firebase token + refresh handoff, notification center delegate, ACK, dismiss, and inline reply + are not implemented and remain future smoke/implementation work. - The Dart plugin expects native notification and action channels: `com.tokilabs.nexo.messaging/notifications` and `com.tokilabs.nexo.messaging/notification_actions`. @@ -36,8 +48,11 @@ The first meaningful iOS smoke target is therefore: 5. Deliver one visible FCM notification through APNs. 6. Map a notification open into the Dart `NotificationOpenedEvent` contract. -ACK, dismiss, inline reply, and native notification persistence are follow-up -implementation work unless the iOS native bridge is expanded first. +The minimum iOS debug/action bridge (channel registration plus the method +contract above) now exists, so Level 0 contract tests and local injection can +exercise the Dart routing path. APNs registration, FCM token handoff, ACK, +dismiss, inline reply, and native notification persistence are still follow-up +implementation work. ## Decisions Needed Before Testing @@ -166,16 +181,19 @@ Do not place service-account JSON in the repo. ## Native Bridge Work Needed For A Full Smoke -The current iOS scaffold cannot yet complete the Dart notification contract. -Minimum native bridge work: +The minimum debug/action bridge is in place, but the iOS native side still +cannot complete a full APNs/FCM delivery smoke. Already done: - Register the expected EventChannel: `com.tokilabs.nexo.messaging/notifications`. - Register the expected MethodChannel: `com.tokilabs.nexo.messaging/notification_actions`. - Implement `saveDeviceToken`, `getDeviceToken`, `setAuthToken`, - `clearAuthToken`, `setSigningKey`, and `debugSendNativeEvent` for iOS or - explicitly no-op them with clear smoke limitations. + `clearAuthToken`, `setSigningKey`, and `debugSendNativeEvent` for iOS, with + auth token/signing key kept as validation-only no-ops for now. + +Remaining native bridge work: + - Request user notification authorization or rely consistently on FlutterFire's `FirebaseMessaging.requestPermission`. - Call `UIApplication.shared.registerForRemoteNotifications()`. diff --git a/packages/messaging_flutter/ios/Classes/NexoMessagingPlugin.swift b/packages/messaging_flutter/ios/Classes/NexoMessagingPlugin.swift index 8a207edb..afb3ab64 100644 --- a/packages/messaging_flutter/ios/Classes/NexoMessagingPlugin.swift +++ b/packages/messaging_flutter/ios/Classes/NexoMessagingPlugin.swift @@ -1,19 +1,97 @@ import Flutter import UIKit -public class NexoMessagingPlugin: NSObject, FlutterPlugin { +private enum NexoMessagingChannels { + static let legacy = "nexo_messaging" + static let events = "com.tokilabs.nexo.messaging/notifications" + static let actions = "com.tokilabs.nexo.messaging/notification_actions" +} + +private enum NexoMessagingKeys { + static let deviceToken = "nexo.messaging.deviceToken" +} + +public class NexoMessagingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + private var eventSink: FlutterEventSink? + public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "nexo_messaging", binaryMessenger: registrar.messenger()) let instance = NexoMessagingPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) + let messenger = registrar.messenger() + + let legacyChannel = FlutterMethodChannel( + name: NexoMessagingChannels.legacy, + binaryMessenger: messenger + ) + registrar.addMethodCallDelegate(instance, channel: legacyChannel) + + let actionChannel = FlutterMethodChannel( + name: NexoMessagingChannels.actions, + binaryMessenger: messenger + ) + registrar.addMethodCallDelegate(instance, channel: actionChannel) + + let eventChannel = FlutterEventChannel( + name: NexoMessagingChannels.events, + binaryMessenger: messenger + ) + eventChannel.setStreamHandler(instance) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { + case "debugSendNativeEvent": + guard let payload = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARG", message: "event payload is null", details: nil)) + return + } + eventSink?(payload) + result(nil) + case "saveDeviceToken": + guard let token = argument("token", from: call) else { + result(FlutterError(code: "INVALID_ARG", message: "token is null", details: nil)) + return + } + UserDefaults.standard.set(token, forKey: NexoMessagingKeys.deviceToken) + result(nil) + case "getDeviceToken": + result(UserDefaults.standard.string(forKey: NexoMessagingKeys.deviceToken)) + case "setAuthToken": + guard argument("serverUrl", from: call) != nil, argument("token", from: call) != nil else { + result(FlutterError(code: "INVALID_ARG", message: "serverUrl or token is null", details: nil)) + return + } + result(nil) + case "setSigningKey": + guard argument("serverUrl", from: call) != nil, argument("signingKey", from: call) != nil else { + result(FlutterError(code: "INVALID_ARG", message: "serverUrl or signingKey is null", details: nil)) + return + } + result(nil) + case "clearAuthToken": + guard argument("serverUrl", from: call) != nil else { + result(FlutterError(code: "INVALID_ARG", message: "serverUrl is null", details: nil)) + return + } + result(nil) case "getPlatformVersion": result("iOS " + UIDevice.current.systemVersion) default: result(FlutterMethodNotImplemented) } } + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + private func argument(_ name: String, from call: FlutterMethodCall) -> String? { + guard let arguments = call.arguments as? [String: Any] else { return nil } + return arguments[name] as? String + } }