import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; import 'package:path_provider/path_provider.dart'; import '../config.dart'; import '../models/signaling.dart'; /// SDK 与服务器的连接状态 enum ConnectStatus { idle, connecting, success, failed } /// 通话信令的自定义消息类型(与官方样板工程保持一致,保证互通) class SignalingType { SignalingType._(); static const int callingInvite = 200; // 发起呼叫 static const int callingAccept = 201; // 接听 static const int callingReject = 202; // 拒接 static const int callingCancel = 203; // 取消呼叫 static const int callingHungup = 204; // 挂断 } /// OpenIM SDK 的统一封装:初始化、登录、监听、会话列表。 /// 用 ChangeNotifier 做状态分发,不引入额外状态管理框架。 class IMService extends ChangeNotifier { IMService._(); static final IMService instance = IMService._(); /// SDK 是否已初始化(App 启动时做一次) bool sdkReady = false; /// 与服务器的连接状态(断网横幅用) ConnectStatus connectStatus = ConnectStatus.idle; /// 最近一次连接失败的原始错误(诊断展示用) String? lastConnectError; /// SDK 数据/日志目录(_doInit 时记录) String? _dataDir; /// 读 SDK 日志文件的结尾部分(定位「SDK 卡在哪」用)。读不到返回空串。 Future sdkLogTail() async { try { final dataDir = _dataDir ?? '${(await getApplicationDocumentsDirectory()).path}/openim'; final dir = Directory(dataDir); if (!dir.existsSync()) return ''; final files = dir .listSync(recursive: true) .whereType() .where((f) => f.path.endsWith('.log')) .toList() ..sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync())); if (files.isEmpty) return ''; final file = files.first; final len = file.lengthSync(); final start = len > 2048 ? len - 2048 : 0; final raf = file.openSync(); try { raf.setPositionSync(start); final bytes = raf.readSync(len - start); // 去掉日志里的 ANSI 颜色码,免得截图里出现乱码 return String.fromCharCodes(bytes) .replaceAll(RegExp(r'\x1B\[[0-9;]*m'), '') .trim(); } finally { raf.closeSync(); } } catch (_) { return ''; } } /// 是否已登录 bool loggedIn = false; /// 会话首次同步中(消息列表的「正在加载」态) bool syncing = true; /// 会话同步失败(消息列表的「加载失败」态) bool syncFailed = false; /// 会话同步是否完成过至少一次(区分「正在加载」和「还没有消息」) bool conversationsLoaded = false; /// 会话列表(置顶在前,其余按最新消息时间倒序) List conversations = []; /// 当前登录用户信息 UserInfo? selfInfo; /// 当前登录凭证(发起通话取 LiveKit token 时要用) String? currentUserID; String? currentToken; /// 被踢下线 / token 失效时的回调(由 main.dart 设置:清缓存、回登录页) void Function()? onForceLogout; /// 收到新消息(聊天页订阅) final StreamController _newMsgController = StreamController.broadcast(); Stream get onNewMessage => _newMsgController.stream; /// 消息被撤回(撤回方的 clientMsgID,聊天页订阅) final StreamController _revokeController = StreamController.broadcast(); Stream get onMessageRevoked => _revokeController.stream; /// 收到通话信令(仅在线自定义消息,通话模块订阅) final StreamController _signalingController = StreamController.broadcast(); Stream get onSignaling => _signalingController.stream; int get platformID => Platform.isIOS ? IMPlatform.ios : IMPlatform.android; /// App 启动时初始化 SDK(只做一次)。 /// 用共享 Future 防止超时后重复触发 initSDK:第一次调用还在跑时, /// 后续调用直接复用同一个 Future;失败了则允许下次重试。 Future? _initFuture; Future init() { if (sdkReady) return Future.value(); final inFlight = _initFuture; if (inFlight != null) return inFlight; final future = _doInit(); _initFuture = future; return future.whenComplete(() { // 失败的初始化允许重试;成功的保留 sdkReady 标记即可 if (!sdkReady && identical(_initFuture, future)) _initFuture = null; }); } /// 初始化进行到哪一步(超时时展示,区分卡在获取目录还是 SDK 内核) String lastInitStep = '还没开始'; Future _doInit() async { lastInitStep = '① 获取存储目录'; final dir = await getApplicationDocumentsDirectory(); final dataDir = '${dir.path}/openim'; _dataDir = dataDir; lastInitStep = '② 创建数据目录'; await Directory(dataDir).create(recursive: true); lastInitStep = '③ 启动消息组件内核'; final ok = await OpenIM.iMManager.initSDK( platformID: platformID, apiAddr: apiAddr, wsAddr: wsAddr, dataDir: dataDir, logFilePath: dataDir, logLevel: 6, listener: OnConnectListener( onConnecting: () { connectStatus = ConnectStatus.connecting; notifyListeners(); }, onConnectFailed: (code, error) { connectStatus = ConnectStatus.failed; lastConnectError = '$code $error'; notifyListeners(); }, onConnectSuccess: () { connectStatus = ConnectStatus.success; lastConnectError = null; notifyListeners(); }, onKickedOffline: _forceLogout, onUserTokenExpired: _forceLogout, onUserTokenInvalid: _forceLogout, ), ); lastInitStep = '④ 内核已返回(结果:$ok)'; sdkReady = ok == true; _setBusinessListeners(); notifyListeners(); } void _setBusinessListeners() { OpenIM.iMManager.conversationManager.setConversationListener( OnConversationListener( onSyncServerStart: (reInstall) { syncing = true; syncFailed = false; notifyListeners(); }, onSyncServerFinish: (reInstall) { syncing = false; conversationsLoaded = true; refreshConversations(); }, onSyncServerFailed: (reInstall) { syncing = false; syncFailed = true; notifyListeners(); }, onConversationChanged: (list) => refreshConversations(), onNewConversation: (list) => refreshConversations(), onTotalUnreadMessageCountChanged: (count) => notifyListeners(), ), ); OpenIM.iMManager.messageManager.setAdvancedMsgListener( OnAdvancedMsgListener( onRecvNewMessage: (msg) { _newMsgController.add(msg); }, onRecvOfflineNewMessage: (msg) { _newMsgController.add(msg); }, onNewRecvMessageRevoked: (info) { if (info.clientMsgID != null) _revokeController.add(info.clientMsgID!); }, onRecvOnlineOnlyMessage: (msg) { // 通话信令走「仅在线」的自定义消息,转发给通话模块 if (msg.contentType == MessageType.custom) { _signalingController.add(msg); } }, ), ); // 好友与群变更只负责刷新界面(通讯录页直接监听 IMService) OpenIM.iMManager.friendshipManager.setFriendshipListener( OnFriendshipListener( onFriendAdded: (info) => notifyListeners(), onFriendDeleted: (info) => notifyListeners(), onFriendInfoChanged: (info) => notifyListeners(), onFriendApplicationAdded: (info) => notifyListeners(), onFriendApplicationAccepted: (info) => notifyListeners(), onFriendApplicationRejected: (info) => notifyListeners(), ), ); OpenIM.iMManager.groupManager.setGroupListener( OnGroupListener( onJoinedGroupAdded: (info) => notifyListeners(), onJoinedGroupDeleted: (info) => notifyListeners(), onGroupInfoChanged: (info) => notifyListeners(), ), ); } /// 登录(init 之后调用)。userID/token 来自公司账号登录接口。 Future login({required String userID, required String token}) async { await init(); if (!sdkReady) { // initSDK 返回 false:SDK 没初始化成功,直接登录会无限等待 throw StateError('消息组件初始化失败'); } final user = await OpenIM.iMManager.login( userID: userID, token: token, defaultValue: () async => UserInfo(userID: userID), ); currentUserID = userID; currentToken = token; selfInfo = user; loggedIn = true; notifyListeners(); // SDK 登录期间会话同步回调可能先于 loggedIn=true 触发,refreshConversations // 会因登录态守卫而提前返回,导致会话列表停在空态。登录完成后等会话同步 // (onSyncServerFinish 置 conversationsLoaded)真正结束再刷新一次。 await _waitSyncThenRefresh(); } /// 等待 SDK 会话同步完成(最多约 10s),随后拉取会话列表。 Future _waitSyncThenRefresh() async { for (var i = 0; i < 12; i++) { if (conversationsLoaded) { await refreshConversations(); return; } await Future.delayed(const Duration(milliseconds: 800)); } // 超时兜底:仍补一次(refreshConversations 内部有重试) await refreshConversations(); } /// 刷新自己的资料(我的页面展示用) Future refreshSelfInfo() async { if (!loggedIn) return; try { selfInfo = await OpenIM.iMManager.userManager.getSelfUserInfo(); notifyListeners(); } catch (_) { // 拉取失败沿用内存里的旧数据 } } /// 退出登录 Future logout() async { try { await OpenIM.iMManager.logout(); } catch (_) { // 本地照常清理 } _resetLoginState(); } void _forceLogout() { _resetLoginState(); onForceLogout?.call(); } void _resetLoginState() { loggedIn = false; currentUserID = null; currentToken = null; selfInfo = null; conversations = []; conversationsLoaded = false; syncing = true; syncFailed = false; notifyListeners(); } /// 重新拉取全部会话并排序(置顶在前,其余按最新消息时间倒序)。 /// SDK 登录刚完成时立刻调用可能拿到 10009「登录未完成」,这里做短重试。 Future refreshConversations() async { if (!loggedIn) return; for (var attempt = 0; attempt < 5; attempt++) { try { final list = await OpenIM.iMManager.conversationManager.getAllConversationList(); list.sort((a, b) { final ap = a.isPinned == true ? 0 : 1; final bp = b.isPinned == true ? 0 : 1; if (ap != bp) return ap - bp; return (b.latestMsgSendTime ?? 0).compareTo(a.latestMsgSendTime ?? 0); }); conversations = list; conversationsLoaded = true; notifyListeners(); return; } catch (_) { // 登录尚未完成或瞬时失败,短暂等待后重试 await Future.delayed(const Duration(milliseconds: 800)); } } } /// 进入聊天页后清除该会话未读 Future markConversationRead(String conversationID) async { try { await OpenIM.iMManager.conversationManager.markConversationMessageAsRead(conversationID: conversationID); } catch (_) { // 标记失败不影响聊天 } } /// 解析「仅在线」自定义消息里的通话信令,不是通话信令返回 null static SignalingPayload? parseSignaling(Message msg) { try { final data = msg.customElem?.data; if (data == null || data.isEmpty) return null; final map = jsonDecode(data) as Map; final customType = map['customType']; if (customType is! int || customType < 200 || customType > 204) return null; final payload = SignalingPayload( type: customType, invitation: InvitationInfo.fromJson(Map.from(map['data'] ?? {})), ); return payload; } catch (_) { return null; } } } /// 一条通话信令 class SignalingPayload { /// 见 [SignalingType] final int type; final InvitationInfo invitation; SignalingPayload({required this.type, required this.invitation}); /// 房间号 String? get roomID => invitation.roomID; /// 呼叫发起人 String? get inviterUserID => invitation.inviterUserID; }