feat(video_calls): continous ringing on a defferent device fix

This commit is contained in:
misha
2026-10-21 14:25:59 +03:00
parent a6b94f835c
commit 141cf5543d
26 changed files with 871 additions and 93 deletions

View File

@@ -16,7 +16,7 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
Log.i(TAG, "onMessageReceived: ${message.data}")
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
VideoCallPushBridge.dismissAllIncomingCalls(this)
VideoCallPushBridge.dismissEndedVideoCall(this, message.data)
return
}

View File

@@ -21,7 +21,7 @@ class HmsPushService : HmsMessageService() {
if (message == null) return
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
VideoCallPushBridge.dismissAllIncomingCalls(this)
VideoCallPushBridge.dismissEndedVideoCall(this, message.dataOfMap)
return
}

View File

@@ -59,7 +59,16 @@ class MainActivity: FlutterActivity() {
when (call.method) {
"dismissIncomingCalls" -> {
try {
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext)
val args = call.arguments as? Map<*, *>
val callId = args?.get("call_id")?.toString()
?: args?.get("callId")?.toString()
val conversationUuid = args?.get("conversation_uuid")?.toString()
?: args?.get("conversationUuid")?.toString()
VideoCallPushBridge.dismissIncomingCalls(
applicationContext,
callId = callId,
conversationUuid = conversationUuid,
)
result.success(null)
} catch (e: Exception) {
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)

View File

@@ -18,6 +18,7 @@ import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.hiennv.flutter_callkit_incoming.addCall
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
import com.hiennv.flutter_callkit_incoming.removeAllCalls
import com.hiennv.flutter_callkit_incoming.removeCall
import java.util.HashMap
import java.util.concurrent.atomic.AtomicInteger
@@ -25,11 +26,13 @@ object VideoCallPushBridge {
private const val TAG = "VideoCallPushBridge"
private const val ANDROID_ACCENT_COLOR = "#2787F5"
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
private const val TOMBSTONE_TTL_MS = 2 * 60 * 1000L
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
private val mainHandler = Handler(Looper.getMainLooper())
private val playbackLock = Any()
private val playbackGeneration = AtomicInteger(0)
private val tombstonedCallIds = HashMap<String, Long>()
private var activeSoundManager: CallkitSoundPlayerManager? = null
private var activeNotificationManager: CallkitNotificationManager? = null
@@ -118,6 +121,12 @@ object VideoCallPushBridge {
storedAppContext = appContext
ensureIncomingCallCallbackRegistered()
val serverCallId = callId(data)
if (isTombstoned(serverCallId)) {
Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId")
return
}
val callData = buildCallDataBundle(data)
if (callData == null) {
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
@@ -126,7 +135,7 @@ object VideoCallPushBridge {
val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)
val seenGeneration = playbackGeneration.get()
Log.i(TAG, "showIncomingCall: conversation=$callId")
Log.i(TAG, "showIncomingCall: id=$callId conversation=${conversationUuid(data)}")
// Ringtone.play() and stop() must run on the same thread. FCM delivers
// pushes on a background thread, while Answer runs on the main thread.
@@ -180,17 +189,43 @@ object VideoCallPushBridge {
}
}
fun dismissAllIncomingCalls(context: Context) {
fun dismissIncomingCalls(
context: Context,
callId: String? = null,
conversationUuid: String? = null,
) {
val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() }
val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedCallId != null) {
markTombstone(normalizedCallId)
}
val appContext = context.applicationContext
val calls = getDataActiveCalls(appContext)
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
Log.i(
TAG,
"dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " +
"conversation=${normalizedConversation ?: "-"}",
)
playbackGeneration.incrementAndGet()
val dismiss = Runnable {
synchronized(playbackLock) {
val notificationManager = resolveNotificationManager(appContext)
val bundles = calls.map { it.toBundle() }
val allBundles = calls.map { it.toBundle() }
val targeted = hasDismissTarget(normalizedCallId, normalizedConversation)
val bundles = if (targeted) {
allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) }
} else {
allBundles
}
val activeMatches = activeCallBundle?.let { bundle ->
!targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation)
} == true
if (activeMatches || !targeted) {
stopPlaybackLocked(appContext)
}
for (bundle in bundles) {
try {
@@ -198,25 +233,39 @@ object VideoCallPushBridge {
} catch (error: Exception) {
Log.w(
TAG,
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
"dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
error,
)
}
}
if (!targeted || bundles.isNotEmpty()) {
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error)
}
}
if (!targeted) {
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error)
}
} else {
for (bundle in bundles) {
try {
removeCall(appContext, Data.fromBundle(bundle))
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error)
}
}
}
if (activeMatches || !targeted) {
stopIncomingRingtone()
}
scheduleMissedCallCleanup(notificationManager, bundles)
}
}
@@ -228,6 +277,18 @@ object VideoCallPushBridge {
}
}
fun dismissAllIncomingCalls(context: Context) {
dismissIncomingCalls(context)
}
fun dismissEndedVideoCall(context: Context, data: Map<String, String>) {
dismissIncomingCalls(
context = context,
callId = callId(data),
conversationUuid = conversationUuid(data),
)
}
private fun ensureIncomingCallCallbackRegistered() {
if (callbackRegistered) return
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
@@ -403,6 +464,8 @@ object VideoCallPushBridge {
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
val conversationUuid = conversationUuid(data) ?: return null
val serverCallId = callId(data)
val callKitId = serverCallId ?: conversationUuid
val callerName = data["counterpartName"]
?: data["counterpart_name"]
?: data["caller_name"]
@@ -428,6 +491,9 @@ object VideoCallPushBridge {
val extra = HashMap<String, Any?>()
extra["conversationUuid"] = conversationUuid
if (!serverCallId.isNullOrBlank()) {
extra["call_id"] = serverCallId
}
extra["counterpartName"] = callerName
if (avatarUrl.isNotBlank()) {
extra["counterpartAvatarUrl"] = avatarUrl
@@ -438,9 +504,13 @@ object VideoCallPushBridge {
if (counterpartIsConsultant) {
extra["counterpartIsConsultant"] = true
}
val messageId = data["message_id"] ?: data["messageId"]
if (!messageId.isNullOrBlank()) {
extra["message_id"] = messageId
}
return Bundle().apply {
putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid)
putString(CallkitConstants.EXTRA_CALLKIT_ID, callKitId)
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
@@ -465,6 +535,74 @@ object VideoCallPushBridge {
}
}
private fun callId(data: Map<String, String>): String? {
return data["call_id"]?.trim()?.takeIf { it.isNotEmpty() }
?: data["callId"]?.trim()?.takeIf { it.isNotEmpty() }
}
private fun hasDismissTarget(callId: String?, conversationUuid: String?): Boolean {
return !callId.isNullOrBlank() || !conversationUuid.isNullOrBlank()
}
private fun bundleMatches(
bundle: Bundle,
callId: String?,
conversationUuid: String?,
): Boolean {
val normalizedCallId = callId?.trim()?.lowercase()
if (!normalizedCallId.isNullOrEmpty()) {
val id = bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)?.trim()?.lowercase()
if (id != null && id == normalizedCallId) return true
val extraCallId = extraString(bundle, "call_id")
?: extraString(bundle, "callId")
if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) {
return true
}
return false
}
val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false
val handle = bundle.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)?.trim()?.lowercase()
if (handle != null && handle == normalizedConversation) return true
val extraConversation = extraString(bundle, "conversationUuid")
?: extraString(bundle, "conversation_uuid")
return extraConversation?.lowercase() == normalizedConversation
}
@Suppress("UNCHECKED_CAST")
private fun extraString(bundle: Bundle, key: String): String? {
val extra = bundle.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA) as? HashMap<String, Any?>
?: return null
val value = extra[key] ?: return null
val text = value.toString().trim()
return text.takeIf { it.isNotEmpty() }
}
private fun markTombstone(callId: String) {
val normalized = callId.trim().lowercase()
if (normalized.isEmpty()) return
pruneTombstones()
tombstonedCallIds[normalized] = System.currentTimeMillis() + TOMBSTONE_TTL_MS
}
private fun isTombstoned(callId: String?): Boolean {
val normalized = callId?.trim()?.lowercase() ?: return false
if (normalized.isEmpty()) return false
pruneTombstones()
return tombstonedCallIds.containsKey(normalized)
}
private fun pruneTombstones() {
val now = System.currentTimeMillis()
val iterator = tombstonedCallIds.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (entry.value <= now) {
iterator.remove()
}
}
}
private fun conversationUuid(data: Map<String, String>): String? {
val fromData = data["conversationUuid"]
?: data["conversation_uuid"]

View File

@@ -62,12 +62,16 @@ class PushNavigationHandler {
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
if (endedPayload != null) {
developer.log(
'PushNavigationHandler: dismiss incoming call UI, source=$source',
'PushNavigationHandler: dismiss incoming call UI, source=$source '
'call_id=${endedPayload.callId ?? '-'} '
'reason=${endedPayload.reason?.name ?? '-'}',
name: 'PushNavigationHandler',
);
unawaited(
VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: endedPayload.conversationUuid,
callId: endedPayload.callId,
reason: endedPayload.reason,
),
);
return;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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(),
},
);
}
}

