Files
tongxunruanjian/mobile/lib/services/im_service.dart
T
KIMI a8cb84f4c7 fix: 登录失败自动自检三个服务端口并展示 SDK 原始错误(B-58)
- IMService 记录 onConnectFailed 的 code/error(lastConnectError)
- SDK 未初始化成功时 login 直接抛错,不再无限等待
- 登录页失败提示下附自检结果:账号服务/消息接口/消息长连接 通不通
- 版本号 1.0.2+3
2026-08-15 15:12:20 +08:00

314 lines
10 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;
/// 是否已登录
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;
});
}
Future<void> _doInit() async {
final dir = await getApplicationDocumentsDirectory();
final dataDir = '${dir.path}/openim';
await Directory(dataDir).create(recursive: true);
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,
),
);
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();
}
/// 刷新自己的资料(我的页面展示用)
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;
}