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
This commit is contained in:
parent
908a4ecc18
commit
68ccfb13fd
3 changed files with 267 additions and 13 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue