From 3249e4b4e5e90dda8bdf17875ee5b435e5bcdac4 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Thu, 30 Apr 2026 19:23:22 +0800 Subject: [PATCH] feat(ios): align with Android SDK - STOMP host header, loginWithDemo, Push/Update SDK, add editedAt/fromId, fix MESSAGE frame handling --- Sources/XuqmSDK/IM/ImClient.swift | 22 ++++-- Sources/XuqmSDK/IM/ImSDK.swift | 31 ++++++++- Sources/XuqmSDK/IM/ImTypes.swift | 4 ++ Sources/XuqmSDK/Push/PushSDK.swift | 44 +++++++++++- Sources/XuqmSDK/Update/UpdateSDK.swift | 5 -- Tests/XuqmSDKTests/SmokeTests.swift | 96 +++++++++++++++++++++++++- 6 files changed, 187 insertions(+), 15 deletions(-) diff --git a/Sources/XuqmSDK/IM/ImClient.swift b/Sources/XuqmSDK/IM/ImClient.swift index 7940fe7..956f414 100644 --- a/Sources/XuqmSDK/IM/ImClient.swift +++ b/Sources/XuqmSDK/IM/ImClient.swift @@ -75,6 +75,7 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S id: messageId, appId: activeAppId ?? "", fromUserId: activeUserId ?? "", + fromId: activeUserId, toId: toId, chatType: chatType, msgType: msgType, @@ -83,7 +84,8 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S mentionedUserIds: mentionedUserIds?.isEmpty == false ? mentionedUserIds : nil, groupReadCount: nil, revoked: false, - createdAt: now + createdAt: now, + editedAt: nil ) guard let activeAppId else { delegate?.imClientDidError("IM appId not configured") @@ -198,8 +200,12 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S let msg = try? JSONDecoder().decode(ImMessage.self, from: messageData) else { continue } + if msg.status == .read { + delegate?.imClientDidReadMessage(msg) + } if (msg.revoked ?? false) || msg.status == .revoked || msg.msgType == .revoked { delegate?.imClientDidReceiveRevokedMessage(msg) + break } if msg.chatType == .group { delegate?.imClientDidReceiveGroupMessage(msg) @@ -272,11 +278,15 @@ public final class ImClient: NSObject, URLSessionWebSocketDelegate, @unchecked S return } activeToken = token - sendFrame(command: "CONNECT", headers: [ + var headers: [String: String] = [ "accept-version": "1.2", "Authorization": "Bearer \(token)", - "heart-beat": "10000,10000", - ]) + "heart-beat": "0,0", + ] + if let host = activeWsURL?.host { + headers["host"] = host + } + sendFrame(command: "CONNECT", headers: headers) } public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, @@ -293,6 +303,7 @@ private extension ImMessage { id: id, appId: appId, fromUserId: fromUserId, + fromId: fromId, toId: toId, chatType: chatType, msgType: msgType, @@ -301,7 +312,8 @@ private extension ImMessage { mentionedUserIds: mentionedUserIds, groupReadCount: groupReadCount, revoked: false, - createdAt: createdAt + createdAt: createdAt, + editedAt: editedAt ) } } diff --git a/Sources/XuqmSDK/IM/ImSDK.swift b/Sources/XuqmSDK/IM/ImSDK.swift index 4d0d51d..d2f5063 100644 --- a/Sources/XuqmSDK/IM/ImSDK.swift +++ b/Sources/XuqmSDK/IM/ImSDK.swift @@ -34,6 +34,33 @@ public final class ImSDK { client?.connect() } + public func loginWithDemo(userId: String, password: String = "123456") async throws { + let config = XuqmSDK.shared.requireConfig() + struct DemoLoginResponse: Decodable, Sendable { + let profile: DemoProfile + let imToken: String + struct DemoProfile: Decodable, Sendable { + let appId: String + let userId: String + let nickname: String? + let avatar: String? + } + } + let res: DemoLoginResponse = try await ApiClient.shared.request( + path: "/api/demo/auth/login", + method: "POST", + queryItems: [URLQueryItem(name: "appId", value: config.appId)], + body: ["appId": config.appId, "userId": userId, "password": password] + ) + currentUserId = res.profile.userId + XuqmSDK.shared.tokenStore?.save(res.imToken) + client?.disconnect() + client = ImClient(token: res.imToken, appId: config.appId) + client?.setCurrentUserId(res.profile.userId) + client?.delegate = delegate + client?.connect() + } + public func setDelegate(_ delegate: ImEventDelegate) { self.delegate = delegate client?.delegate = delegate @@ -282,6 +309,7 @@ public final class ImSDK { id: UUID().uuidString, appId: XuqmSDK.shared.requireConfig().appId, fromUserId: currentUserId ?? "", + fromId: currentUserId, toId: toId, chatType: chatType, msgType: msgType, @@ -290,7 +318,8 @@ public final class ImSDK { mentionedUserIds: mentionedUserIds, groupReadCount: nil, revoked: false, - createdAt: Int64(Date().timeIntervalSince1970 * 1000) + createdAt: Int64(Date().timeIntervalSince1970 * 1000), + editedAt: nil ) } diff --git a/Sources/XuqmSDK/IM/ImTypes.swift b/Sources/XuqmSDK/IM/ImTypes.swift index dc5ddcd..5a7e847 100644 --- a/Sources/XuqmSDK/IM/ImTypes.swift +++ b/Sources/XuqmSDK/IM/ImTypes.swift @@ -36,6 +36,7 @@ public struct ImMessage: Codable, Sendable { public let id: String public let appId: String public let fromUserId: String + public let fromId: String? public let toId: String public let chatType: ChatType public let msgType: MsgType @@ -45,6 +46,7 @@ public struct ImMessage: Codable, Sendable { public let groupReadCount: Int? public let revoked: Bool? public let createdAt: Int64 + public let editedAt: Int64? } public struct PageResult: Decodable, Sendable { @@ -64,11 +66,13 @@ public protocol ImEventDelegate: AnyObject { func imClientDidDisconnect(reason: String?) func imClientDidReceiveMessage(_ message: ImMessage) func imClientDidReceiveGroupMessage(_ message: ImMessage) + func imClientDidReadMessage(_ message: ImMessage) func imClientDidReceiveRevokedMessage(_ message: ImMessage) func imClientDidError(_ error: String) } public extension ImEventDelegate { + func imClientDidReadMessage(_ message: ImMessage) {} func imClientDidReceiveRevokedMessage(_ message: ImMessage) {} } diff --git a/Sources/XuqmSDK/Push/PushSDK.swift b/Sources/XuqmSDK/Push/PushSDK.swift index a290174..24e1b3a 100644 --- a/Sources/XuqmSDK/Push/PushSDK.swift +++ b/Sources/XuqmSDK/Push/PushSDK.swift @@ -1,4 +1,8 @@ import Foundation +import UserNotifications +#if canImport(UIKit) +import UIKit +#endif public enum PushVendor: String { case apns = "APNS" @@ -6,10 +10,30 @@ public enum PushVendor: String { } @MainActor -public final class PushSDK { +public final class PushSDK: NSObject, UNUserNotificationCenterDelegate { public static let shared = PushSDK() - private init() {} + private override init() { + super.init() + } + + @MainActor + public func requestAuthorization(options: UNAuthorizationOptions = [.alert, .badge, .sound]) async throws -> Bool { + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: options) + #if canImport(UIKit) + if granted { + await MainActor.run { + UIApplication.shared.registerForRemoteNotifications() + } + } + #endif + return granted + } + + public func registerDeviceToken(_ deviceToken: Data, userId: String) async throws { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + try await registerToken(token, userId: userId) + } public func registerToken(_ token: String, userId: String, vendor: PushVendor = .apns) async throws { let config = XuqmSDK.shared.requireConfig() @@ -24,4 +48,20 @@ public final class PushSDK { ] ) } + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound, .badge]) + } + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + completionHandler() + } } diff --git a/Sources/XuqmSDK/Update/UpdateSDK.swift b/Sources/XuqmSDK/Update/UpdateSDK.swift index eff1daa..8b1e822 100644 --- a/Sources/XuqmSDK/Update/UpdateSDK.swift +++ b/Sources/XuqmSDK/Update/UpdateSDK.swift @@ -1,9 +1,6 @@ import Foundation - #if canImport(UIKit) import UIKit -#elseif canImport(AppKit) -import AppKit #endif public struct AppUpdateInfo: Decodable, Sendable { @@ -37,8 +34,6 @@ public final class UpdateSDK { guard let storeURL = URL(string: url) else { return } #if canImport(UIKit) UIApplication.shared.open(storeURL) - #elseif canImport(AppKit) - NSWorkspace.shared.open(storeURL) #endif } } diff --git a/Tests/XuqmSDKTests/SmokeTests.swift b/Tests/XuqmSDKTests/SmokeTests.swift index e240c57..790ab97 100644 --- a/Tests/XuqmSDKTests/SmokeTests.swift +++ b/Tests/XuqmSDKTests/SmokeTests.swift @@ -2,7 +2,99 @@ import XCTest @testable import XuqmSDK final class SmokeTests: XCTestCase { - func testPlaceholder() { - XCTAssertTrue(true) + + func testSDKConfig() { + let config = SDKConfig(appKey: "ak_test", appSecret: "as_test") + XCTAssertEqual(config.appKey, "ak_test") + XCTAssertEqual(config.appSecret, "as_test") + XCTAssertEqual(config.appId, "ak_test") + } + + func testTokenStoreSaveGetClear() { + let store = TokenStore() + store.save("test_token_123") + XCTAssertEqual(store.get(), "test_token_123") + store.clear() + XCTAssertNil(store.get()) + } + + func testImMessageCoding() throws { + let msg = ImMessage( + id: "msg_1", + appId: "ak_test", + fromUserId: "user_a", + fromId: "user_a", + toId: "user_b", + chatType: .single, + msgType: .text, + content: "Hello", + status: .sent, + mentionedUserIds: nil, + groupReadCount: nil, + revoked: false, + createdAt: 1714300000000, + editedAt: nil + ) + let data = try JSONEncoder().encode(msg) + let decoded = try JSONDecoder().decode(ImMessage.self, from: data) + XCTAssertEqual(decoded.id, "msg_1") + XCTAssertEqual(decoded.content, "Hello") + XCTAssertEqual(decoded.status, .sent) + } + + func testChatTypeAndMsgTypeRawValues() { + XCTAssertEqual(ChatType.single.rawValue, "SINGLE") + XCTAssertEqual(ChatType.group.rawValue, "GROUP") + XCTAssertEqual(MsgType.text.rawValue, "TEXT") + XCTAssertEqual(MsgType.revoked.rawValue, "REVOKED") + } + + func testPageResultDecoding() throws { + let json = """ + { + "content": [{"id": "1"}], + "totalElements": 10, + "totalPages": 1, + "size": 20, + "number": 0, + "numberOfElements": 1, + "first": true, + "last": true, + "empty": false + } + """.data(using: .utf8)! + struct Item: Codable, Sendable { let id: String } + let result = try JSONDecoder().decode(PageResult.self, from: json) + XCTAssertEqual(result.content.count, 1) + XCTAssertEqual(result.totalElements, 10) + XCTAssertTrue(result.first) + } + + func testApiResponseDecoding() throws { + let json = """ + { + "code": 200, + "status": "0", + "data": {"token": "abc123"}, + "message": "success" + } + """.data(using: .utf8)! + struct TokenData: Decodable { let token: String } + let response = try JSONDecoder().decode(ApiResponse.self, from: json) + XCTAssertEqual(response.code, 200) + XCTAssertEqual(response.data?.token, "abc123") + } + + func testEmptyResponse() throws { + let json = """ + { + "code": 200, + "status": "0", + "data": null, + "message": "success" + } + """.data(using: .utf8)! + let response = try JSONDecoder().decode(ApiResponse.self, from: json) + XCTAssertEqual(response.code, 200) } }