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:
编码工程师
2026-08-20 07:58:24 +08:00
co-authored by Cursor multica-agent
parent 2a817e77d8
commit 3832e080c4
19 changed files with 597 additions and 81 deletions
@@ -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,
);
}
}