forked from eatvio/eatvio_app
Merge branch 'issue-1089' of ssh://10.55.5.123:222/misha/eatvio_app
This commit is contained in:
@@ -232,7 +232,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
if (kind == VideoCallChatSignalKind.ended) {
|
||||
unawaited(_dismissIncomingVideoCallUi(message.roomId));
|
||||
unawaited(
|
||||
VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId),
|
||||
VideoCallEndedPushHandler.endActiveCallIfMatches(
|
||||
conversationUuid: message.roomId,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,12 +32,14 @@ class VideoCallManager {
|
||||
VideoCallForegroundCoordinator? foregroundCoordinator,
|
||||
VideoCallLifecycleBinding? lifecycleBinding,
|
||||
VideoCallAudioSessionCoordinator? audioSessionCoordinator,
|
||||
Future<String?> Function()? resolveHwId,
|
||||
}) : _videoCallRepository = videoCallRepository,
|
||||
_profileRepository = profileRepository,
|
||||
_foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(),
|
||||
_lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(),
|
||||
_audioSession =
|
||||
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator() {
|
||||
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator(),
|
||||
_resolveHwId = resolveHwId ?? DeviceHwIdProvider.current {
|
||||
FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData);
|
||||
if (Platform.isIOS) {
|
||||
// LiveKit rewrites the audio session whenever a remote audio track
|
||||
@@ -52,6 +54,7 @@ class VideoCallManager {
|
||||
final VideoCallForegroundCoordinator _foreground;
|
||||
final VideoCallLifecycleBinding _lifecycleBinding;
|
||||
final VideoCallAudioSessionCoordinator _audioSession;
|
||||
final Future<String?> Function() _resolveHwId;
|
||||
|
||||
static const lk.AudioCaptureOptions _micCaptureOptions =
|
||||
lk.AudioCaptureOptions(
|
||||
@@ -78,6 +81,8 @@ class VideoCallManager {
|
||||
bool _callEndedNotified = true;
|
||||
bool _forceEndRequested = false;
|
||||
bool _sessionMayRequestToken = false;
|
||||
String? _activeCallId;
|
||||
final Set<String> _callDeclineNotifiedKeys = <String>{};
|
||||
final Map<String, DateTime> _leftConversationAt = {};
|
||||
final Set<String> _callAcceptNotifiedConversationUuids = {};
|
||||
bool _callStageEntered = false;
|
||||
@@ -102,6 +107,14 @@ class VideoCallManager {
|
||||
|
||||
bool get callEndedNotified => _callEndedNotified;
|
||||
|
||||
String? get activeCallId => _activeCallId;
|
||||
|
||||
void rememberCallId(String? callId) {
|
||||
final id = callId?.trim();
|
||||
if (id == null || id.isEmpty) return;
|
||||
_activeCallId = id;
|
||||
}
|
||||
|
||||
bool get isActiveCall =>
|
||||
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
||||
_callStageEntered &&
|
||||
@@ -441,7 +454,10 @@ class VideoCallManager {
|
||||
await _disconnectLiveKitOnly();
|
||||
if (shouldNotifyBackend) {
|
||||
try {
|
||||
await _videoCallRepository.notifyCallEnded(uuid);
|
||||
await _videoCallRepository.notifyCallEnded(
|
||||
uuid,
|
||||
callId: _activeCallId,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
await _foreground.stopOngoingCall();
|
||||
@@ -451,17 +467,22 @@ class VideoCallManager {
|
||||
_videoPausedForBackground = false;
|
||||
_liveKitDisposed = true;
|
||||
_myProfileNameFuture = null;
|
||||
_activeCallId = null;
|
||||
_emitSnapshot(VideoCallManagerSnapshot.initial);
|
||||
}
|
||||
|
||||
/// Notifies backend that this device accepted an incoming call so ringing
|
||||
/// stops on the user's other devices.
|
||||
Future<void> notifyIncomingCallAccepted(String conversationUuid) async {
|
||||
Future<void> notifyIncomingCallAccepted(
|
||||
String conversationUuid, {
|
||||
String? callId,
|
||||
}) async {
|
||||
final uuid = conversationUuid.trim();
|
||||
if (uuid.isEmpty) return;
|
||||
rememberCallId(callId);
|
||||
if (_callAcceptNotifiedConversationUuids.contains(uuid)) return;
|
||||
|
||||
final hwId = (await DeviceHwIdProvider.current())?.trim();
|
||||
final hwId = (await _resolveHwId())?.trim();
|
||||
if (hwId == null || hwId.length < 8) return;
|
||||
|
||||
_callAcceptNotifiedConversationUuids.add(uuid);
|
||||
@@ -469,6 +490,7 @@ class VideoCallManager {
|
||||
await _videoCallRepository.notifyCallAccepted(
|
||||
conversationUuid: uuid,
|
||||
hwId: hwId,
|
||||
callId: _activeCallId ?? callId,
|
||||
);
|
||||
} catch (_) {
|
||||
_callAcceptNotifiedConversationUuids.remove(uuid);
|
||||
@@ -476,28 +498,67 @@ class VideoCallManager {
|
||||
}
|
||||
|
||||
/// Decline from incoming UI before joining the LiveKit room.
|
||||
Future<void> declineIncomingCall(String conversationUuid) async {
|
||||
///
|
||||
/// Uses `POST /decline` so the backend can stop ringing on the user's
|
||||
/// other devices. Do not call `/ended` for a ringing decline.
|
||||
Future<void> declineIncomingCall(
|
||||
String conversationUuid, {
|
||||
String? callId,
|
||||
}) async {
|
||||
final uuid = conversationUuid.trim();
|
||||
final resolvedCallId = callId?.trim().isNotEmpty == true
|
||||
? callId!.trim()
|
||||
: _activeCallId;
|
||||
if (uuid.isEmpty) {
|
||||
await endCall();
|
||||
await _cleanupLocalCallAfterDecline();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_snapshot.conversationUuid == uuid) {
|
||||
await endCall();
|
||||
return;
|
||||
}
|
||||
|
||||
await endCall();
|
||||
if (!incomingDeclineShouldNotifyBackend(
|
||||
final declineKey = '${uuid.toLowerCase()}|${resolvedCallId ?? ''}';
|
||||
final shouldNotify = incomingDeclineShouldNotifyBackend(
|
||||
leftAt: _leftConversationAt[uuid],
|
||||
now: DateTime.now(),
|
||||
)) {
|
||||
);
|
||||
|
||||
await _cleanupLocalCallAfterDecline(conversationUuid: uuid);
|
||||
|
||||
if (!shouldNotify || _callDeclineNotifiedKeys.contains(declineKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final hwId = (await _resolveHwId())?.trim();
|
||||
if (hwId == null || hwId.length < 8) return;
|
||||
|
||||
_callDeclineNotifiedKeys.add(declineKey);
|
||||
try {
|
||||
await _videoCallRepository.notifyCallEnded(uuid);
|
||||
} catch (_) {}
|
||||
await _videoCallRepository.notifyCallDeclined(
|
||||
conversationUuid: uuid,
|
||||
hwId: hwId,
|
||||
callId: resolvedCallId,
|
||||
);
|
||||
} catch (_) {
|
||||
_callDeclineNotifiedKeys.remove(declineKey);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanupLocalCallAfterDecline({String? conversationUuid}) async {
|
||||
final uuid = conversationUuid?.trim();
|
||||
if (uuid != null && uuid.isNotEmpty) {
|
||||
_leftConversationAt[uuid] = DateTime.now();
|
||||
}
|
||||
_callEndedNotified = true;
|
||||
_sessionMayRequestToken = false;
|
||||
_disposeRoomEphemerals();
|
||||
await _disconnectLiveKitOnly();
|
||||
await _foreground.stopOngoingCall();
|
||||
await _stopCallAudioSession();
|
||||
_lifecycleBinding.detach();
|
||||
_callStageEntered = false;
|
||||
_videoPausedForBackground = false;
|
||||
_liveKitDisposed = true;
|
||||
_myProfileNameFuture = null;
|
||||
_activeCallId = null;
|
||||
_emitSnapshot(VideoCallManagerSnapshot.initial);
|
||||
}
|
||||
|
||||
/// [currentUiEnabled] reflects what the user sees on the mic control
|
||||
@@ -805,6 +866,7 @@ class VideoCallManager {
|
||||
_roomConnectFuture = null;
|
||||
_clearPreparedConnectionCredentials();
|
||||
_callAcceptNotifiedConversationUuids.clear();
|
||||
_callDeclineNotifiedKeys.clear();
|
||||
_emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: false));
|
||||
}
|
||||
|
||||
@@ -1011,9 +1073,11 @@ class VideoCallManager {
|
||||
try {
|
||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
||||
_snapshot.conversationUuid,
|
||||
callId: _activeCallId,
|
||||
);
|
||||
if (_liveKitDisposed) return;
|
||||
|
||||
rememberCallId(tokenResponse.callId);
|
||||
_preparedWsUrl = tokenResponse.wsUrl;
|
||||
_preparedToken = tokenResponse.token;
|
||||
|
||||
@@ -1058,8 +1122,10 @@ class VideoCallManager {
|
||||
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
|
||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
||||
_snapshot.conversationUuid,
|
||||
callId: _activeCallId,
|
||||
);
|
||||
if (_liveKitDisposed) return;
|
||||
rememberCallId(tokenResponse.callId);
|
||||
wsUrl = tokenResponse.wsUrl;
|
||||
token = tokenResponse.token;
|
||||
_preparedWsUrl = wsUrl;
|
||||
|
||||
@@ -9,6 +9,9 @@ abstract class VideoCallTokenResponse with _$VideoCallTokenResponse {
|
||||
required String token,
|
||||
required String roomName,
|
||||
required String wsUrl,
|
||||
@JsonKey(name: 'call_id') String? callId,
|
||||
@JsonKey(name: 'call_status') String? callStatus,
|
||||
@JsonKey(name: 'call_expires_at') String? callExpiresAt,
|
||||
}) = _VideoCallTokenResponse;
|
||||
|
||||
factory VideoCallTokenResponse.fromJson(Map<String, Object?> json) =>
|
||||
@@ -29,8 +32,11 @@ abstract class VideoCallAdminTokenResponse with _$VideoCallAdminTokenResponse {
|
||||
|
||||
@freezed
|
||||
abstract class VideoCallEndedResponse with _$VideoCallEndedResponse {
|
||||
const factory VideoCallEndedResponse({required bool sent}) =
|
||||
_VideoCallEndedResponse;
|
||||
const factory VideoCallEndedResponse({
|
||||
required bool sent,
|
||||
@JsonKey(name: 'call_id') String? callId,
|
||||
String? status,
|
||||
}) = _VideoCallEndedResponse;
|
||||
|
||||
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
|
||||
_$VideoCallEndedResponseFromJson(json);
|
||||
@@ -41,8 +47,24 @@ abstract class VideoCallAcceptResponse with _$VideoCallAcceptResponse {
|
||||
const factory VideoCallAcceptResponse({
|
||||
required bool sent,
|
||||
@JsonKey(name: 'devices_notified') required int devicesNotified,
|
||||
@JsonKey(name: 'call_id') String? callId,
|
||||
String? status,
|
||||
@JsonKey(name: 'already_accepted') bool? alreadyAccepted,
|
||||
}) = _VideoCallAcceptResponse;
|
||||
|
||||
factory VideoCallAcceptResponse.fromJson(Map<String, Object?> json) =>
|
||||
_$VideoCallAcceptResponseFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class VideoCallDeclineResponse with _$VideoCallDeclineResponse {
|
||||
const factory VideoCallDeclineResponse({
|
||||
required bool sent,
|
||||
@JsonKey(name: 'devices_notified') required int devicesNotified,
|
||||
@JsonKey(name: 'call_id') String? callId,
|
||||
String? status,
|
||||
}) = _VideoCallDeclineResponse;
|
||||
|
||||
factory VideoCallDeclineResponse.fromJson(Map<String, Object?> json) =>
|
||||
_$VideoCallDeclineResponseFromJson(json);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,13 @@ class VideoCallRepository {
|
||||
|
||||
static const Map<String, dynamic> _emptyJsonBody = <String, dynamic>{};
|
||||
|
||||
Future<VideoCallTokenResponse> getParticipantToken(String conversationUuid) {
|
||||
Future<VideoCallTokenResponse> getParticipantToken(
|
||||
String conversationUuid, {
|
||||
String? callId,
|
||||
}) {
|
||||
return videoCallService.getParticipantToken(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: callId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,20 +22,46 @@ class VideoCallRepository {
|
||||
return videoCallService.getAdminToken(conversationUuid: conversationUuid);
|
||||
}
|
||||
|
||||
Future<VideoCallEndedResponse> notifyCallEnded(String conversationUuid) {
|
||||
Future<VideoCallEndedResponse> notifyCallEnded(
|
||||
String conversationUuid, {
|
||||
String? callId,
|
||||
}) {
|
||||
final body = <String, dynamic>{
|
||||
if (callId != null && callId.trim().isNotEmpty) 'call_id': callId.trim(),
|
||||
};
|
||||
return videoCallService.notifyCallEnded(
|
||||
conversationUuid: conversationUuid,
|
||||
body: _emptyJsonBody,
|
||||
body: body.isEmpty ? _emptyJsonBody : body,
|
||||
);
|
||||
}
|
||||
|
||||
Future<VideoCallAcceptResponse> notifyCallAccepted({
|
||||
required String conversationUuid,
|
||||
required String hwId,
|
||||
String? callId,
|
||||
}) {
|
||||
return videoCallService.notifyCallAccepted(
|
||||
conversationUuid: conversationUuid,
|
||||
body: <String, dynamic>{'hw_id': hwId},
|
||||
body: <String, dynamic>{
|
||||
'hw_id': hwId,
|
||||
if (callId != null && callId.trim().isNotEmpty)
|
||||
'call_id': callId.trim(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<VideoCallDeclineResponse> notifyCallDeclined({
|
||||
required String conversationUuid,
|
||||
required String hwId,
|
||||
String? callId,
|
||||
}) {
|
||||
return videoCallService.notifyCallDeclined(
|
||||
conversationUuid: conversationUuid,
|
||||
body: <String, dynamic>{
|
||||
'hw_id': hwId,
|
||||
if (callId != null && callId.trim().isNotEmpty)
|
||||
'call_id': callId.trim(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,7 +701,18 @@ class VideoCallCallKitCoordinator {
|
||||
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
|
||||
|
||||
_suppressedEndCallKitIds.add(callKitId);
|
||||
unawaited(_manager.notifyIncomingCallAccepted(conversationUuid));
|
||||
final params = _paramsForCallId(callKitId);
|
||||
final callId = _parseCallId(params?.extra ?? const <String, Object?>{}) ??
|
||||
(callKitId.contains('-') ? callKitId : null);
|
||||
if (callId != null) {
|
||||
_manager.rememberCallId(callId);
|
||||
}
|
||||
unawaited(
|
||||
_manager.notifyIncomingCallAccepted(
|
||||
conversationUuid,
|
||||
callId: callId,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await _waitForAcceptNavigationReady();
|
||||
@@ -874,6 +885,8 @@ class VideoCallCallKitCoordinator {
|
||||
extra,
|
||||
handle: params?.handle ?? callKitId,
|
||||
);
|
||||
final callId = _parseCallId(extra) ??
|
||||
(callKitId.contains('-') ? callKitId : null);
|
||||
final remoteEndDismissal =
|
||||
callKitDeclineIsEndedPush(
|
||||
cachedExtra: params?.extra,
|
||||
@@ -911,7 +924,10 @@ class VideoCallCallKitCoordinator {
|
||||
return;
|
||||
}
|
||||
|
||||
await _manager.declineIncomingCall(conversationUuid);
|
||||
await _manager.declineIncomingCall(
|
||||
conversationUuid,
|
||||
callId: callId,
|
||||
);
|
||||
if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
|
||||
await _cubit.onCallEndedExternally();
|
||||
}
|
||||
@@ -977,6 +993,10 @@ class VideoCallCallKitCoordinator {
|
||||
return '';
|
||||
}
|
||||
|
||||
static String? _parseCallId(Map<String, Object?> extra) {
|
||||
return _parseString(extra, const ['call_id', 'callId']);
|
||||
}
|
||||
|
||||
static String? _parseString(Map<String, Object?> map, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = map[key];
|
||||
|
||||
35
lib/features/video_calls/services/video_call_end_reason.dart
Normal file
35
lib/features/video_calls/services/video_call_end_reason.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
/// Cause of a `video_call_ended` push / Echo event.
|
||||
///
|
||||
/// This is not the session status. It tells the client how to close ringing
|
||||
/// or an active call.
|
||||
enum VideoCallEndReason {
|
||||
accepted,
|
||||
declined,
|
||||
cancelled,
|
||||
ended,
|
||||
timeout;
|
||||
|
||||
static VideoCallEndReason? tryParse(Object? raw) {
|
||||
if (raw == null) return null;
|
||||
final normalized = raw.toString().trim().toLowerCase().replaceAll('-', '_');
|
||||
if (normalized.isEmpty) return null;
|
||||
for (final value in VideoCallEndReason.values) {
|
||||
if (value.name == normalized) return value;
|
||||
}
|
||||
if (normalized == 'hangup' || normalized == 'hang_up') {
|
||||
return VideoCallEndReason.ended;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Close ringing UI; do not tear down an already-accepted LiveKit call.
|
||||
bool get dismissesRingingOnly =>
|
||||
this == VideoCallEndReason.accepted ||
|
||||
this == VideoCallEndReason.declined ||
|
||||
this == VideoCallEndReason.cancelled ||
|
||||
this == VideoCallEndReason.timeout;
|
||||
|
||||
/// Tear down an accepted in-progress call.
|
||||
bool get endsActiveCall =>
|
||||
this == VideoCallEndReason.ended || this == VideoCallEndReason.timeout;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
@@ -13,30 +16,99 @@ abstract final class VideoCallEndedPushHandler {
|
||||
'com.eatvioapp/video_call_push',
|
||||
);
|
||||
|
||||
static Future<void> dismissIncomingCallUi({String? conversationUuid}) async {
|
||||
await endActiveCallIfMatches(conversationUuid);
|
||||
static Future<void> dismissIncomingCallUi({
|
||||
String? conversationUuid,
|
||||
String? callId,
|
||||
VideoCallEndReason? reason,
|
||||
}) async {
|
||||
final normalizedCallId = callId?.trim();
|
||||
if (normalizedCallId != null && normalizedCallId.isNotEmpty) {
|
||||
VideoCallTombstoneRegistry.mark(normalizedCallId);
|
||||
}
|
||||
|
||||
log(
|
||||
'VideoCallEndedPushHandler: dismiss '
|
||||
'call_id=${normalizedCallId ?? '-'} '
|
||||
'conversation=${conversationUuid ?? '-'} '
|
||||
'reason=${reason?.name ?? '-'}',
|
||||
name: 'VideoCallEndedPush',
|
||||
);
|
||||
|
||||
final shouldEndActive = reason == null || reason.endsActiveCall;
|
||||
if (shouldEndActive && reason != VideoCallEndReason.accepted) {
|
||||
await endActiveCallIfMatches(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: normalizedCallId,
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
await _androidPushChannel.invokeMethod<void>('dismissIncomingCalls');
|
||||
await _androidPushChannel.invokeMethod<void>(
|
||||
'dismissIncomingCalls',
|
||||
<String, String?>{
|
||||
if (normalizedCallId != null && normalizedCallId.isNotEmpty)
|
||||
'call_id': normalizedCallId,
|
||||
if (conversationUuid != null && conversationUuid.trim().isNotEmpty)
|
||||
'conversation_uuid': conversationUuid.trim(),
|
||||
},
|
||||
);
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
await _dismissViaCallKit(conversationUuid);
|
||||
await _dismissViaCallKit(
|
||||
conversationUuid: conversationUuid,
|
||||
callId: normalizedCallId,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> endActiveCallIfMatches(String? conversationUuid) async {
|
||||
static Future<void> endActiveCallIfMatches({
|
||||
String? conversationUuid,
|
||||
String? callId,
|
||||
}) async {
|
||||
final uuid = conversationUuid?.trim();
|
||||
if (uuid == null || uuid.isEmpty) return;
|
||||
if (!sl.isRegistered<VideoCallSessionCubit>()) return;
|
||||
|
||||
final cubit = sl<VideoCallSessionCubit>();
|
||||
if (cubit.state.conversationUuid != uuid) return;
|
||||
// callId is reserved for ringing dismiss matching; active sessions are
|
||||
// still keyed by conversation until the cubit stores call_id.
|
||||
try {
|
||||
await cubit.handleRemoteCallEnded();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static bool callMatches({
|
||||
required CallKitParams call,
|
||||
String? conversationUuid,
|
||||
String? callId,
|
||||
}) {
|
||||
final normalizedCallId = callId?.trim().toLowerCase();
|
||||
if (normalizedCallId != null && normalizedCallId.isNotEmpty) {
|
||||
final id = call.id.trim().toLowerCase();
|
||||
if (id.isNotEmpty && id == normalizedCallId) return true;
|
||||
final extra = call.extra;
|
||||
if (extra != null) {
|
||||
for (final key in const ['call_id', 'callId']) {
|
||||
final value = extra[key];
|
||||
if (value is String &&
|
||||
value.trim().toLowerCase() == normalizedCallId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// When call_id is known, never fall back to conversation matching:
|
||||
// a stale ended event must not close a newer ring in the same chat.
|
||||
return false;
|
||||
}
|
||||
|
||||
final uuid = conversationUuid?.trim().toLowerCase();
|
||||
if (uuid == null || uuid.isEmpty) return false;
|
||||
return callMatchesConversation(call, uuid);
|
||||
}
|
||||
|
||||
static bool callMatchesConversation(
|
||||
CallKitParams call,
|
||||
String conversationUuid,
|
||||
@@ -57,11 +129,18 @@ abstract final class VideoCallEndedPushHandler {
|
||||
/// The close of a ringing call comes back from the plugin as a decline.
|
||||
/// Each id is marked first, so the coordinator does not report that
|
||||
/// decline to the backend as if the user had rejected the call.
|
||||
static Future<void> _dismissViaCallKit(String? conversationUuid) async {
|
||||
static Future<void> _dismissViaCallKit({
|
||||
String? conversationUuid,
|
||||
String? callId,
|
||||
}) async {
|
||||
final uuid = conversationUuid?.trim();
|
||||
final normalizedCallId = callId?.trim();
|
||||
try {
|
||||
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
||||
if (uuid == null || uuid.isEmpty) {
|
||||
final hasTarget =
|
||||
(normalizedCallId != null && normalizedCallId.isNotEmpty) ||
|
||||
(uuid != null && uuid.isNotEmpty);
|
||||
if (!hasTarget) {
|
||||
for (final call in activeCalls) {
|
||||
VideoCallRemoteEndRegistry.mark(call.id);
|
||||
}
|
||||
@@ -69,7 +148,13 @@ abstract final class VideoCallEndedPushHandler {
|
||||
return;
|
||||
}
|
||||
for (final call in activeCalls) {
|
||||
if (!callMatchesConversation(call, uuid)) continue;
|
||||
if (!callMatches(
|
||||
call: call,
|
||||
conversationUuid: uuid,
|
||||
callId: normalizedCallId,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
VideoCallRemoteEndRegistry.mark(call.id);
|
||||
try {
|
||||
await FlutterCallkitIncoming.endCall(call.id);
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
|
||||
/// Marker for a data-only `video_call_ended` push.
|
||||
class VideoCallEndedPushPayload {
|
||||
const VideoCallEndedPushPayload({this.conversationUuid});
|
||||
const VideoCallEndedPushPayload({
|
||||
this.conversationUuid,
|
||||
this.callId,
|
||||
this.reason,
|
||||
this.acceptedDeviceId,
|
||||
this.acceptedClient,
|
||||
});
|
||||
|
||||
final String? conversationUuid;
|
||||
final String? callId;
|
||||
final VideoCallEndReason? reason;
|
||||
final String? acceptedDeviceId;
|
||||
final String? acceptedClient;
|
||||
|
||||
static VideoCallEndedPushPayload? tryParse(Map<String, dynamic> data) {
|
||||
if (!VideoCallPushPayload.isEndedVideoCall(data)) return null;
|
||||
final rawUuid =
|
||||
data['conversationUuid'] ??
|
||||
data['conversation_uuid'] ??
|
||||
data['uuid'];
|
||||
final conversationUuid = rawUuid?.toString().trim();
|
||||
|
||||
final conversationUuid = _string(data, const [
|
||||
'conversationUuid',
|
||||
'conversation_uuid',
|
||||
'uuid',
|
||||
]);
|
||||
final callId = _string(data, const ['call_id', 'callId']);
|
||||
final acceptedDeviceId = _string(data, const [
|
||||
'accepted_device_id',
|
||||
'acceptedDeviceId',
|
||||
]);
|
||||
final acceptedClient = _string(data, const [
|
||||
'accepted_client',
|
||||
'acceptedClient',
|
||||
]);
|
||||
|
||||
return VideoCallEndedPushPayload(
|
||||
conversationUuid:
|
||||
conversationUuid == null || conversationUuid.isEmpty
|
||||
? null
|
||||
: conversationUuid,
|
||||
conversationUuid: conversationUuid,
|
||||
callId: callId,
|
||||
reason: VideoCallEndReason.tryParse(
|
||||
data['reason'] ?? data['end_reason'] ?? data['endReason'],
|
||||
),
|
||||
acceptedDeviceId: acceptedDeviceId,
|
||||
acceptedClient: acceptedClient,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _string(Map<String, dynamic> data, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = data[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +58,16 @@ abstract final class VideoCallFcmHandler {
|
||||
}
|
||||
|
||||
log(
|
||||
'VideoCallFcmHandler: dismiss incoming call UI, source=$source',
|
||||
'VideoCallFcmHandler: dismiss incoming call UI, source=$source '
|
||||
'call_id=${endedPayload.callId ?? '-'} '
|
||||
'reason=${endedPayload.reason?.name ?? '-'}',
|
||||
name: 'VideoCallFcm',
|
||||
);
|
||||
try {
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: endedPayload.conversationUuid,
|
||||
callId: endedPayload.callId,
|
||||
reason: endedPayload.reason,
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
@@ -53,17 +55,23 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
? payload.counterpartName!.trim()
|
||||
: 'Итвио';
|
||||
final ios = isIos ?? Platform.isIOS;
|
||||
if (VideoCallTombstoneRegistry.contains(payload.callId)) {
|
||||
log(
|
||||
'VideoCallIncomingPushPresenter: skip tombstoned call_id=${payload.callId}',
|
||||
name: 'VideoCallIncoming',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!ios && (showAndroidIncoming != null || Platform.isAndroid)) {
|
||||
await (showAndroidIncoming ?? _showAndroidIncoming)(payload);
|
||||
return;
|
||||
}
|
||||
final callId = ios
|
||||
? VideoCallKitId.iosUuid(
|
||||
conversationUuid: payload.conversationUuid,
|
||||
messageId: payload.messageId,
|
||||
) ??
|
||||
payload.conversationUuid
|
||||
: payload.conversationUuid;
|
||||
final callId = VideoCallKitId.resolve(
|
||||
callId: payload.callId,
|
||||
conversationUuid: payload.conversationUuid,
|
||||
messageId: payload.messageId,
|
||||
isIos: ios,
|
||||
);
|
||||
|
||||
if (ios) {
|
||||
final claimed = isIncomingCallClaimed ?? _isIncomingCallClaimed;
|
||||
@@ -91,6 +99,7 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
duration: 45000,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': payload.conversationUuid,
|
||||
if (payload.callId != null) 'call_id': payload.callId,
|
||||
if (payload.counterpartName != null)
|
||||
'counterpartName': payload.counterpartName,
|
||||
if (payload.counterpartAvatarUrl != null)
|
||||
@@ -99,6 +108,7 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
'localParticipantName': payload.localParticipantName,
|
||||
if (payload.counterpartIsConsultant != null)
|
||||
'counterpartIsConsultant': payload.counterpartIsConsultant,
|
||||
if (payload.messageId != null) 'message_id': payload.messageId,
|
||||
},
|
||||
callingNotification: const NotificationParams(showNotification: false),
|
||||
missedCallNotification: const NotificationParams(
|
||||
@@ -138,6 +148,7 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
await _androidPushChannel
|
||||
.invokeMethod<void>('showIncomingCall', <String, String>{
|
||||
'conversation_uuid': payload.conversationUuid,
|
||||
if (payload.callId != null) 'call_id': payload.callId!,
|
||||
if (payload.counterpartName != null)
|
||||
'caller_name': payload.counterpartName!,
|
||||
if (payload.counterpartAvatarUrl != null)
|
||||
@@ -150,6 +161,8 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
: 'false',
|
||||
if (payload.messageId != null)
|
||||
'message_id': payload.messageId.toString(),
|
||||
if (payload.expiresAt != null)
|
||||
'expires_at': payload.expiresAt!.toUtc().toIso8601String(),
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
/// CallKit on iOS rejects a second incoming call that reuses a UUID.
|
||||
/// Resolves the CallKit / ConnectionService id for an incoming call.
|
||||
///
|
||||
/// The conversation uuid stays in the call payload for navigation. The CallKit
|
||||
/// id is derived from that uuid plus the chat message id, so one ring is one
|
||||
/// call and the next ring in the same chat still appears. Android notifications
|
||||
/// keep the conversation uuid: the same id updates one incoming notification.
|
||||
/// Prefer the backend `call_id` (stable for the whole session). Legacy
|
||||
/// fallback: conversation uuid + message id on iOS, conversation uuid on
|
||||
/// Android.
|
||||
abstract final class VideoCallKitId {
|
||||
static final RegExp _uuidPrefix = RegExp(
|
||||
r'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$',
|
||||
);
|
||||
|
||||
static String resolve({
|
||||
String? callId,
|
||||
required String conversationUuid,
|
||||
int? messageId,
|
||||
required bool isIos,
|
||||
}) {
|
||||
final serverCallId = callId?.trim();
|
||||
if (serverCallId != null && serverCallId.isNotEmpty) {
|
||||
return serverCallId;
|
||||
}
|
||||
if (isIos) {
|
||||
return iosUuid(
|
||||
conversationUuid: conversationUuid,
|
||||
messageId: messageId,
|
||||
) ??
|
||||
conversationUuid;
|
||||
}
|
||||
return conversationUuid;
|
||||
}
|
||||
|
||||
static String? iosUuid({
|
||||
required String conversationUuid,
|
||||
required int? messageId,
|
||||
|
||||
@@ -6,6 +6,8 @@ class VideoCallPushPayload {
|
||||
this.localParticipantName,
|
||||
this.counterpartIsConsultant,
|
||||
this.messageId,
|
||||
this.callId,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
final String conversationUuid;
|
||||
@@ -14,6 +16,8 @@ class VideoCallPushPayload {
|
||||
final String? localParticipantName;
|
||||
final bool? counterpartIsConsultant;
|
||||
final int? messageId;
|
||||
final String? callId;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
|
||||
if (!isIncomingVideoCall(data)) return null;
|
||||
@@ -52,6 +56,8 @@ class VideoCallPushPayload {
|
||||
'isConsultant',
|
||||
]),
|
||||
messageId: _messageId(data),
|
||||
callId: _string(data, const ['call_id', 'callId']),
|
||||
expiresAt: _dateTime(data, const ['expires_at', 'expiresAt']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,6 +125,9 @@ class VideoCallPushPayload {
|
||||
static String? conversationUuidFromData(Map<String, dynamic> data) =>
|
||||
_conversationUuid(data);
|
||||
|
||||
static String? callIdFromData(Map<String, dynamic> data) =>
|
||||
_string(data, const ['call_id', 'callId']);
|
||||
|
||||
static String? _conversationUuid(Map<String, dynamic> data) {
|
||||
final fromData = _string(data, const [
|
||||
'conversationUuid',
|
||||
@@ -181,6 +190,17 @@ class VideoCallPushPayload {
|
||||
return null;
|
||||
}
|
||||
|
||||
static DateTime? _dateTime(Map<String, dynamic> data, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = data[key];
|
||||
if (value is DateTime) return value;
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return DateTime.tryParse(value.trim());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _isTruthy(Object? value) {
|
||||
if (value == null) return false;
|
||||
if (value is bool) return value;
|
||||
|
||||
@@ -11,6 +11,7 @@ abstract class VideoCallService {
|
||||
@GET('/api/v2/chat/{conversation}/video-call/token')
|
||||
Future<VideoCallTokenResponse> getParticipantToken({
|
||||
@Path('conversation') required String conversationUuid,
|
||||
@Query('call_id') String? callId,
|
||||
});
|
||||
|
||||
@GET('/api/v2/chat/{conversation}/video-call/admin-token')
|
||||
@@ -29,4 +30,10 @@ abstract class VideoCallService {
|
||||
@Path('conversation') required String conversationUuid,
|
||||
@Body() required Map<String, dynamic> body,
|
||||
});
|
||||
|
||||
@POST('/api/v2/chat/{conversation}/video-call/decline')
|
||||
Future<VideoCallDeclineResponse> notifyCallDeclined({
|
||||
@Path('conversation') required String conversationUuid,
|
||||
@Body() required Map<String, dynamic> body,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/// Remembers cancelled / declined call ids so a late `video_call_started`
|
||||
/// push does not show an already-finished ring.
|
||||
abstract final class VideoCallTombstoneRegistry {
|
||||
static const Duration defaultTtl = Duration(minutes: 2);
|
||||
|
||||
static final Map<String, DateTime> _expiresAtByCallId = <String, DateTime>{};
|
||||
|
||||
static void mark(
|
||||
String? callId, {
|
||||
DateTime? now,
|
||||
Duration ttl = defaultTtl,
|
||||
}) {
|
||||
final id = _normalize(callId);
|
||||
if (id == null) return;
|
||||
final at = now ?? DateTime.now();
|
||||
_expiresAtByCallId[id] = at.add(ttl);
|
||||
}
|
||||
|
||||
static bool contains(String? callId, {DateTime? now}) {
|
||||
final id = _normalize(callId);
|
||||
if (id == null) return false;
|
||||
_prune(now ?? DateTime.now());
|
||||
return _expiresAtByCallId.containsKey(id);
|
||||
}
|
||||
|
||||
static void clear() => _expiresAtByCallId.clear();
|
||||
|
||||
static void _prune(DateTime now) {
|
||||
_expiresAtByCallId.removeWhere((_, expiresAt) => !expiresAt.isAfter(now));
|
||||
}
|
||||
|
||||
static String? _normalize(String? callId) {
|
||||
final id = callId?.trim().toLowerCase();
|
||||
if (id == null || id.isEmpty) return null;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user