diff --git a/Sources/XuqmSDK/Core/XuqmSDK.swift b/Sources/XuqmSDK/Core/XuqmSDK.swift index 8a45d39..44af928 100644 --- a/Sources/XuqmSDK/Core/XuqmSDK.swift +++ b/Sources/XuqmSDK/Core/XuqmSDK.swift @@ -1,13 +1,22 @@ import Foundation @MainActor -public final class XuqmSDK { +public final class XuqmSDK: NSObject { public static let shared = XuqmSDK() private(set) var config: SDKConfig? private(set) var tokenStore: TokenStore? - private init() {} + public private(set) var currentUserId: String? + public var onUserSigExpired: (() -> Void)? + + private var userSig: String? + private var userSigTimer: Timer? + private var cachedDeviceToken: String? + + private override init() { + super.init() + } public func initialize(config: SDKConfig) { self.config = config @@ -21,4 +30,93 @@ public final class XuqmSDK { } return config } + + public func login(userId: String, userSig: String) async { + self.currentUserId = userId + self.userSig = userSig + startUserSigExpirationTimer(userSig: userSig) + + do { + try await ImSDK.shared.loginWithUserSig(userId, userSig) + } catch { + // IM login failed; silently ignored per facade pattern + } + + if let cachedDeviceToken { + do { + try await PushSDK.shared.registerToken(cachedDeviceToken, userId: userId) + } catch { + // Push registration failed + } + } + } + + public func logout() async { + userSigTimer?.invalidate() + userSigTimer = nil + + ImSDK.shared.disconnect() + + if let userId = currentUserId { + do { + try await PushSDK.shared.unregisterToken(userId: userId) + } catch { + // Push unregistration failed + } + } + + tokenStore?.clear() + currentUserId = nil + userSig = nil + cachedDeviceToken = nil + } + + public func registerDeviceToken(_ deviceToken: Data) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + self.cachedDeviceToken = token + Task { @MainActor in + if let userId = self.currentUserId { + try? await PushSDK.shared.registerToken(token, userId: userId) + } + } + } + + private func startUserSigExpirationTimer(userSig: String) { + userSigTimer?.invalidate() + guard let expDate = extractExpirationDate(from: userSig) else { return } + let interval = expDate.timeIntervalSinceNow - 300 // 5 minutes before expiry + guard interval > 0 else { + onUserSigExpired?() + return + } + userSigTimer = Timer.scheduledTimer( + timeInterval: interval, + target: self, + selector: #selector(userSigDidExpire), + userInfo: nil, + repeats: false + ) + } + + @objc private func userSigDidExpire() { + onUserSigExpired?() + } + + private func extractExpirationDate(from userSig: String) -> Date? { + let parts = userSig.split(separator: ".") + guard parts.count >= 2 else { return nil } + var base64 = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = 4 - base64.count % 4 + if padding != 4 { + base64 += String(repeating: "=", count: padding) + } + guard let data = Data(base64Encoded: base64), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let exp = json["exp"] as? TimeInterval else { + return nil + } + return Date(timeIntervalSince1970: exp) + } } diff --git a/Sources/XuqmSDK/IM/ImSDK.swift b/Sources/XuqmSDK/IM/ImSDK.swift index d2f5063..dacafa7 100644 --- a/Sources/XuqmSDK/IM/ImSDK.swift +++ b/Sources/XuqmSDK/IM/ImSDK.swift @@ -8,8 +8,34 @@ public final class ImSDK { private weak var delegate: ImEventDelegate? private var currentUserId: String? + public private(set) var connectionState: ImConnectionState = .disconnected + private var connectionStateListeners: [(ImConnectionState) -> Void] = [] + private init() {} + public func addConnectionStateListener(_ listener: @escaping (ImConnectionState) -> Void) { + connectionStateListeners.append(listener) + } + + private func updateConnectionState(_ state: ImConnectionState) { + connectionState = state + for listener in connectionStateListeners { + listener(state) + } + } + + public func loginWithUserSig(_ userId: String, _ userSig: String) async throws { + let config = XuqmSDK.shared.requireConfig() + currentUserId = userId + XuqmSDK.shared.tokenStore?.save(userSig) + client?.disconnect() + client = ImClient(token: userSig, appId: config.appId) + client?.setCurrentUserId(userId) + client?.delegate = self + updateConnectionState(.connecting) + client?.connect() + } + public func login(userId: String, nickname: String? = nil, avatar: String? = nil) async throws { let config = XuqmSDK.shared.requireConfig() @@ -30,7 +56,8 @@ public final class ImSDK { client?.disconnect() client = ImClient(token: res.token, appId: config.appId) client?.setCurrentUserId(userId) - client?.delegate = delegate + client?.delegate = self + updateConnectionState(.connecting) client?.connect() } @@ -57,13 +84,14 @@ public final class ImSDK { client?.disconnect() client = ImClient(token: res.imToken, appId: config.appId) client?.setCurrentUserId(res.profile.userId) - client?.delegate = delegate + client?.delegate = self + updateConnectionState(.connecting) client?.connect() } public func setDelegate(_ delegate: ImEventDelegate) { self.delegate = delegate - client?.delegate = delegate + client?.delegate = self } public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) -> ImMessage { @@ -818,6 +846,7 @@ public final class ImSDK { public func disconnect() { client?.disconnect() client = nil + updateConnectionState(.disconnected) } private func fetchHistoryInternal( @@ -870,3 +899,37 @@ public final class ImSDK { return formatter.string(from: date) } } + +@MainActor +extension ImSDK: ImEventDelegate { + public func imClientDidConnect() { + updateConnectionState(.connected) + delegate?.imClientDidConnect() + } + + public func imClientDidDisconnect(reason: String?) { + updateConnectionState(.disconnected) + delegate?.imClientDidDisconnect(reason: reason) + } + + public func imClientDidReceiveMessage(_ message: ImMessage) { + delegate?.imClientDidReceiveMessage(message) + } + + public func imClientDidReceiveGroupMessage(_ message: ImMessage) { + delegate?.imClientDidReceiveGroupMessage(message) + } + + public func imClientDidReadMessage(_ message: ImMessage) { + delegate?.imClientDidReadMessage(message) + } + + public func imClientDidReceiveRevokedMessage(_ message: ImMessage) { + delegate?.imClientDidReceiveRevokedMessage(message) + } + + public func imClientDidError(_ error: String) { + updateConnectionState(.disconnected) + delegate?.imClientDidError(error) + } +} diff --git a/Sources/XuqmSDK/IM/ImTypes.swift b/Sources/XuqmSDK/IM/ImTypes.swift index 5a7e847..ec791eb 100644 --- a/Sources/XuqmSDK/IM/ImTypes.swift +++ b/Sources/XuqmSDK/IM/ImTypes.swift @@ -1,5 +1,11 @@ import Foundation +public enum ImConnectionState: String, Sendable { + case connected + case disconnected + case connecting +} + public enum ChatType: String, Codable, Sendable { case single = "SINGLE" case group = "GROUP" diff --git a/Sources/XuqmSDK/Push/PushSDK.swift b/Sources/XuqmSDK/Push/PushSDK.swift index 24e1b3a..43f6235 100644 --- a/Sources/XuqmSDK/Push/PushSDK.swift +++ b/Sources/XuqmSDK/Push/PushSDK.swift @@ -49,6 +49,31 @@ public final class PushSDK: NSObject, UNUserNotificationCenterDelegate { ) } + public func registerFcmToken(_ token: String, userId: String) async throws { + try await registerToken(token, userId: userId, vendor: .fcm) + } + + public var isFcmAvailable: Bool { + #if canImport(FirebaseMessaging) + return true + #else + return false + #endif + } + + public func unregisterToken(userId: String, vendor: PushVendor = .apns) async throws { + let config = XuqmSDK.shared.requireConfig() + let _: EmptyResponse = try await ApiClient.shared.request( + path: "/api/push/unregister", + method: "POST", + queryItems: [ + URLQueryItem(name: "appId", value: config.appId), + URLQueryItem(name: "userId", value: userId), + URLQueryItem(name: "vendor", value: vendor.rawValue), + ] + ) + } + public func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, diff --git a/TEST_REPORT.md b/TEST_REPORT.md new file mode 100644 index 0000000..d836230 --- /dev/null +++ b/TEST_REPORT.md @@ -0,0 +1,130 @@ +# iOS SDK 测试报告 + +> **生成时间**: 2026-05-01 +> **版本**: 0.1.0 +> **测试状态**: 部分功能待测试 + +--- + +## 测试环境 + +| 项目 | 版本/配置 | +|------|-----------| +| Xcode | 16.0 | +| iOS 模拟器 | iPhone 16 Pro(iOS 18.0) | +| Swift | 5.9+ | +| Swift Tools Version | 5.9 | +| 最低 iOS 版本 | iOS 14 | + +--- + +## 测试用例清单 + +### TC-01 SDK 初始化测试 + +| 字段 | 内容 | +|------|------| +| **测试目的** | 验证 SDK 初始化及模块配置 | +| **测试步骤** | 1. 在 `AppDelegate` 中调用 `XuqmSDK.shared.initialize(config: SDKConfig(appId:appSecret:))`
2. 确认 `XuqmSDK.shared.requireConfig()` 返回有效配置
3. 确认 `ApiClient.shared` 已配置 baseURL 与拦截器 | +| **预期结果** | 1. 初始化成功,无 fatalError
2. `config.appId` 与传入值一致
3. `TokenStore` 已实例化 | +| **实际结果** | 待测试 | +| **通过状态** | ⬜ | + +--- + +### TC-02 IM 登录/登出测试(UserSig 模式) + +| 字段 | 内容 | +|------|------| +| **测试目的** | 验证 UserSig 鉴权登录与登出流程 | +| **测试步骤** | 1. 调用 `XuqmSDK.shared.login(userId: "user_001", userSig: "xxx")`
2. 观察 `ImSDK.shared.loginWithUserSig` 内部触发 WebSocket 连接
3. 监听 `ImEventDelegate.imClientDidConnect()`
4. 调用 `XuqmSDK.shared.logout()`
5. 确认 `ImSDK.shared.disconnect()` 执行,Push Token 解注册 | +| **预期结果** | 1. `currentUserId` 被赋值
2. WebSocket 连接成功,状态变为 `.connecting` → `.connected`
3. delegate `imClientDidConnect()` 触发
4. 登出后 `currentUserId` 置 nil
5. `PushSDK.shared.unregisterToken` 被调用 | +| **实际结果** | 待测试 | +| **通过状态** | ⬜ | + +--- + +### TC-03 单聊消息收发测试 + +| 字段 | 内容 | +|------|------| +| **测试目的** | 验证单聊消息发送、接收、历史与已读 | +| **测试步骤** | 1. 调用 `ImSDK.shared.sendTextMessage(toId:chatType:content:)`
2. 接收方通过 `ImEventDelegate.imClientDidReceiveMessage(_:)` 接收
3. 调用 `fetchHistory(toId:page:size:)`
4. 调用 `markRead(targetId:chatType:)`
5. 发送方重新拉取历史,确认 `status == .read` | +| **预期结果** | 1. 返回 `ImMessage`,`status` 为 `.sending` 或 `.sent`
2. 接收方实时收到消息,未读角标 +1
3. 历史消息返回 `[ImMessage]`
4. `markRead` HTTP 200,未读清零
5. 发送方消息状态更新为 `.read` | +| **实际结果** | 待测试 | +| **通过状态** | ⬜ | + +--- + +### TC-04 群聊消息收发测试 + +| 字段 | 内容 | +|------|------| +| **测试目的** | 验证群创建、订阅、群消息收发与历史 | +| **测试步骤** | 1. 调用 `createGroup(name:memberIds:groupType:)`
2. 双端调用 `subscribeGroup(_:)`
3. 发送方调用 `sendTextMessage(toId:chatType:content:)`(chatType=.group)
4. 接收方通过 `imClientDidReceiveGroupMessage(_:)` 接收
5. 双端调用 `fetchGroupHistory(groupId:page:size:)` | +| **预期结果** | 1. 返回 `ImGroup`,`memberIds` 包含指定用户
2. WebSocket 订阅 `/topic/group/{groupId}` 成功
3. 群消息发送成功
4. 群成员实时收到消息
5. 群历史正确分页 | +| **实际结果** | 待测试 | +| **通过状态** | ⬜ | + +--- + +### TC-05 连接状态监听测试(新增) + +| 字段 | 内容 | +|------|------| +| **测试目的** | 验证 `connectionState` 属性与 `addConnectionStateListener` 回调 | +| **测试步骤** | 1. 添加 `addConnectionStateListener { state in print(state) }`
2. 触发登录,观察状态流转
3. 手动断开网络,观察重连状态
4. 恢复网络,观察恢复为 `.connected`
5. 调用 `disconnect()`,观察 `.disconnected` | +| **预期结果** | 1. 监听器被加入数组
2. 登录时状态变化:`.disconnected` → `.connecting` → `.connected`
3. 断网后状态变为 `.disconnected` 并触发重连
4. 恢复网络后回到 `.connected`
5. `disconnect()` 后状态为 `.disconnected`,监听器仍保留(不移除) | +| **实际结果** | 待测试 | +| **通过状态** | ⬜ | + +--- + +### TC-06 Push 设备注册测试(APNs + FCM) + +| 字段 | 内容 | +|------|------| +| **测试目的** | 验证 APNs 设备 Token 获取、注册与 FCM 备选方案 | +| **测试步骤** | 1. 在 `AppDelegate` 中调用 `PushSDK.shared.requestAuthorization()`
2. 系统授权后 `UIApplication.shared.registerForRemoteNotifications()`
3. 在 `didRegisterForRemoteNotificationsWithDeviceToken` 中调用 `XuqmSDK.shared.registerDeviceToken(_:)`
4. 确认 `PushSDK.shared.registerToken(token:userId:vendor:)` 调用(vendor=.apns)
5. 若集成 Firebase,验证 `registerFcmToken` 路径 | +| **预期结果** | 1. `requestAuthorization` 返回 `true`
2. 系统弹窗申请通知权限
3. `cachedDeviceToken` 被保存
4. `/api/push/register` 返回 200
5. FCM 路径返回 vendor=FCM,注册成功 | +| **实际结果** | 待测试 | +| **通过状态** | ⬜ | + +--- + +### TC-07 版本更新检查测试 + +| 字段 | 内容 | +|------|------| +| **测试目的** | 验证 UpdateSDK 检查 App Store 更新 | +| **测试步骤** | 1. 调用 `UpdateSDK.shared.checkAppUpdate(currentVersionCode: 1)`
2. 若 `needsUpdate=true` 且 `forceUpdate=true`,调用 `openAppStore(url:)`
3. 观察是否能正确跳转到 App Store 或下载页 | +| **预期结果** | 1. 返回 `AppUpdateInfo`,`platform=IOS`
2. `forceUpdate` 为布尔值,下载链接有效
3. `UIApplication.shared.open` 成功跳转 | +| **实际结果** | 待测试 | +| **通过状态** | ⬜ | + +--- + +### TC-08 UserSig 过期检测测试(新增) + +| 字段 | 内容 | +|------|------| +| **测试目的** | 验证 `XuqmSDK` 解析 UserSig JWT exp 并在过期前触发回调 | +| **测试步骤** | 1. 生成一个 exp 为当前时间 + 6 分钟的 UserSig JWT
2. 调用 `XuqmSDK.shared.login(userId:userSig:)`
3. 设置 `XuqmSDK.shared.onUserSigExpired = { ... }`
4. 等待 1 分钟,观察 Timer 是否在正确时间点触发
5. 触发后调用 `logout()`,确认 Timer 被 `invalidate` | +| **预期结果** | 1. `extractExpirationDate` 正确解析 JWT payload
2. `startUserSigExpirationTimer` 创建 `Timer`
3. 到期前 5 分钟触发 `onUserSigExpired?()`
4. 回调在主线程执行
5. `logout()` 后 `userSigTimer` 为 nil,无内存泄漏 | +| **实际结果** | 待测试 | +| **通过状态** | ⬜ | + +--- + +## 测试汇总 + +| 用例编号 | 用例名称 | 状态 | +|---------|---------|------| +| TC-01 | SDK 初始化测试 | ⬜ 待测试 | +| TC-02 | IM 登录/登出测试(UserSig 模式) | ⬜ 待测试 | +| TC-03 | 单聊消息收发测试 | ⬜ 待测试 | +| TC-04 | 群聊消息收发测试 | ⬜ 待测试 | +| TC-05 | 连接状态监听测试 | ⬜ 待测试 | +| TC-06 | Push 设备注册测试(APNs + FCM) | ⬜ 待测试 | +| TC-07 | 版本更新检查测试 | ⬜ 待测试 | +| TC-08 | UserSig 过期检测测试 | ⬜ 待测试 | diff --git a/XuqmDemo/Package.swift b/XuqmDemo/Package.swift new file mode 100644 index 0000000..4cf63ed --- /dev/null +++ b/XuqmDemo/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "XuqmDemo", + platforms: [.iOS(.v16), .macOS(.v13)], + products: [ + .library(name: "XuqmDemo", targets: ["XuqmDemo"]), + ], + dependencies: [ + .package(path: ".."), + ], + targets: [ + .target( + name: "XuqmDemo", + dependencies: [ + .product(name: "XuqmSDK", package: "XuqmGroup-iOSSDK"), + ], + path: "Sources" + ), + ] +) diff --git a/XuqmDemo/README.md b/XuqmDemo/README.md new file mode 100644 index 0000000..8fc6dc9 --- /dev/null +++ b/XuqmDemo/README.md @@ -0,0 +1,65 @@ +# XuqmDemo + +XuqmGroup iOS SDK 的 SwiftUI 演示应用。 + +## 功能 + +- **登录**:使用 demo-service 登录(`user_a` / `123456`) +- **会话列表**:展示最近会话,支持搜索、置顶、静音、删除 +- **单聊**:与某个用户收发文本消息 +- **更新检查**:检查 App 更新 +- **个人资料**:展示并修改当前用户信息 + +## 环境 + +- 演示服务器:`https://dev.xuqinmin.com` +- App ID:`ak_demo_chat` + +## 运行方式 + +### 方式一:嵌入 Xcode 项目 + +1. 将 `XuqmDemo/Sources` 下的所有 Swift 文件拖入你的 Xcode iOS App 项目 +2. 确保项目已依赖 `XuqmSDK`(本地 SPM 或源码引用) +3. 构建并运行 + +### 方式二:Swift Package Manager(验证编译) + +```bash +cd XuqmDemo +swift build +``` + +> 注:由于 Demo 是 iOS SwiftUI 应用,建议使用 Xcode 打开 `XuqmGroup-iOSSDK` 仓库,然后新建一个 iOS App Target 并将 `XuqmDemo/Sources` 中的文件加入该 Target 进行真机/模拟器运行。 + +## 项目结构 + +``` +XuqmDemo/ + Package.swift + Sources/ + XuqmDemoApp.swift # @main 入口,初始化 SDK 与路由 + Views/ + LoginView.swift # 登录页 + ConversationListView.swift # 会话列表页 + ChatView.swift # 单聊页 + UpdateCheckView.swift # 更新检查页 + ProfileView.swift # 个人资料页 + ViewModels/ + AuthViewModel.swift # 登录/登出逻辑 + ConversationViewModel.swift# 会话列表逻辑 + ChatViewModel.swift # 聊天逻辑(含 ImEventDelegate 桥接) + Models/ + DemoModels.swift # 路由、状态、辅助函数 + README.md +``` + +## 关键 SDK API 使用 + +- `XuqmSDK.shared.initialize(config:)` — 初始化 SDK +- `ImSDK.shared.loginWithDemo(userId:password:)` — Demo 登录 +- `ImSDK.shared.listConversations()` — 获取会话列表 +- `ImSDK.shared.fetchHistory(toId:page:size:)` — 获取历史消息 +- `ImSDK.shared.sendTextMessage(toId:chatType:content:)` — 发送文本消息 +- `ImSDK.shared.getProfile(userId:)` / `updateProfile(...)` — 用户资料 +- `UpdateSDK.shared.checkAppUpdate(currentVersionCode:)` — 检查更新 diff --git a/XuqmDemo/Sources/Models/DemoModels.swift b/XuqmDemo/Sources/Models/DemoModels.swift new file mode 100644 index 0000000..906be25 --- /dev/null +++ b/XuqmDemo/Sources/Models/DemoModels.swift @@ -0,0 +1,111 @@ +import Foundation +import XuqmSDK + +enum AppRoute: Hashable { + case chat(targetId: String, targetName: String) +} + +struct DemoUser: Identifiable { + let id: String + let userId: String + let nickname: String + let avatar: String? +} + +enum LoginState: Equatable { + case idle + case loading + case success + case error(String) +} + +enum ConversationLoadState: Equatable { + case idle + case loading + case loaded + case error(String) +} + +struct MessageItem: Identifiable { + let id: String + let message: ImMessage + let isOwn: Bool +} + +func parseMessageText(_ message: ImMessage) -> String { + if message.msgType == .text { + if let data = message.content.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let text = json["text"] as? String { + return text + } + return message.content + } + return messagePreview(message) +} + +func messagePreview(_ message: ImMessage) -> String { + switch message.msgType { + case .image: return "[图片]" + case .video: return "[视频]" + case .audio: return "[语音]" + case .file: return "[文件]" + case .location: return "[位置]" + case .custom: return "[自定义]" + case .richText: return "[富文本]" + case .forward: return "[转发]" + case .quote: return "[引用]" + case .merge: return "[合并转发]" + case .callAudio: return "[语音通话]" + case .callVideo: return "[视频通话]" + case .revoked: return "[消息已撤回]" + case .notify: return message.content + case .text: return message.content + } +} + +func conversationPreview(_ conversation: ConversationData) -> String { + switch conversation.lastMsgType?.uppercased() { + case "TEXT": return conversation.lastMsgContent ?? "" + case "IMAGE": return "[图片]" + case "VIDEO": return "[视频]" + case "AUDIO": return "[语音]" + case "FILE": return "[文件]" + case "LOCATION": return "[位置]" + case "CUSTOM": return "[自定义]" + case "RICH_TEXT": return "[富文本]" + case "FORWARD": return "[转发]" + case "QUOTE": return "[引用]" + case "MERGE": return "[合并转发]" + case "CALL_AUDIO": return "[语音通话]" + case "CALL_VIDEO": return "[视频通话]" + case "REVOKED": return "[消息已撤回]" + case "NOTIFY": return conversation.lastMsgContent ?? "[通知]" + default: return conversation.lastMsgContent ?? "" + } +} + +func statusLabel(_ status: MsgStatus) -> String { + switch status { + case .sending: return "发送中" + case .sent: return "已发送" + case .delivered: return "已送达" + case .read: return "已读" + case .failed: return "发送失败" + case .revoked: return "已撤回" + } +} + +func formatConversationTime(_ timestamp: Int64) -> String { + let date = Date(timeIntervalSince1970: Double(timestamp) / 1000) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + if Calendar.current.isDateInToday(date) { + formatter.dateFormat = "HH:mm" + } else if Calendar.current.isDateInYesterday(date) { + return "昨天" + } else { + formatter.dateFormat = "MM-dd" + } + return formatter.string(from: date) +} diff --git a/XuqmDemo/Sources/ViewModels/AuthViewModel.swift b/XuqmDemo/Sources/ViewModels/AuthViewModel.swift new file mode 100644 index 0000000..36f2736 --- /dev/null +++ b/XuqmDemo/Sources/ViewModels/AuthViewModel.swift @@ -0,0 +1,34 @@ +import Foundation +import XuqmSDK + +@MainActor +final class AuthViewModel: ObservableObject { + @Published var state: LoginState = .idle + @Published var currentUserId: String = "" + @Published var currentNickname: String = "" + + func login(userId: String, password: String) { + guard !userId.isEmpty, !password.isEmpty else { + state = .error("请输入用户 ID 和密码") + return + } + state = .loading + Task { + do { + try await ImSDK.shared.loginWithDemo(userId: userId, password: password) + currentUserId = userId + currentNickname = userId + state = .success + } catch { + state = .error(error.localizedDescription) + } + } + } + + func logout() { + ImSDK.shared.disconnect() + currentUserId = "" + currentNickname = "" + state = .idle + } +} diff --git a/XuqmDemo/Sources/ViewModels/ChatViewModel.swift b/XuqmDemo/Sources/ViewModels/ChatViewModel.swift new file mode 100644 index 0000000..5da0eb0 --- /dev/null +++ b/XuqmDemo/Sources/ViewModels/ChatViewModel.swift @@ -0,0 +1,145 @@ +import Foundation +import XuqmSDK + +@MainActor +final class ChatViewModel: ObservableObject { + @Published var messages: [ImMessage] = [] + @Published var inputText: String = "" + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var connectionStatus: String = "连接中" + + private var targetId: String = "" + private var chatType: ChatType = .single + private var currentPage = 0 + private let pageSize = 20 + private var hasMore = true + private var isLoadingMore = false + + private var delegateBridge: ChatEventDelegateBridge? + + func setup(targetId: String, chatType: ChatType) { + self.targetId = targetId + self.chatType = chatType + self.messages = [] + self.currentPage = 0 + self.hasMore = true + self.isLoadingMore = false + self.errorMessage = nil + + let bridge = ChatEventDelegateBridge(viewModel: self) + self.delegateBridge = bridge + ImSDK.shared.setDelegate(bridge) + + loadHistory() + } + + func loadHistory() { + guard !isLoading else { return } + isLoading = true + currentPage = 0 + hasMore = true + Task { + do { + let history = try await ImSDK.shared.fetchHistory(toId: targetId, page: currentPage, size: pageSize) + messages = history + hasMore = history.count >= pageSize + isLoading = false + } catch { + errorMessage = error.localizedDescription + isLoading = false + } + } + } + + func loadMoreHistory() { + guard hasMore, !isLoadingMore, !isLoading else { return } + isLoadingMore = true + currentPage += 1 + Task { + do { + let history = try await ImSDK.shared.fetchHistory(toId: targetId, page: currentPage, size: pageSize) + messages.append(contentsOf: history) + hasMore = history.count >= pageSize + isLoadingMore = false + } catch { + isLoadingMore = false + } + } + } + + func sendText() { + let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + let message = ImSDK.shared.sendTextMessage(toId: targetId, chatType: chatType, content: text) + messages.insert(message, at: 0) + inputText = "" + } + + func markRead() { + Task { + try? await ImSDK.shared.markRead(targetId: targetId, chatType: chatType) + } + } + + // MARK: - Delegate callbacks (called on MainActor via bridge) + + func didReceiveMessage(_ message: ImMessage) { + guard message.fromId == targetId || message.toId == targetId else { return } + if !messages.contains(where: { $0.id == message.id }) { + messages.insert(message, at: 0) + } + } + + func didConnect() { + connectionStatus = "已连接" + } + + func didDisconnect(reason: String?) { + connectionStatus = reason ?? "已断开" + } + + func didError(_ error: String) { + connectionStatus = "错误: \(error)" + } +} + +// MARK: - ImEventDelegate Bridge + +private final class ChatEventDelegateBridge: NSObject, ImEventDelegate { + weak var viewModel: ChatViewModel? + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + func imClientDidConnect() { + Task { @MainActor [weak viewModel] in + viewModel?.didConnect() + } + } + + func imClientDidDisconnect(reason: String?) { + Task { @MainActor [weak viewModel] in + viewModel?.didDisconnect(reason: reason) + } + } + + func imClientDidReceiveMessage(_ message: ImMessage) { + Task { @MainActor [weak viewModel] in + viewModel?.didReceiveMessage(message) + } + } + + func imClientDidReceiveGroupMessage(_ message: ImMessage) { + Task { @MainActor [weak viewModel] in + viewModel?.didReceiveMessage(message) + } + } + + func imClientDidError(_ error: String) { + Task { @MainActor [weak viewModel] in + viewModel?.didError(error) + } + } +} diff --git a/XuqmDemo/Sources/ViewModels/ConversationViewModel.swift b/XuqmDemo/Sources/ViewModels/ConversationViewModel.swift new file mode 100644 index 0000000..22a3e7e --- /dev/null +++ b/XuqmDemo/Sources/ViewModels/ConversationViewModel.swift @@ -0,0 +1,76 @@ +import Foundation +import XuqmSDK + +@MainActor +final class ConversationViewModel: ObservableObject { + @Published var conversations: [ConversationData] = [] + @Published var state: ConversationLoadState = .idle + @Published var query: String = "" + @Published var totalUnreadCount: Int = 0 + + var filteredConversations: [ConversationData] { + if query.isEmpty { return conversations } + let keyword = query.trimmingCharacters(in: .whitespaces) + return conversations.filter { + $0.targetId.localizedCaseInsensitiveContains(keyword) + || conversationPreview($0).localizedCaseInsensitiveContains(keyword) + } + } + + func load() { + state = .loading + Task { + do { + conversations = try await ImSDK.shared.listConversations() + totalUnreadCount = conversations.reduce(0) { $0 + $1.unreadCount } + state = .loaded + } catch { + state = .error(error.localizedDescription) + } + } + } + + func refresh() { + Task { + do { + conversations = try await ImSDK.shared.listConversations() + totalUnreadCount = conversations.reduce(0) { $0 + $1.unreadCount } + } catch { + state = .error(error.localizedDescription) + } + } + } + + func deleteConversation(_ conversation: ConversationData) { + Task { + do { + try await ImSDK.shared.deleteConversation(targetId: conversation.targetId, chatType: conversation.chatType) + await refresh() + } catch { + state = .error(error.localizedDescription) + } + } + } + + func togglePinned(_ conversation: ConversationData) { + Task { + do { + try await ImSDK.shared.setConversationPinned(targetId: conversation.targetId, chatType: conversation.chatType, pinned: !conversation.isPinned) + await refresh() + } catch { + state = .error(error.localizedDescription) + } + } + } + + func toggleMuted(_ conversation: ConversationData) { + Task { + do { + try await ImSDK.shared.setConversationMuted(targetId: conversation.targetId, chatType: conversation.chatType, muted: !conversation.isMuted) + await refresh() + } catch { + state = .error(error.localizedDescription) + } + } + } +} diff --git a/XuqmDemo/Sources/Views/ChatView.swift b/XuqmDemo/Sources/Views/ChatView.swift new file mode 100644 index 0000000..5704a15 --- /dev/null +++ b/XuqmDemo/Sources/Views/ChatView.swift @@ -0,0 +1,113 @@ +import SwiftUI +import XuqmSDK + +struct ChatView: View { + let targetId: String + let targetName: String + let currentUserId: String + + @StateObject private var viewModel = ChatViewModel() + @State private var scrollToBottom = false + + var body: some View { + VStack(spacing: 0) { + if viewModel.connectionStatus != "已连接" { + Text(viewModel.connectionStatus) + .font(.caption) + .foregroundStyle(.white) + .padding(.vertical, 4) + .frame(maxWidth: .infinity) + .background(viewModel.connectionStatus.contains("错误") ? Color.red : Color.orange) + } + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 8) { + ForEach(viewModel.messages.reversed(), id: \.id) { message in + MessageBubble(message: message, currentUserId: currentUserId) + .id(message.id) + .rotationEffect(.degrees(180)) + } + + if viewModel.isLoading { + ProgressView() + .padding() + .rotationEffect(.degrees(180)) + } + } + .padding() + .rotationEffect(.degrees(180)) + } + .onChange(of: viewModel.messages.count) { _ in + if let first = viewModel.messages.first { + withAnimation { + proxy.scrollTo(first.id, anchor: .bottom) + } + } + } + } + + Divider() + + HStack(spacing: 12) { + TextField("输入消息…", text: $viewModel.inputText, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...4) + + Button { + viewModel.sendText() + } label: { + Image(systemName: "paperplane.fill") + .foregroundStyle(viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : Color.accentColor) + } + .disabled(viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding() + } + .navigationTitle(targetName) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .onAppear { + viewModel.setup(targetId: targetId, chatType: .single) + viewModel.markRead() + } + } +} + +struct MessageBubble: View { + let message: ImMessage + let currentUserId: String + + private var isOwn: Bool { + message.fromUserId == currentUserId + } + + var body: some View { + HStack { + if isOwn { Spacer(minLength: 40) } + + VStack(alignment: isOwn ? .trailing : .leading, spacing: 2) { + Text(parseMessageText(message)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isOwn ? Color.accentColor.opacity(0.15) : Color.gray.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .foregroundStyle(.primary) + + HStack(spacing: 4) { + if isOwn { + Text(statusLabel(message.status)) + .font(.caption2) + .foregroundStyle(.secondary) + } + Text(formatConversationTime(message.createdAt)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + if !isOwn { Spacer(minLength: 40) } + } + } +} diff --git a/XuqmDemo/Sources/Views/ConversationListView.swift b/XuqmDemo/Sources/Views/ConversationListView.swift new file mode 100644 index 0000000..d5e6eed --- /dev/null +++ b/XuqmDemo/Sources/Views/ConversationListView.swift @@ -0,0 +1,128 @@ +import SwiftUI +import XuqmSDK + +struct ConversationListView: View { + @StateObject private var viewModel = ConversationViewModel() + @Binding var path: NavigationPath + + var body: some View { + List { + if viewModel.totalUnreadCount > 0 { + Text("总未读 \(viewModel.totalUnreadCount)") + .font(.caption) + .foregroundStyle(.secondary) + } + + ForEach(viewModel.filteredConversations, id: \.targetId) { conversation in + ConversationRow(conversation: conversation) + .contentShape(Rectangle()) + .onTapGesture { + path.append(AppRoute.chat(targetId: conversation.targetId, targetName: conversation.targetId)) + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + viewModel.deleteConversation(conversation) + } label: { + Text("删除") + } + } + .swipeActions(edge: .leading) { + Button { + viewModel.togglePinned(conversation) + } label: { + Text(conversation.isPinned ? "取消置顶" : "置顶") + } + .tint(.orange) + + Button { + viewModel.toggleMuted(conversation) + } label: { + Text(conversation.isMuted ? "取消静音" : "静音") + } + .tint(.indigo) + } + } + } + .listStyle(.plain) + .searchable(text: $viewModel.query, prompt: "搜索会话") + .navigationTitle("会话") + .toolbar { + #if os(iOS) + ToolbarItem(placement: .navigationBarTrailing) { + Button { + viewModel.refresh() + } label: { + Image(systemName: "arrow.clockwise") + } + } + #endif + } + .refreshable { + viewModel.refresh() + } + .onAppear { + if viewModel.conversations.isEmpty { + viewModel.load() + } + } + } +} + +struct ConversationRow: View { + let conversation: ConversationData + + var body: some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.15)) + .frame(width: 48, height: 48) + Text(String(conversation.targetId.prefix(1).uppercased())) + .font(.headline) + .foregroundStyle(Color.accentColor) + } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(conversation.targetId) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + + Spacer() + + if conversation.isPinned { + Text("置顶") + .font(.caption2) + .foregroundStyle(.orange) + } + + Text(formatConversationTime(conversation.lastMsgTime)) + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + Text(conversationPreview(conversation)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + if conversation.unreadCount > 0 && !conversation.isMuted { + Text("\(conversation.unreadCount)") + .font(.caption2) + .fontWeight(.bold) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.red) + .clipShape(Capsule()) + } + } + } + } + .padding(.vertical, 4) + } +} diff --git a/XuqmDemo/Sources/Views/LoginView.swift b/XuqmDemo/Sources/Views/LoginView.swift new file mode 100644 index 0000000..219b93c --- /dev/null +++ b/XuqmDemo/Sources/Views/LoginView.swift @@ -0,0 +1,74 @@ +import SwiftUI +import XuqmSDK + +struct LoginView: View { + @ObservedObject var viewModel: AuthViewModel + @State private var userId: String = "user_a" + @State private var password: String = "123456" + + var body: some View { + VStack(spacing: 20) { + Spacer() + + Text("XuqmGroup IM") + .font(.largeTitle) + .fontWeight(.bold) + + Text("iOS Demo") + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer().frame(height: 40) + + VStack(spacing: 16) { + TextField("用户 ID", text: $userId) + .textContentType(.username) + #if os(iOS) + .autocapitalization(.none) + #endif + .padding() + .background(Color.gray.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + SecureField("密码", text: $password) + .textContentType(.password) + .padding() + .background(Color.gray.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + if case .error(let msg) = viewModel.state { + Text(msg) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + } + + Button { + viewModel.login(userId: userId.trimmingCharacters(in: .whitespaces), password: password) + } label: { + HStack { + if case .loading = viewModel.state { + ProgressView() + .tint(.white) + } + Text("登录") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(viewModel.state == .loading || userId.isEmpty || password.isEmpty) + + Spacer() + + Text("演示环境: https://dev.xuqinmin.com") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 32) + } +} diff --git a/XuqmDemo/Sources/Views/ProfileView.swift b/XuqmDemo/Sources/Views/ProfileView.swift new file mode 100644 index 0000000..6e0365f --- /dev/null +++ b/XuqmDemo/Sources/Views/ProfileView.swift @@ -0,0 +1,119 @@ +import SwiftUI +import XuqmSDK + +@MainActor +final class ProfileViewModel: ObservableObject { + @Published var profile: UserProfile? + @Published var isLoading = false + @Published var errorMessage: String? + @Published var nickname: String = "" + + func loadProfile(userId: String) { + isLoading = true + Task { + do { + let p = try await ImSDK.shared.getProfile(userId: userId) + profile = p + nickname = p.nickname ?? userId + isLoading = false + } catch { + errorMessage = error.localizedDescription + isLoading = false + } + } + } + + func saveProfile(userId: String) { + isLoading = true + Task { + do { + let p = try await ImSDK.shared.updateProfile(userId: userId, nickname: nickname) + profile = p + isLoading = false + } catch { + errorMessage = error.localizedDescription + isLoading = false + } + } + } +} + +struct ProfileView: View { + @ObservedObject var authViewModel: AuthViewModel + @StateObject private var viewModel = ProfileViewModel() + + var body: some View { + VStack(spacing: 20) { + Spacer().frame(height: 40) + + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.15)) + .frame(width: 80, height: 80) + Text(String(viewModel.nickname.prefix(1).uppercased())) + .font(.largeTitle) + .foregroundStyle(Color.accentColor) + } + + Text(authViewModel.currentUserId) + .font(.caption) + .foregroundStyle(.secondary) + + Divider() + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 8) { + Text("昵称") + .font(.caption) + .foregroundStyle(.secondary) + TextField("输入昵称", text: $viewModel.nickname) + .textFieldStyle(.roundedBorder) + } + .padding(.horizontal) + + if let error = viewModel.errorMessage { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Button { + viewModel.saveProfile(userId: authViewModel.currentUserId) + } label: { + HStack { + if viewModel.isLoading { + ProgressView() + } + Text("保存") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(viewModel.isLoading || viewModel.nickname.isEmpty) + .padding(.horizontal) + + Spacer() + + Button { + authViewModel.logout() + } label: { + Text("退出登录") + .frame(maxWidth: .infinity) + .padding() + .background(Color.red.opacity(0.1)) + .foregroundStyle(.red) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(.horizontal) + } + .padding() + .onAppear { + if !authViewModel.currentUserId.isEmpty { + viewModel.loadProfile(userId: authViewModel.currentUserId) + } + } + } +} diff --git a/XuqmDemo/Sources/Views/UpdateCheckView.swift b/XuqmDemo/Sources/Views/UpdateCheckView.swift new file mode 100644 index 0000000..49b9d3d --- /dev/null +++ b/XuqmDemo/Sources/Views/UpdateCheckView.swift @@ -0,0 +1,112 @@ +import SwiftUI +import XuqmSDK + +@MainActor +final class UpdateViewModel: ObservableObject { + @Published var isChecking = false + @Published var updateInfo: AppUpdateInfo? + @Published var message: String? + + func checkUpdate() { + isChecking = true + message = nil + Task { + do { + let info = try await UpdateSDK.shared.checkAppUpdate(currentVersionCode: 1) + updateInfo = info + if !info.needsUpdate { + message = "已是最新版本" + } + isChecking = false + } catch { + message = "检查失败: \(error.localizedDescription)" + isChecking = false + } + } + } + + func openAppStore() { + if let url = updateInfo?.appStoreUrl, !url.isEmpty { + UpdateSDK.shared.openAppStore(url: url) + } + } +} + +struct UpdateCheckView: View { + @StateObject private var viewModel = UpdateViewModel() + + var body: some View { + VStack(spacing: 20) { + Spacer().frame(height: 40) + + Text("版本更新") + .font(.title2) + .fontWeight(.bold) + + if let info = viewModel.updateInfo, info.needsUpdate { + VStack(alignment: .leading, spacing: 12) { + Text("发现新版本: \(info.versionName ?? "")") + .font(.headline) + + if let changeLog = info.changeLog, !changeLog.isEmpty { + Text("更新说明: \(changeLog)") + .font(.body) + .foregroundStyle(.secondary) + } + + if info.forceUpdate == true { + Text("强制更新") + .font(.caption) + .foregroundStyle(.red) + } + + Button { + viewModel.openAppStore() + } label: { + Text("前往 App Store") + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + .padding() + .background(Color.gray.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.horizontal) + } else { + if let msg = viewModel.message { + Text(msg) + .foregroundStyle(.secondary) + } + + Button { + viewModel.checkUpdate() + } label: { + HStack { + if viewModel.isChecking { + ProgressView() + } + Text(viewModel.isChecking ? "检查中…" : "检查更新") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(viewModel.isChecking) + .padding(.horizontal) + } + + Spacer() + } + .padding() + .onAppear { + if viewModel.updateInfo == nil && !viewModel.isChecking { + viewModel.checkUpdate() + } + } + } +} diff --git a/XuqmDemo/Sources/XuqmDemoApp.swift b/XuqmDemo/Sources/XuqmDemoApp.swift new file mode 100644 index 0000000..7e52e57 --- /dev/null +++ b/XuqmDemo/Sources/XuqmDemoApp.swift @@ -0,0 +1,100 @@ +import SwiftUI +import XuqmSDK + +@main +struct XuqmDemoApp: App { + #if canImport(UIKit) + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + #endif + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +#if canImport(UIKit) +class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + initializeSDK() + return true + } +} +#endif + +@MainActor +private func initializeSDK() { + let config = SDKConfig( + appKey: "ak_demo_chat", + appSecret: "demo_secret", + debug: true + ) + XuqmSDK.shared.initialize(config: config) +} + +struct ContentView: View { + @StateObject private var authViewModel = AuthViewModel() + @State private var path = NavigationPath() + @State private var selectedTab = 0 + + init() { + #if !canImport(UIKit) + initializeSDK() + #endif + } + + var body: some View { + Group { + if case .success = authViewModel.state { + MainTabView(authViewModel: authViewModel, path: $path, selectedTab: $selectedTab) + } else { + NavigationStack { + LoginView(viewModel: authViewModel) + } + } + } + } +} + +struct MainTabView: View { + @ObservedObject var authViewModel: AuthViewModel + @Binding var path: NavigationPath + @Binding var selectedTab: Int + + var body: some View { + NavigationStack(path: $path) { + TabView(selection: $selectedTab) { + ConversationListView(path: $path) + .tabItem { + Image(systemName: "message.fill") + Text("会话") + } + .tag(0) + + UpdateCheckView() + .tabItem { + Image(systemName: "arrow.up.circle.fill") + Text("更新") + } + .tag(1) + + ProfileView(authViewModel: authViewModel) + .tabItem { + Image(systemName: "person.fill") + Text("我的") + } + .tag(2) + } + .navigationDestination(for: AppRoute.self) { route in + switch route { + case .chat(let targetId, let targetName): + ChatView(targetId: targetId, targetName: targetName, currentUserId: authViewModel.currentUserId) + } + } + } + } +}