View File

@@ -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];

View 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;
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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(

View File

@@ -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(
final callId = VideoCallKitId.resolve(
callId: payload.callId,
conversationUuid: payload.conversationUuid,
messageId: payload.messageId,
) ??
payload.conversationUuid
: payload.conversationUuid;
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 (_) {}
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
});
}

View File

@@ -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;
}
}

View File

@@ -52,6 +52,7 @@ void main() {
videoCallRepository: mockRepository,
foregroundCoordinator: foreground,
lifecycleBinding: lifecycleBinding,
resolveHwId: () async => 'test-hw-id-android-01',
);
mockRoom = MockRoom();
when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected);
@@ -94,6 +95,13 @@ void main() {
await manager.declineIncomingCall('test-uuid');
verifyNever(mockRepository.notifyCallEnded(any));
verifyNever(
mockRepository.notifyCallDeclined(
conversationUuid: anyNamed('conversationUuid'),
hwId: anyNamed('hwId'),
callId: anyNamed('callId'),
),
);
},
);
@@ -112,15 +120,34 @@ void main() {
);
test(
'decline of a call that was never joined notifies the backend',
'decline of a call that was never joined notifies the backend via /decline',
() async {
when(
mockRepository.notifyCallEnded(any),
).thenAnswer((_) async => const VideoCallEndedResponse(sent: true));
mockRepository.notifyCallDeclined(
conversationUuid: anyNamed('conversationUuid'),
hwId: anyNamed('hwId'),
callId: anyNamed('callId'),
),
).thenAnswer(
(_) async => const VideoCallDeclineResponse(
sent: true,
devicesNotified: 1,
),
);
await manager.declineIncomingCall('other-uuid');
await manager.declineIncomingCall(
'other-uuid',
callId: 'call-uuid-1',
);
verify(mockRepository.notifyCallEnded('other-uuid')).called(1);
verify(
mockRepository.notifyCallDeclined(
conversationUuid: 'other-uuid',
hwId: 'test-hw-id-android-01',
callId: 'call-uuid-1',
),
).called(1);
verifyNever(mockRepository.notifyCallEnded(any));
},
);

