- contacts_screen 用 f.friendUserID(SDK 恒为空)打开会话,导致建出 si__<空对方> 会话,所有消息发给空 userID 全部失败;改用 f.userID - login 后补一次 refreshConversations:SDK 登录期间同步回调先于 loggedIn=true 触发,refreshConversations 因登录态守卫提前返回, 会话列表永久停在空态 双机实测:修复前 sendMessage userID='' 失败;修复后 A→B 消息 正常送达(B 端 onRecvNewMessage 收到 fixverify999) Co-authored-by: multica-agent <github@multica.ai>
360 lines
12 KiB
Dart
360 lines
12 KiB
Dart
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<String> 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<File>()
|
||
.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<ConversationInfo> conversations = [];
|
||
|
||
/// 当前登录用户信息
|
||
UserInfo? selfInfo;
|
||
|
||
/// 当前登录凭证(发起通话取 LiveKit token 时要用)
|
||
String? currentUserID;
|
||
String? currentToken;
|
||
|
||
/// 被踢下线 / token 失效时的回调(由 main.dart 设置:清缓存、回登录页)
|
||
void Function()? onForceLogout;
|
||
|
||
/// 收到新消息(聊天页订阅)
|
||
final StreamController<Message> _newMsgController = StreamController<Message>.broadcast();
|
||
Stream<Message> get onNewMessage => _newMsgController.stream;
|
||
|
||
/// 消息被撤回(撤回方的 clientMsgID,聊天页订阅)
|
||
final StreamController<String> _revokeController = StreamController<String>.broadcast();
|
||
Stream<String> get onMessageRevoked => _revokeController.stream;
|
||
|
||
/// 收到通话信令(仅在线自定义消息,通话模块订阅)
|
||
final StreamController<Message> _signalingController = StreamController<Message>.broadcast();
|
||
Stream<Message> get onSignaling => _signalingController.stream;
|
||
|
||
int get platformID => Platform.isIOS ? IMPlatform.ios : IMPlatform.android;
|
||
|
||
/// App 启动时初始化 SDK(只做一次)。
|
||
/// 用共享 Future 防止超时后重复触发 initSDK:第一次调用还在跑时,
|
||
/// 后续调用直接复用同一个 Future;失败了则允许下次重试。
|
||
Future<void>? _initFuture;
|
||
|
||
Future<void> 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<void> _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<void> 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
|
||
// 会因登录态守卫而提前返回,导致会话列表停在空态。登录完成后主动补一次刷新。
|
||
await refreshConversations();
|
||
}
|
||
|
||
/// 刷新自己的资料(我的页面展示用)
|
||
Future<void> refreshSelfInfo() async {
|
||
if (!loggedIn) return;
|
||
try {
|
||
selfInfo = await OpenIM.iMManager.userManager.getSelfUserInfo();
|
||
notifyListeners();
|
||
} catch (_) {
|
||
// 拉取失败沿用内存里的旧数据
|
||
}
|
||
}
|
||
|
||
/// 退出登录
|
||
Future<void> 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();
|
||
}
|
||
|
||
/// 重新拉取全部会话并排序(置顶在前,其余按最新消息时间倒序)
|
||
Future<void> refreshConversations() async {
|
||
if (!loggedIn) return;
|
||
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();
|
||
} catch (_) {
|
||
// 拉取失败保留现有列表
|
||
}
|
||
}
|
||
|
||
/// 进入聊天页后清除该会话未读
|
||
Future<void> 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<String, dynamic>;
|
||
final customType = map['customType'];
|
||
if (customType is! int || customType < 200 || customType > 204) return null;
|
||
final payload = SignalingPayload(
|
||
type: customType,
|
||
invitation: InvitationInfo.fromJson(Map<String, dynamic>.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;
|
||
}
|