Files
tongxunruanjian/mobile/lib/services/im_service.dart
T
编码工程师andmultica-agent d6ef6eb9a4 fix(mobile): 通讯录进会话空对方 + 登录后会话列表空态(B-52 无法发送消息根因)
- 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>
2026-08-16 02:14:13 +08:00

360 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}