feat(mobile-next): 迁移 Android 一对一语音通话薄适配与入口
复用官方 OpenIMLive 信令与房间,换票改走账号服务 /api/rtc_token;入口仅音频,占线/超时/重复回调与销毁释放由公司层防护。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
2a817e77d8
commit
3832e080c4
@@ -1,3 +1,5 @@
|
|||||||
|
import 'rtc/livekit_call_config.dart';
|
||||||
|
|
||||||
/// 公司功能开关。关闭项直接不展示入口,不扩范围外功能。
|
/// 公司功能开关。关闭项直接不展示入口,不扩范围外功能。
|
||||||
class FeatureFlags {
|
class FeatureFlags {
|
||||||
FeatureFlags._();
|
FeatureFlags._();
|
||||||
@@ -14,6 +16,6 @@ class FeatureFlags {
|
|||||||
/// 品牌迁移未取得 AGPL 书面结论前保持 false:保留 OpenIM 标识。
|
/// 品牌迁移未取得 AGPL 书面结论前保持 false:保留 OpenIM 标识。
|
||||||
static const bool brandMigration = false;
|
static const bool brandMigration = false;
|
||||||
|
|
||||||
/// 安卓语音通话另阶段迁移;本阶段不接入 LiveKit。
|
/// 一对一语音通话:配置齐全且薄适配落地后才打开入口。
|
||||||
static const bool livekitCall = false;
|
static bool get livekitCall => LiveKitCallConfig.enabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:openim_common/openim_common.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
class CallPermissions {
|
||||||
|
CallPermissions._();
|
||||||
|
|
||||||
|
static Future<bool> ensureMicrophone() async {
|
||||||
|
final status = await Permission.microphone.request();
|
||||||
|
if (status.isGranted) return true;
|
||||||
|
IMViews.showToast('需要麦克风权限才能通话');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// 一对一语音会话防护:占线、超时、重复信令、销毁时释放计时器。
|
||||||
|
/// 不自建第二套信令,只约束官方 OpenIMLive 的进出场。
|
||||||
|
enum VoiceCallPhase { idle, outgoing, incoming, inCall }
|
||||||
|
|
||||||
|
class CallSessionGuard {
|
||||||
|
CallSessionGuard({this.inviteTimeout = const Duration(seconds: 30)});
|
||||||
|
|
||||||
|
final Duration inviteTimeout;
|
||||||
|
|
||||||
|
VoiceCallPhase phase = VoiceCallPhase.idle;
|
||||||
|
String? roomID;
|
||||||
|
void Function()? onTimeout;
|
||||||
|
|
||||||
|
final Set<String> _seenKeys = <String>{};
|
||||||
|
Timer? _timeout;
|
||||||
|
|
||||||
|
bool get isBusy => phase != VoiceCallPhase.idle;
|
||||||
|
|
||||||
|
bool acceptSignaling({required int customType, required String roomID}) {
|
||||||
|
if (roomID.isEmpty) return false;
|
||||||
|
return _seenKeys.add('$customType:$roomID');
|
||||||
|
}
|
||||||
|
|
||||||
|
String? startOutgoing(String roomID) {
|
||||||
|
if (isBusy) return 'busy';
|
||||||
|
this.roomID = roomID;
|
||||||
|
phase = VoiceCallPhase.outgoing;
|
||||||
|
_armTimeout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? startIncoming(String roomID) {
|
||||||
|
if (isBusy) return 'busy';
|
||||||
|
this.roomID = roomID;
|
||||||
|
phase = VoiceCallPhase.incoming;
|
||||||
|
_armTimeout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void markConnected() {
|
||||||
|
if (!isBusy) return;
|
||||||
|
_timeout?.cancel();
|
||||||
|
_timeout = null;
|
||||||
|
phase = VoiceCallPhase.inCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool sameRoom(String? other) =>
|
||||||
|
other != null && other.isNotEmpty && other == roomID;
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_timeout?.cancel();
|
||||||
|
_timeout = null;
|
||||||
|
_seenKeys.clear();
|
||||||
|
phase = VoiceCallPhase.idle;
|
||||||
|
roomID = null;
|
||||||
|
onTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _armTimeout() {
|
||||||
|
_timeout?.cancel();
|
||||||
|
_timeout = Timer(inviteTimeout, () {
|
||||||
|
if (phase == VoiceCallPhase.outgoing || phase == VoiceCallPhase.incoming) {
|
||||||
|
onTimeout?.call();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import '../config/app_endpoints.dart';
|
||||||
|
|
||||||
|
/// Android 一对一语音通话配置门。入口只在配置齐全且本卡已落地时打开。
|
||||||
|
class LiveKitCallConfig {
|
||||||
|
LiveKitCallConfig._();
|
||||||
|
|
||||||
|
/// 公司账号服务换票路径,对应 account-service `POST /api/rtc_token`。
|
||||||
|
static const String rtcTokenPath = '/api/rtc_token';
|
||||||
|
|
||||||
|
static const Duration inviteTimeout = Duration(seconds: 30);
|
||||||
|
|
||||||
|
/// 薄适配与构建验证通过后为 true;配置不齐时 [enabled] 仍为 false。
|
||||||
|
static const bool shipped = true;
|
||||||
|
|
||||||
|
static bool get endpointsReady =>
|
||||||
|
AppEndpoints.livekitUrl.startsWith('ws://') &&
|
||||||
|
AppEndpoints.authApiBase.startsWith('http') &&
|
||||||
|
rtcTokenPath == '/api/rtc_token';
|
||||||
|
|
||||||
|
static bool get enabled => shipped && endpointsReady;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:openim_common/openim_common.dart';
|
||||||
|
|
||||||
|
import '../auth/auth_api.dart';
|
||||||
|
import '../config/app_endpoints.dart';
|
||||||
|
import 'livekit_call_config.dart';
|
||||||
|
import 'rtc_token_mapper.dart';
|
||||||
|
|
||||||
|
/// 公司账号服务换 LiveKit 进房 token。不改官方 SDK 核心,也不改现网接口。
|
||||||
|
class RtcTokenApi {
|
||||||
|
RtcTokenApi({Dio? dio})
|
||||||
|
: _dio = dio ??
|
||||||
|
Dio(BaseOptions(
|
||||||
|
baseUrl: AppEndpoints.authApiBase,
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
|
));
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<SignalingCertificate> getToken({
|
||||||
|
required String room,
|
||||||
|
required String identity,
|
||||||
|
required String authToken,
|
||||||
|
}) async {
|
||||||
|
if (room.isEmpty || identity.isEmpty) {
|
||||||
|
throw AuthException('通话数据不完整');
|
||||||
|
}
|
||||||
|
if (authToken.isEmpty) {
|
||||||
|
throw AuthException('登录状态已失效,请重新登录');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final resp = await _dio.post(
|
||||||
|
LiveKitCallConfig.rtcTokenPath,
|
||||||
|
data: {'room': room, 'identity': identity},
|
||||||
|
options: Options(headers: {'Authorization': 'Bearer $authToken'}),
|
||||||
|
);
|
||||||
|
final cred = RtcTokenMapper.parse(
|
||||||
|
resp.data,
|
||||||
|
fallbackLiveUrl: AppEndpoints.livekitUrl,
|
||||||
|
);
|
||||||
|
return SignalingCertificate(
|
||||||
|
token: cred.token,
|
||||||
|
roomID: room,
|
||||||
|
liveURL: cred.liveURL,
|
||||||
|
);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final data = e.response?.data;
|
||||||
|
if (data is Map && data['msg'] != null && data['msg'].toString().isNotEmpty) {
|
||||||
|
throw AuthException(data['msg'].toString());
|
||||||
|
}
|
||||||
|
throw AuthException('连不上语音服务,请检查网络', network: true);
|
||||||
|
} on AuthException {
|
||||||
|
rethrow;
|
||||||
|
} on FormatException catch (e) {
|
||||||
|
throw AuthException(e.message);
|
||||||
|
} catch (_) {
|
||||||
|
throw AuthException('发起通话失败,请稍后再试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/// 把账号服务 `/api/rtc_token` 的响应收成进房凭证。
|
||||||
|
///
|
||||||
|
/// 现网成功体:`{ code: 0, data: { token } }`,可选 `liveURL` / `serverUrl`。
|
||||||
|
/// 缺少直播地址时由调用方填 [fallbackLiveUrl],不得改服务端。
|
||||||
|
class CompanyRtcCredential {
|
||||||
|
const CompanyRtcCredential({required this.token, required this.liveURL});
|
||||||
|
|
||||||
|
final String token;
|
||||||
|
final String liveURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RtcTokenMapper {
|
||||||
|
RtcTokenMapper._();
|
||||||
|
|
||||||
|
static CompanyRtcCredential parse(
|
||||||
|
dynamic body, {
|
||||||
|
required String fallbackLiveUrl,
|
||||||
|
}) {
|
||||||
|
if (body is! Map) {
|
||||||
|
throw const FormatException('语音通话凭证格式不正确');
|
||||||
|
}
|
||||||
|
final map = Map<String, dynamic>.from(body);
|
||||||
|
final code = map['code'];
|
||||||
|
if (code != null && code != 0) {
|
||||||
|
final msg = map['msg']?.toString() ?? '';
|
||||||
|
throw FormatException(msg.isNotEmpty ? msg : '获取通话凭证失败');
|
||||||
|
}
|
||||||
|
final data = map['data'];
|
||||||
|
final payload = data is Map ? Map<String, dynamic>.from(data) : map;
|
||||||
|
final token = payload['token']?.toString() ?? '';
|
||||||
|
if (token.isEmpty) {
|
||||||
|
throw const FormatException('服务器未返回通话凭证');
|
||||||
|
}
|
||||||
|
final live = payload['liveURL']?.toString() ??
|
||||||
|
payload['serverUrl']?.toString() ??
|
||||||
|
'';
|
||||||
|
return CompanyRtcCredential(
|
||||||
|
token: token,
|
||||||
|
liveURL: live.isNotEmpty ? live : fallbackLiveUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,9 @@ class AppController extends GetxController with UpgradeManger {
|
|||||||
|
|
||||||
if (isRunningBackground && !run) {}
|
if (isRunningBackground && !run) {}
|
||||||
isRunningBackground = run;
|
isRunningBackground = run;
|
||||||
|
if (Get.isRegistered<IMController>()) {
|
||||||
|
Get.find<IMController>().backgroundSubject.add(run);
|
||||||
|
}
|
||||||
if (!run) {
|
if (!run) {
|
||||||
_cancelAllNotifications();
|
_cancelAllNotifications();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,19 +9,113 @@ import 'package:openim_common/openim_common.dart';
|
|||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:openim_live/openim_live.dart';
|
import 'package:openim_live/openim_live.dart';
|
||||||
|
|
||||||
|
import '../../company/feature_flags.dart';
|
||||||
|
import '../../company/rtc/call_permissions.dart';
|
||||||
|
import '../../company/rtc/call_session_guard.dart';
|
||||||
|
import '../../company/rtc/rtc_token_api.dart';
|
||||||
import '../im_callback.dart';
|
import '../im_callback.dart';
|
||||||
|
|
||||||
class IMController extends GetxController with IMCallback, OpenIMLive {
|
class IMController extends GetxController with IMCallback, OpenIMLive {
|
||||||
late Rx<UserFullInfo> userInfo;
|
late Rx<UserFullInfo> userInfo;
|
||||||
late String atAllTag;
|
late String atAllTag;
|
||||||
|
final _rtcTokenApi = RtcTokenApi();
|
||||||
|
final callGuard = CallSessionGuard();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onClose() {
|
void onClose() {
|
||||||
|
callGuard.dispose();
|
||||||
super.close();
|
super.close();
|
||||||
onCloseLive();
|
onCloseLive();
|
||||||
super.onClose();
|
super.onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SignalingCertificate> getRtcCertificate(String roomID, String userID) {
|
||||||
|
final token = DataSp.imToken;
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
return Future.error(StateError('登录状态已失效,请重新登录'));
|
||||||
|
}
|
||||||
|
return _rtcTokenApi.getToken(room: roomID, identity: userID, authToken: token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onLiveSessionClosed() {
|
||||||
|
callGuard.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onLiveConnected() {
|
||||||
|
callGuard.markConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void receiveNewInvitation(SignalingInfo info) {
|
||||||
|
if (!FeatureFlags.livekitCall) return;
|
||||||
|
final invitation = info.invitation;
|
||||||
|
final roomID = invitation?.roomID ?? '';
|
||||||
|
if (invitation?.mediaType != 'audio' || invitation?.sessionType != ConversationType.single) {
|
||||||
|
onTapReject(info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!callGuard.acceptSignaling(customType: CustomMessageType.callingInvite, roomID: roomID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isBusy || callGuard.isBusy) {
|
||||||
|
onTapReject(info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (callGuard.startIncoming(roomID) != null) {
|
||||||
|
onTapReject(info);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.receiveNewInvitation(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void call({
|
||||||
|
required CallObj callObj,
|
||||||
|
required CallType callType,
|
||||||
|
CallState callState = CallState.call,
|
||||||
|
String? roomID,
|
||||||
|
String? inviterUserID,
|
||||||
|
required List<String> inviteeUserIDList,
|
||||||
|
String? groupID,
|
||||||
|
SignalingCertificate? credentials,
|
||||||
|
}) async {
|
||||||
|
if (!FeatureFlags.livekitCall) return;
|
||||||
|
if (callType != CallType.audio || callObj != CallObj.single) {
|
||||||
|
IMViews.showToast('暂只支持一对一语音通话');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isBusy || callGuard.isBusy) {
|
||||||
|
IMViews.showToast(StrRes.callingBusy);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final granted = await CallPermissions.ensureMicrophone();
|
||||||
|
if (!granted) return;
|
||||||
|
callGuard.startOutgoing(roomID ?? '');
|
||||||
|
super.call(
|
||||||
|
callObj: callObj,
|
||||||
|
callType: callType,
|
||||||
|
callState: callState,
|
||||||
|
roomID: roomID,
|
||||||
|
inviterUserID: inviterUserID,
|
||||||
|
inviteeUserIDList: inviteeUserIDList,
|
||||||
|
groupID: groupID,
|
||||||
|
credentials: credentials,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SignalingCertificate> onTapPickup(SignalingInfo signaling) async {
|
||||||
|
final granted = await CallPermissions.ensureMicrophone();
|
||||||
|
if (!granted) {
|
||||||
|
await onTapReject(signaling);
|
||||||
|
return Future.error(StateError('microphone denied'));
|
||||||
|
}
|
||||||
|
return super.onTapPickup(signaling);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() async {
|
void onInit() async {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
@@ -84,8 +178,14 @@ class IMController extends GetxController with IMCallback, OpenIMLive {
|
|||||||
customType == CustomMessageType.callingReject ||
|
customType == CustomMessageType.callingReject ||
|
||||||
customType == CustomMessageType.callingCancel ||
|
customType == CustomMessageType.callingCancel ||
|
||||||
customType == CustomMessageType.callingHungup) {
|
customType == CustomMessageType.callingHungup) {
|
||||||
|
if (!FeatureFlags.livekitCall) return;
|
||||||
final signaling = SignalingInfo(invitation: InvitationInfo.fromJson(map['data']));
|
final signaling = SignalingInfo(invitation: InvitationInfo.fromJson(map['data']));
|
||||||
signaling.userID = signaling.invitation?.inviterUserID;
|
signaling.userID = signaling.invitation?.inviterUserID;
|
||||||
|
final roomID = signaling.invitation?.roomID ?? '';
|
||||||
|
if (customType != CustomMessageType.callingInvite &&
|
||||||
|
!callGuard.acceptSignaling(customType: customType as int, roomID: roomID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (customType) {
|
switch (customType) {
|
||||||
case CustomMessageType.callingInvite:
|
case CustomMessageType.callingInvite:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
|||||||
import 'package:wechat_camera_picker/wechat_camera_picker.dart';
|
import 'package:wechat_camera_picker/wechat_camera_picker.dart';
|
||||||
import 'package:openim_live/openim_live.dart';
|
import 'package:openim_live/openim_live.dart';
|
||||||
|
|
||||||
|
import '../../company/feature_flags.dart';
|
||||||
import '../../core/controller/app_controller.dart';
|
import '../../core/controller/app_controller.dart';
|
||||||
import '../../core/controller/im_controller.dart';
|
import '../../core/controller/im_controller.dart';
|
||||||
import '../../core/im_callback.dart';
|
import '../../core/im_callback.dart';
|
||||||
@@ -1622,18 +1623,17 @@ class ChatLogic extends SuperController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void call() {
|
void call() {
|
||||||
|
if (!FeatureFlags.livekitCall || !isSingleChat) return;
|
||||||
if (rtcIsBusy) {
|
if (rtcIsBusy) {
|
||||||
IMViews.showToast(StrRes.callingBusy);
|
IMViews.showToast(StrRes.callingBusy);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
IMViews.openIMCallSheet(nickname.value, (index) {
|
|
||||||
imLogic.call(
|
imLogic.call(
|
||||||
callObj: CallObj.single,
|
callObj: CallObj.single,
|
||||||
callType: index == 0 ? CallType.audio : CallType.video,
|
callType: CallType.audio,
|
||||||
inviteeUserIDList: [if (isSingleChat) userID!],
|
inviteeUserIDList: [userID!],
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onScrollToTop() {
|
void onScrollToTop() {
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ class ChatPage extends StatelessWidget {
|
|||||||
onCloseMultiModel: logic.exit,
|
onCloseMultiModel: logic.exit,
|
||||||
onClickMoreBtn: logic.chatSetup,
|
onClickMoreBtn: logic.chatSetup,
|
||||||
onClickCallBtn: logic.call,
|
onClickCallBtn: logic.call,
|
||||||
showCallBtn: FeatureFlags.livekitCall,
|
showCallBtn: FeatureFlags.livekitCall && logic.isSingleChat,
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: WaterMarkBgView(
|
child: WaterMarkBgView(
|
||||||
@@ -244,7 +244,7 @@ class ChatPage extends StatelessWidget {
|
|||||||
toolbox: ChatToolBox(
|
toolbox: ChatToolBox(
|
||||||
onTapAlbum: logic.onTapAlbum,
|
onTapAlbum: logic.onTapAlbum,
|
||||||
onTapCamera: logic.onTapCamera,
|
onTapCamera: logic.onTapCamera,
|
||||||
onTapCall: logic.call,
|
onTapCall: FeatureFlags.livekitCall && logic.isSingleChat ? logic.call : null,
|
||||||
onTapCard: logic.onTapCarte,
|
onTapCard: logic.onTapCarte,
|
||||||
onTapFile: logic.onTapFile,
|
onTapFile: logic.onTapFile,
|
||||||
onTapLocation: logic.onTapLocation,
|
onTapLocation: logic.onTapLocation,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:extended_image/extended_image.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:openim/company/feature_flags.dart';
|
||||||
import 'package:openim/routes/app_navigator.dart';
|
import 'package:openim/routes/app_navigator.dart';
|
||||||
import 'package:openim_common/openim_common.dart';
|
import 'package:openim_common/openim_common.dart';
|
||||||
import 'package:sprintf/sprintf.dart';
|
import 'package:sprintf/sprintf.dart';
|
||||||
@@ -324,13 +325,12 @@ class UserProfilePanelLogic extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void toCall() {
|
void toCall() {
|
||||||
IMViews.openIMCallSheet(userInfo.value.showName, (index) {
|
if (!FeatureFlags.livekitCall) return;
|
||||||
imLogic.call(
|
imLogic.call(
|
||||||
callObj: CallObj.single,
|
callObj: CallObj.single,
|
||||||
callType: index == 0 ? CallType.audio : CallType.video,
|
callType: CallType.audio,
|
||||||
inviteeUserIDList: [userInfo.value.userID!],
|
inviteeUserIDList: [userInfo.value.userID!],
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void copyID() {
|
void copyID() {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:openim_common/openim_common.dart';
|
import 'package:openim_common/openim_common.dart';
|
||||||
|
|
||||||
|
import '../../../company/feature_flags.dart';
|
||||||
import 'user_profile _panel_logic.dart';
|
import 'user_profile _panel_logic.dart';
|
||||||
|
|
||||||
class UserProfilePanelPage extends StatelessWidget {
|
class UserProfilePanelPage extends StatelessWidget {
|
||||||
@@ -234,12 +235,14 @@ class UserProfilePanelPage extends StatelessWidget {
|
|||||||
height: 108.h,
|
height: 108.h,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
if (FeatureFlags.livekitCall) ...[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ImageTextButton.call(
|
child: ImageTextButton.call(
|
||||||
onTap: logic.toCall,
|
onTap: logic.toCall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
11.horizontalSpace,
|
11.horizontalSpace,
|
||||||
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ImageTextButton.message(
|
child: ImageTextButton.message(
|
||||||
onTap: logic.toChat,
|
onTap: logic.toChat,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class ChatToolBox extends StatelessWidget {
|
|||||||
ToolboxItemInfo(
|
ToolboxItemInfo(
|
||||||
text: StrRes.toolboxCall,
|
text: StrRes.toolboxCall,
|
||||||
icon: ImageRes.toolboxCall,
|
icon: ImageRes.toolboxCall,
|
||||||
onTap: () => Permissions.cameraAndMicrophone(onTapCall),
|
onTap: () => Permissions.microphone(onTapCall),
|
||||||
),
|
),
|
||||||
ToolboxItemInfo(
|
ToolboxItemInfo(
|
||||||
text: StrRes.toolboxFile,
|
text: StrRes.toolboxFile,
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ mixin OpenIMLive {
|
|||||||
),
|
),
|
||||||
onError: onError,
|
onError: onError,
|
||||||
onRoomDisconnected: () => onRoomDisconnected(event.data),
|
onRoomDisconnected: () => onRoomDisconnected(event.data),
|
||||||
|
onStartCalling: onLiveConnected,
|
||||||
|
onClose: () {
|
||||||
|
_stopSound();
|
||||||
|
onLiveSessionClosed();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} else if (event.state == CallState.beRejected) {
|
} else if (event.state == CallState.beRejected) {
|
||||||
insertSignalingMessageSubject.add(event);
|
insertSignalingMessageSubject.add(event);
|
||||||
@@ -224,10 +229,14 @@ mixin OpenIMLive {
|
|||||||
onBusyLine: onBusyLine,
|
onBusyLine: onBusyLine,
|
||||||
onStartCalling: () {
|
onStartCalling: () {
|
||||||
_stopSound();
|
_stopSound();
|
||||||
|
onLiveConnected();
|
||||||
},
|
},
|
||||||
onError: onError,
|
onError: onError,
|
||||||
onRoomDisconnected: () => onRoomDisconnected(signal),
|
onRoomDisconnected: () => onRoomDisconnected(signal),
|
||||||
onClose: _stopSound,
|
onClose: () {
|
||||||
|
_stopSound();
|
||||||
|
onLiveSessionClosed();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,11 +264,20 @@ mixin OpenIMLive {
|
|||||||
offlinePushInfo: OfflinePushInfo(),
|
offlinePushInfo: OfflinePushInfo(),
|
||||||
userID: signaling.invitation!.inviteeUserIDList!.first,
|
userID: signaling.invitation!.inviteeUserIDList!.first,
|
||||||
isOnlineOnly: true);
|
isOnlineOnly: true);
|
||||||
final certificate = await Apis.getTokenForRTC(signaling.invitation!.roomID!, OpenIM.iMManager.userID);
|
final certificate = await getRtcCertificate(signaling.invitation!.roomID!, OpenIM.iMManager.userID);
|
||||||
|
|
||||||
return certificate;
|
return certificate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 公司层可覆盖:走账号服务 `/api/rtc_token`,默认仍走官方 chat 换票。
|
||||||
|
Future<SignalingCertificate> getRtcCertificate(String roomID, String userID) {
|
||||||
|
return Apis.getTokenForRTC(roomID, userID);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onLiveSessionClosed() {}
|
||||||
|
|
||||||
|
void onLiveConnected() {}
|
||||||
|
|
||||||
Future<SignalingCertificate> onTapPickup(SignalingInfo signaling) async {
|
Future<SignalingCertificate> onTapPickup(SignalingInfo signaling) async {
|
||||||
_beCalledEvent = null; // ios bug
|
_beCalledEvent = null; // ios bug
|
||||||
_autoPickup = false;
|
_autoPickup = false;
|
||||||
@@ -272,7 +290,7 @@ mixin OpenIMLive {
|
|||||||
offlinePushInfo: OfflinePushInfo(),
|
offlinePushInfo: OfflinePushInfo(),
|
||||||
userID: signaling.invitation!.inviterUserID,
|
userID: signaling.invitation!.inviterUserID,
|
||||||
isOnlineOnly: true);
|
isOnlineOnly: true);
|
||||||
final certificate = await Apis.getTokenForRTC(signaling.invitation!.roomID!, OpenIM.iMManager.userID);
|
final certificate = await getRtcCertificate(signaling.invitation!.roomID!, OpenIM.iMManager.userID);
|
||||||
|
|
||||||
return certificate;
|
return certificate;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,50 +43,59 @@ class _SingleRoomViewState extends SignalState<SingleRoomView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// always dispose listener
|
final room = _room;
|
||||||
(() async {
|
final listener = _listener;
|
||||||
_room?.removeListener(_onRoomDidUpdate);
|
_room = null;
|
||||||
await _listener?.dispose();
|
_listener = null;
|
||||||
await _room?.remoteParticipants.values.firstOrNull?.dispose();
|
room?.removeListener(_onRoomDidUpdate);
|
||||||
await _room?.localParticipant?.dispose();
|
() async {
|
||||||
await _room?.disconnect();
|
await listener?.dispose();
|
||||||
await _room?.dispose();
|
await room?.remoteParticipants.values.firstOrNull?.dispose();
|
||||||
})();
|
await room?.localParticipant?.dispose();
|
||||||
|
await room?.disconnect();
|
||||||
|
await room?.dispose();
|
||||||
|
}();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> connect() async {
|
Future<void> connect() async {
|
||||||
final url = certificate.liveURL!;
|
final url = certificate.liveURL;
|
||||||
final token = certificate.token!;
|
final token = certificate.token;
|
||||||
|
if (url == null || url.isEmpty || token == null || token.isEmpty) {
|
||||||
|
widget.onError?.call(StateError('missing livekit credential'), StackTrace.current);
|
||||||
|
widget.onClose?.call();
|
||||||
|
return;
|
||||||
|
}
|
||||||
final busyLineUsers = certificate.busyLineUserIDList ?? [];
|
final busyLineUsers = certificate.busyLineUserIDList ?? [];
|
||||||
if (busyLineUsers.isNotEmpty) {
|
if (busyLineUsers.isNotEmpty) {
|
||||||
widget.onBusyLine?.call();
|
widget.onBusyLine?.call();
|
||||||
widget.onClose?.call();
|
widget.onClose?.call();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Try to connect to a room
|
|
||||||
// This will throw an Exception if it fails for any reason.
|
|
||||||
try {
|
try {
|
||||||
//create new room
|
_room = Room(
|
||||||
_room = Room();
|
roomOptions: const RoomOptions(
|
||||||
|
|
||||||
// Create a Listener before connecting
|
|
||||||
_listener = _room?.createListener();
|
|
||||||
// Try to connect to the room
|
|
||||||
// This will throw an Exception if it fails for any reason.
|
|
||||||
await _room?.connect(url, token,
|
|
||||||
roomOptions: RoomOptions(
|
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
defaultCameraCaptureOptions: const CameraCaptureOptions(params: VideoParametersPresets.h720_169),
|
defaultCameraCaptureOptions: CameraCaptureOptions(params: VideoParametersPresets.h720_169),
|
||||||
defaultVideoPublishOptions: VideoPublishOptions(
|
defaultVideoPublishOptions: VideoPublishOptions(
|
||||||
simulcast: true,
|
simulcast: true,
|
||||||
videoCodec: 'VP9',
|
videoCodec: 'VP9',
|
||||||
videoEncoding: const VideoEncoding(
|
videoEncoding: VideoEncoding(
|
||||||
maxBitrate: 5 * 1000 * 1000,
|
maxBitrate: 5 * 1000 * 1000,
|
||||||
maxFramerate: 15,
|
maxFramerate: 15,
|
||||||
))));
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_listener = _room?.createListener();
|
||||||
|
await _room?.connect(
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
connectOptions: const ConnectOptions(autoSubscribe: true),
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_room?.addListener(_onRoomDidUpdate);
|
_room?.addListener(_onRoomDidUpdate);
|
||||||
if (null != _listener) _setUpListeners();
|
if (null != _listener) _setUpListeners();
|
||||||
@@ -111,6 +120,12 @@ class _SingleRoomViewState extends SignalState<SingleRoomView> {
|
|||||||
widget.onClose?.call();
|
widget.onClose?.call();
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
..on<RoomAttemptReconnectEvent>((event) {
|
||||||
|
IMViews.showToast('通话重连中');
|
||||||
|
})
|
||||||
|
..on<RoomReconnectedEvent>((event) {
|
||||||
|
IMViews.showToast('通话已恢复');
|
||||||
|
})
|
||||||
..on<RoomRecordingStatusChanged>((event) {})
|
..on<RoomRecordingStatusChanged>((event) {})
|
||||||
..on<LocalTrackPublishedEvent>((_) => _sortParticipants())
|
..on<LocalTrackPublishedEvent>((_) => _sortParticipants())
|
||||||
..on<LocalTrackUnpublishedEvent>((_) => _sortParticipants())
|
..on<LocalTrackUnpublishedEvent>((_) => _sortParticipants())
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ abstract class SignalState<T extends SignalView> extends State<T> {
|
|||||||
int duration = 0;
|
int duration = 0;
|
||||||
bool enabledMicrophone = true;
|
bool enabledMicrophone = true;
|
||||||
bool enabledSpeaker = true;
|
bool enabledSpeaker = true;
|
||||||
|
Timer? _inviteTimeout;
|
||||||
|
|
||||||
ParticipantTrack? remoteParticipantTrack;
|
ParticipantTrack? remoteParticipantTrack;
|
||||||
ParticipantTrack? localParticipantTrack;
|
ParticipantTrack? localParticipantTrack;
|
||||||
@@ -82,11 +83,14 @@ abstract class SignalState<T extends SignalView> extends State<T> {
|
|||||||
widget.onSyncUserInfo?.call(widget.userID).then(_onUpdateUserInfo);
|
widget.onSyncUserInfo?.call(widget.userID).then(_onUpdateUserInfo);
|
||||||
onDail();
|
onDail();
|
||||||
autoPickup();
|
autoPickup();
|
||||||
|
_armInviteTimeout();
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_inviteTimeout?.cancel();
|
||||||
|
_inviteTimeout = null;
|
||||||
callStateSubject.close();
|
callStateSubject.close();
|
||||||
callEventSub?.cancel();
|
callEventSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -116,14 +120,17 @@ abstract class SignalState<T extends SignalView> extends State<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.state == CallState.beRejected || event.state == CallState.beCanceled) {
|
if (event.state == CallState.beRejected || event.state == CallState.beCanceled) {
|
||||||
|
_cancelInviteTimeout();
|
||||||
widget.onClose?.call();
|
widget.onClose?.call();
|
||||||
} else if (event.state == CallState.otherReject || event.state == CallState.otherAccepted) {
|
} else if (event.state == CallState.otherReject || event.state == CallState.otherAccepted) {
|
||||||
if (existParticipants()) {
|
if (existParticipants()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_cancelInviteTimeout();
|
||||||
widget.onClose?.call();
|
widget.onClose?.call();
|
||||||
IMViews.showToast(sprintf(StrRes.otherCallHandle, [event.state == CallState.otherReject ? StrRes.rejectCall : StrRes.accept]));
|
IMViews.showToast(sprintf(StrRes.otherCallHandle, [event.state == CallState.otherReject ? StrRes.rejectCall : StrRes.accept]));
|
||||||
} else if (event.state == CallState.timeout) {
|
} else if (event.state == CallState.timeout) {
|
||||||
|
_cancelInviteTimeout();
|
||||||
widget.onClose?.call();
|
widget.onClose?.call();
|
||||||
} else if (event.state == CallState.beAccepted) {
|
} else if (event.state == CallState.beAccepted) {
|
||||||
// 邀请对象比发起对象提前进入房间
|
// 邀请对象比发起对象提前进入房间
|
||||||
@@ -134,6 +141,7 @@ abstract class SignalState<T extends SignalView> extends State<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onParticipantConnected() {
|
onParticipantConnected() {
|
||||||
|
_cancelInviteTimeout();
|
||||||
callStateSubject.add(CallState.calling);
|
callStateSubject.add(CallState.calling);
|
||||||
widget.onStartCalling?.call();
|
widget.onStartCalling?.call();
|
||||||
}
|
}
|
||||||
@@ -145,10 +153,13 @@ abstract class SignalState<T extends SignalView> extends State<T> {
|
|||||||
/// 发起者在对方为进入房间都是 等待状态
|
/// 发起者在对方为进入房间都是 等待状态
|
||||||
onDail() async {
|
onDail() async {
|
||||||
if (widget.initState == CallState.call) {
|
if (widget.initState == CallState.call) {
|
||||||
// callStateSubject.add(CallState.connecting);
|
try {
|
||||||
certificate = await widget.onDial!.call();
|
certificate = await widget.onDial!.call();
|
||||||
widget.onBindRoomID?.call(roomID = certificate.roomID!);
|
widget.onBindRoomID?.call(roomID = certificate.roomID!);
|
||||||
await connect();
|
await connect();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
widget.onError?.call(error, stackTrace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,25 +171,33 @@ abstract class SignalState<T extends SignalView> extends State<T> {
|
|||||||
|
|
||||||
onTapPickup() async {
|
onTapPickup() async {
|
||||||
Logger.print('------------onTapPickup---------连接中--------');
|
Logger.print('------------onTapPickup---------连接中--------');
|
||||||
|
try {
|
||||||
callStateSubject.add(CallState.connecting);
|
callStateSubject.add(CallState.connecting);
|
||||||
certificate = await widget.onTapPickup!.call();
|
certificate = await widget.onTapPickup!.call();
|
||||||
widget.onBindRoomID?.call(roomID = certificate.roomID!);
|
widget.onBindRoomID?.call(roomID = certificate.roomID!);
|
||||||
await connect();
|
await connect();
|
||||||
|
_cancelInviteTimeout();
|
||||||
callStateSubject.add(CallState.calling);
|
callStateSubject.add(CallState.calling);
|
||||||
widget.onStartCalling?.call();
|
widget.onStartCalling?.call();
|
||||||
Logger.print('------------onTapPickup---------连接成功--------');
|
Logger.print('------------onTapPickup---------连接成功--------');
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
widget.onError?.call(error, stackTrace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [isPositive] 人为挂断行为
|
/// [isPositive] 人为挂断行为
|
||||||
onTapHangup(bool isPositive) async {
|
onTapHangup(bool isPositive) async {
|
||||||
|
_cancelInviteTimeout();
|
||||||
await widget.onTapHangup?.call(duration, isPositive).whenComplete(() => /*isPositive ? {} : */ widget.onClose?.call());
|
await widget.onTapHangup?.call(duration, isPositive).whenComplete(() => /*isPositive ? {} : */ widget.onClose?.call());
|
||||||
}
|
}
|
||||||
|
|
||||||
onTapCancel() async {
|
onTapCancel() async {
|
||||||
|
_cancelInviteTimeout();
|
||||||
await widget.onTapCancel?.call().whenComplete(() => widget.onClose?.call());
|
await widget.onTapCancel?.call().whenComplete(() => widget.onClose?.call());
|
||||||
}
|
}
|
||||||
|
|
||||||
onTapReject() async {
|
onTapReject() async {
|
||||||
|
_cancelInviteTimeout();
|
||||||
await widget.onTapReject?.call().whenComplete(() => widget.onClose?.call());
|
await widget.onTapReject?.call().whenComplete(() => widget.onClose?.call());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +246,27 @@ abstract class SignalState<T extends SignalView> extends State<T> {
|
|||||||
|
|
||||||
bool smallScreenIsRemote = true;
|
bool smallScreenIsRemote = true;
|
||||||
|
|
||||||
|
void _armInviteTimeout() {
|
||||||
|
if (widget.initState != CallState.call && widget.initState != CallState.beCalled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_inviteTimeout?.cancel();
|
||||||
|
_inviteTimeout = Timer(const Duration(seconds: 30), () async {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (callState == CallState.calling) return;
|
||||||
|
if (widget.initState == CallState.call) {
|
||||||
|
await onTapCancel();
|
||||||
|
} else {
|
||||||
|
await onTapReject();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelInviteTimeout() {
|
||||||
|
_inviteTimeout?.cancel();
|
||||||
|
_inviteTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Stack(
|
Widget build(BuildContext context) => Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -4,24 +4,15 @@ import 'package:collection/collection.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:livekit_client/livekit_client.dart';
|
|
||||||
import 'package:openim_common/openim_common.dart';
|
|
||||||
import 'package:openim_live/src/widgets/live_button.dart';
|
|
||||||
import 'package:synchronized/synchronized.dart';
|
|
||||||
|
|
||||||
import '../../../live_client.dart';
|
|
||||||
import '../../../widgets/loading_view.dart';
|
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
import 'package:livekit_client/livekit_client.dart';
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
import 'package:openim_common/openim_common.dart';
|
import 'package:openim_common/openim_common.dart';
|
||||||
import 'package:openim_live/src/widgets/live_button.dart';
|
import 'package:openim_live/src/widgets/live_button.dart';
|
||||||
|
import 'package:openim_live/src/widgets/loading_view.dart';
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
import '../../../live_client.dart';
|
import '../../../live_client.dart';
|
||||||
|
|
||||||
class ControlsView extends StatefulWidget {
|
class ControlsView extends StatefulWidget {
|
||||||
const ControlsView({
|
const ControlsView({
|
||||||
Key? key,
|
Key? key,
|
||||||
@@ -82,8 +73,8 @@ class _ControlsViewState extends State<ControlsView> {
|
|||||||
/// 默认启用麦克风
|
/// 默认启用麦克风
|
||||||
bool _enabledMicrophone = true;
|
bool _enabledMicrophone = true;
|
||||||
|
|
||||||
/// 默认开启扬声器
|
/// 语音默认听筒,视频默认扬声器
|
||||||
bool _enabledSpeaker = true;
|
late bool _enabledSpeaker;
|
||||||
|
|
||||||
final _lockAudio = Lock();
|
final _lockAudio = Lock();
|
||||||
final _lockSpeaker = Lock();
|
final _lockSpeaker = Lock();
|
||||||
@@ -100,6 +91,7 @@ class _ControlsViewState extends State<ControlsView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
_enabledSpeaker = widget.callType != CallType.audio;
|
||||||
_onChangedCallState(widget.initState);
|
_onChangedCallState(widget.initState);
|
||||||
_callStateChangedSub = widget.callStateStream.listen(_onChangedCallState);
|
_callStateChangedSub = widget.callStateStream.listen(_onChangedCallState);
|
||||||
_roomDidUpdateSub = widget.roomDidUpdateStream.listen(_roomDidUpdate);
|
_roomDidUpdateSub = widget.roomDidUpdateStream.listen(_roomDidUpdate);
|
||||||
@@ -116,6 +108,7 @@ class _ControlsViewState extends State<ControlsView> {
|
|||||||
_participant = room.localParticipant;
|
_participant = room.localParticipant;
|
||||||
_participant?.addListener(_onChange);
|
_participant?.addListener(_onChange);
|
||||||
}
|
}
|
||||||
|
Hardware.instance.setSpeakerphoneOn(_enabledSpeaker);
|
||||||
}
|
}
|
||||||
|
|
||||||
_onChangedCallState(CallState state) {
|
_onChangedCallState(CallState state) {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:openim/company/rtc/call_session_guard.dart';
|
||||||
|
import 'package:openim/company/rtc/livekit_call_config.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('配置门:内网地址与换票路径齐全时才打开入口', () {
|
||||||
|
expect(LiveKitCallConfig.rtcTokenPath, '/api/rtc_token');
|
||||||
|
expect(LiveKitCallConfig.endpointsReady, isTrue);
|
||||||
|
expect(LiveKitCallConfig.enabled, LiveKitCallConfig.shipped);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('占线时拒绝第二路呼出/呼入', () {
|
||||||
|
final guard = CallSessionGuard();
|
||||||
|
expect(guard.startOutgoing('room-a'), isNull);
|
||||||
|
expect(guard.startOutgoing('room-b'), 'busy');
|
||||||
|
expect(guard.startIncoming('room-c'), 'busy');
|
||||||
|
guard.dispose();
|
||||||
|
expect(guard.isBusy, isFalse);
|
||||||
|
expect(guard.startIncoming('room-d'), isNull);
|
||||||
|
guard.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同一房间同一信令只处理一次', () {
|
||||||
|
final guard = CallSessionGuard();
|
||||||
|
expect(guard.acceptSignaling(customType: 200, roomID: 'r1'), isTrue);
|
||||||
|
expect(guard.acceptSignaling(customType: 200, roomID: 'r1'), isFalse);
|
||||||
|
expect(guard.acceptSignaling(customType: 201, roomID: 'r1'), isTrue);
|
||||||
|
expect(guard.acceptSignaling(customType: 200, roomID: ''), isFalse);
|
||||||
|
guard.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('接听后取消超时,销毁后释放计时器', () async {
|
||||||
|
var timedOut = false;
|
||||||
|
final guard = CallSessionGuard(inviteTimeout: const Duration(milliseconds: 40));
|
||||||
|
guard.onTimeout = () => timedOut = true;
|
||||||
|
guard.startOutgoing('room-t');
|
||||||
|
guard.markConnected();
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||||
|
expect(timedOut, isFalse);
|
||||||
|
expect(guard.phase, VoiceCallPhase.inCall);
|
||||||
|
|
||||||
|
guard.dispose();
|
||||||
|
expect(guard.phase, VoiceCallPhase.idle);
|
||||||
|
expect(guard.roomID, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('呼出超时回调', () async {
|
||||||
|
var timedOut = false;
|
||||||
|
final guard = CallSessionGuard(inviteTimeout: const Duration(milliseconds: 30));
|
||||||
|
guard.onTimeout = () => timedOut = true;
|
||||||
|
guard.startOutgoing('room-t');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||||
|
expect(timedOut, isTrue);
|
||||||
|
guard.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sameRoom 只认当前房间', () {
|
||||||
|
final guard = CallSessionGuard();
|
||||||
|
guard.startIncoming('room-1');
|
||||||
|
expect(guard.sameRoom('room-1'), isTrue);
|
||||||
|
expect(guard.sameRoom('room-2'), isFalse);
|
||||||
|
expect(guard.sameRoom(null), isFalse);
|
||||||
|
guard.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:openim/company/rtc/rtc_token_mapper.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const fallback = 'ws://192.168.200.11:17880';
|
||||||
|
|
||||||
|
test('解析账号服务 token,缺 liveURL 时回落到配置地址', () {
|
||||||
|
final cred = RtcTokenMapper.parse(
|
||||||
|
{
|
||||||
|
'code': 0,
|
||||||
|
'msg': '',
|
||||||
|
'data': {'token': 'lk-token'},
|
||||||
|
},
|
||||||
|
fallbackLiveUrl: fallback,
|
||||||
|
);
|
||||||
|
expect(cred.token, 'lk-token');
|
||||||
|
expect(cred.liveURL, fallback);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('兼容官方 serverUrl 字段', () {
|
||||||
|
final cred = RtcTokenMapper.parse(
|
||||||
|
{
|
||||||
|
'code': 0,
|
||||||
|
'data': {'token': 't', 'serverUrl': 'ws://live.example:7880'},
|
||||||
|
},
|
||||||
|
fallbackLiveUrl: fallback,
|
||||||
|
);
|
||||||
|
expect(cred.liveURL, 'ws://live.example:7880');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('优先使用 liveURL', () {
|
||||||
|
final cred = RtcTokenMapper.parse(
|
||||||
|
{
|
||||||
|
'data': {
|
||||||
|
'token': 't',
|
||||||
|
'liveURL': 'ws://a:1',
|
||||||
|
'serverUrl': 'ws://b:2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fallbackLiveUrl: fallback,
|
||||||
|
);
|
||||||
|
expect(cred.liveURL, 'ws://a:1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('业务失败码抛出服务端文案', () {
|
||||||
|
expect(
|
||||||
|
() => RtcTokenMapper.parse(
|
||||||
|
{'code': 1, 'msg': '语音通话服务未配置,请联系管理员'},
|
||||||
|
fallbackLiveUrl: fallback,
|
||||||
|
),
|
||||||
|
throwsA(isA<FormatException>().having(
|
||||||
|
(e) => e.message,
|
||||||
|
'message',
|
||||||
|
'语音通话服务未配置,请联系管理员',
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('缺 token 失败', () {
|
||||||
|
expect(
|
||||||
|
() => RtcTokenMapper.parse(
|
||||||
|
{
|
||||||
|
'code': 0,
|
||||||
|
'data': {'token': ''},
|
||||||
|
},
|
||||||
|
fallbackLiveUrl: fallback,
|
||||||
|
),
|
||||||
|
throwsA(isA<FormatException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user