View File

@@ -24,8 +24,10 @@ void main() {
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
final expected = VideoCallTokenResponse.fromJson(json);
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
.thenAnswer((_) async => expected);
when(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: anyNamed('callId'),
)).thenAnswer((_) async => expected);
final result = await repository.getParticipantToken(conversationUuid);
@@ -33,20 +35,28 @@ void main() {
expect(result.token, expected.token);
expect(result.wsUrl, expected.wsUrl);
expect(result.roomName, expected.roomName);
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
verify(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: null,
)).called(1);
});
test('getParticipantToken - пробрасывает ошибку сервиса', () async {
const conversationUuid = 'conv-uuid-broken';
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
.thenThrow(Exception('No token'));
when(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: anyNamed('callId'),
)).thenThrow(Exception('No token'));
expect(
() => repository.getParticipantToken(conversationUuid),
throwsA(isA<Exception>()),
);
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
verify(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: null,
)).called(1);
});
test('getAdminToken - делегирует вызов сервису и возвращает админский токен', () async {
@@ -91,6 +101,60 @@ void main() {
expect(captured.single as Map<String, dynamic>, isEmpty);
});
test('notifyCallEnded - передаёт call_id когда он известен', () async {
const conversationUuid = 'conv-uuid-123';
final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json');
final expected = VideoCallEndedResponse.fromJson(json);
when(mockService.notifyCallEnded(
conversationUuid: conversationUuid,
body: anyNamed('body'),
)).thenAnswer((_) async => expected);
await repository.notifyCallEnded(conversationUuid, callId: 'call-1');
final captured = verify(
mockService.notifyCallEnded(
conversationUuid: conversationUuid,
body: captureAnyNamed('body'),
),
).captured.single as Map<String, dynamic>;
expect(captured, <String, dynamic>{'call_id': 'call-1'});
});
test('notifyCallDeclined - передаёт hw_id и call_id', () async {
const conversationUuid = 'conv-uuid-123';
final json = FixtureReader.readAsMap(
'video_calls/video_call_decline_response.json',
);
final expected = VideoCallDeclineResponse.fromJson(json);
when(mockService.notifyCallDeclined(
conversationUuid: conversationUuid,
body: anyNamed('body'),
)).thenAnswer((_) async => expected);
final result = await repository.notifyCallDeclined(
conversationUuid: conversationUuid,
hwId: 'device-android-001',
callId: 'call-1',
);
expect(result.sent, isTrue);
expect(result.devicesNotified, 1);
final captured = verify(
mockService.notifyCallDeclined(
conversationUuid: conversationUuid,
body: captureAnyNamed('body'),
),
).captured.single as Map<String, dynamic>;
expect(captured, <String, dynamic>{
'hw_id': 'device-android-001',
'call_id': 'call-1',
});
});
test('notifyCallEnded - пробрасывает ошибку сервиса', () async {
const conversationUuid = 'conv-uuid-broken';

View File

@@ -47,6 +47,36 @@ void main() {
});
});
group('VideoCallEndedPushHandler.callMatches', () {
test('matches by call_id and ignores conversation fallback', () {
const call = CallKitParams(
id: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
handle: conversation,
extra: <String, dynamic>{
'conversationUuid': conversation,
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
},
);
expect(
VideoCallEndedPushHandler.callMatches(
call: call,
conversationUuid: conversation,
callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
),
isTrue,
);
expect(
VideoCallEndedPushHandler.callMatches(
call: call,
conversationUuid: conversation,
callId: 'stale-call-id',
),
isFalse,
);
});
});
group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () {
final calls = <MethodCall>[];
late List<Map<String, Object?>> activeCalls;
@@ -93,6 +123,40 @@ void main() {
expect(VideoCallRemoteEndRegistry.contains('other-id'), isFalse);
});
test('ends only the matching call_id', () async {
activeCalls = <Map<String, Object?>>[
<String, Object?>{
'id': 'call-a',
'handle': conversation,
'extra': <String, Object?>{
'conversationUuid': conversation,
'call_id': 'call-a',
},
},
<String, Object?>{
'id': 'call-b',
'handle': conversation,
'extra': <String, Object?>{
'conversationUuid': conversation,
'call_id': 'call-b',
},
},
];
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: conversation,
callId: 'call-a',
);
final ended = calls
.where((call) => call.method == 'endCall')
.map((call) => (call.arguments as Map)['id'])
.toList();
expect(ended, <String>['call-a']);
expect(VideoCallRemoteEndRegistry.contains('call-a'), isTrue);
expect(VideoCallRemoteEndRegistry.contains('call-b'), isFalse);
});
test(
'without a conversation ends every call and marks all of them',
() async {

View File

@@ -60,6 +60,24 @@ void main() {
expect(payload!.conversationUuid, 'abc-123');
});
test('parses ended video call push with call_id and reason', () {
final payload = VideoCallEndedPushPayload.tryParse({
'type': 'video_call_ended',
'conversation_uuid': 'abc-123',
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
'reason': 'declined',
'accepted_device_id': 'device-a',
'accepted_client': 'android',
});
expect(payload, isNotNull);
expect(payload!.conversationUuid, 'abc-123');
expect(payload.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b');
expect(payload.reason?.name, 'declined');
expect(payload.acceptedDeviceId, 'device-a');
expect(payload.acceptedClient, 'android');
});
test('parses message id for a stable CallKit uuid', () {
final payload = VideoCallPushPayload.tryParse({
'type': 'video_call_started',
@@ -77,6 +95,28 @@ void main() {
);
});
test('prefers call_id as CallKit id when present', () {
final payload = VideoCallPushPayload.tryParse({
'type': 'video_call_started',
'conversation_uuid': '550e8400-e29b-41d4-a716-446655440000',
'message_id': '42',
'call_id': '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
'expires_at': '2026-04-27T14:31:00+00:00',
});
expect(payload?.callId, '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b');
expect(payload?.expiresAt, isNotNull);
expect(
VideoCallKitId.resolve(
callId: payload!.callId,
conversationUuid: payload.conversationUuid,
messageId: payload.messageId,
isIos: true,
),
'0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
);
});
test('ignores regular chat push', () {
expect(
VideoCallPushPayload.tryParse({

View File

@@ -21,23 +21,32 @@ void main() {
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
final expected = VideoCallTokenResponse.fromJson(json);
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
.thenAnswer((_) async => expected);
when(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: anyNamed('callId'),
)).thenAnswer((_) async => expected);
final result = await mockService.getParticipantToken(conversationUuid: conversationUuid);
final result = await mockService.getParticipantToken(
conversationUuid: conversationUuid,
);
expect(result, isA<VideoCallTokenResponse>());
expect(result.token, expected.token);
expect(result.wsUrl, expected.wsUrl);
expect(result.roomName, expected.roomName);
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
verify(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: null,
)).called(1);
});
test('getParticipantToken - пробрасывает ошибку', () async {
const conversationUuid = 'conv-uuid-error';
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
.thenThrow(Exception('Network error'));
when(mockService.getParticipantToken(
conversationUuid: conversationUuid,
callId: anyNamed('callId'),
)).thenThrow(Exception('Network error'));
expect(
() => mockService.getParticipantToken(conversationUuid: conversationUuid),

View File

@@ -0,0 +1,22 @@
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
tearDown(VideoCallTombstoneRegistry.clear);
test('marks a call id so a late incoming can be suppressed', () {
const callId = '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b';
final now = DateTime.utc(2026, 10, 21, 10);
VideoCallTombstoneRegistry.mark(callId, now: now);
expect(VideoCallTombstoneRegistry.contains(callId, now: now), isTrue);
expect(
VideoCallTombstoneRegistry.contains(
callId,
now: now.add(const Duration(minutes: 3)),
),
isFalse,
);
});
}

View File

@@ -0,0 +1,6 @@
{
"sent": true,
"devices_notified": 1,
"call_id": "0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b",
"status": "declined"
}