feat(video_calls): ios voip push, mix and crushes fix

This commit is contained in:
misha
2026-10-16 13:49:48 +03:00
parent ff17b813ac
commit 6d2935400a
8 changed files with 715 additions and 89 deletions

View File

@@ -27,7 +27,7 @@ class ChatsCubit extends Cubit<ChatsState> {
/// Test seam. Production uses CallKit.
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
Future<void> Function()? dismissIncomingVideoCall;
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
ChatsCubit({
required this.chatRepository,
@@ -230,7 +230,7 @@ class ChatsCubit extends Cubit<ChatsState> {
}) {
final kind = VideoCallChatSignal.kindOf(message);
if (kind == VideoCallChatSignalKind.ended) {
unawaited(_dismissIncomingVideoCallUi());
unawaited(_dismissIncomingVideoCallUi(message.roomId));
unawaited(
VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId),
);
@@ -260,10 +260,16 @@ class ChatsCubit extends Cubit<ChatsState> {
}
}
Future<void> _dismissIncomingVideoCallUi() async {
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
try {
await (dismissIncomingVideoCall ??
VideoCallEndedPushHandler.dismissIncomingCallUi)();
final dismiss = dismissIncomingVideoCall;
if (dismiss != null) {
await dismiss(conversationUuid);
return;
}
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: conversationUuid,
);
} catch (e, st) {
log(
'ChatsCubit: failed to dismiss incoming video call: $e',

View File

@@ -3,6 +3,7 @@ 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:flutter/services.dart';
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
/// Dismisses incoming call UI after a remote `video_call_ended` push.
@@ -11,9 +12,7 @@ abstract final class VideoCallEndedPushHandler {
'com.eatvioapp/video_call_push',
);
static Future<void> dismissIncomingCallUi({
String? conversationUuid,
}) async {
static Future<void> dismissIncomingCallUi({String? conversationUuid}) async {
await endActiveCallIfMatches(conversationUuid);
if (Platform.isAndroid) {
try {
@@ -22,7 +21,7 @@ abstract final class VideoCallEndedPushHandler {
} catch (_) {}
}
await _dismissViaCallKit();
await _dismissViaCallKit(conversationUuid);
}
static Future<void> endActiveCallIfMatches(String? conversationUuid) async {
@@ -37,18 +36,37 @@ abstract final class VideoCallEndedPushHandler {
} catch (_) {}
}
static Future<void> _dismissViaCallKit() async {
static bool callMatchesConversation(
CallKitParams call,
String conversationUuid,
) {
final uuid = conversationUuid.trim().toLowerCase();
if (uuid.isEmpty) return false;
final handle = call.handle?.trim().toLowerCase();
if (handle != null && handle == uuid) return true;
final extra = call.extra;
if (extra == null) return false;
for (final key in const ['conversationUuid', 'conversation_uuid']) {
final value = extra[key];
if (value is String && value.trim().toLowerCase() == uuid) return true;
}
return false;
}
static Future<void> _dismissViaCallKit(String? conversationUuid) async {
final uuid = conversationUuid?.trim();
try {
final activeCalls = await FlutterCallkitIncoming.activeCalls();
if (uuid == null || uuid.isEmpty) {
await FlutterCallkitIncoming.endAllCalls();
return;
}
for (final call in activeCalls) {
if (!callMatchesConversation(call, uuid)) continue;
try {
await FlutterCallkitIncoming.endCall(call.id);
} catch (_) {}
}
} catch (_) {}
try {
await FlutterCallkitIncoming.endAllCalls();
} catch (_) {}
}
}

View File

@@ -2,18 +2,48 @@ 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:flutter/services.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
typedef VideoCallIncomingClaim = Future<bool> Function(String callId);
typedef VideoCallIncomingShow = Future<void> Function(CallKitParams params);
typedef VideoCallIncomingDelay = Future<void> Function(Duration delay);
/// Shows the system incoming-call UI.
///
/// On iOS the VoIP push reports the call even when the app is in the
/// foreground. A second report for the same id dismisses that UI, so the
/// socket path waits for the push and presents CallKit only if the push
/// never arrived.
abstract final class VideoCallIncomingPushPresenter {
static const String _androidAccentColor = '#2787F5';
static const MethodChannel _voipChannel = MethodChannel(
'com.eatvioapp/voip_diagnostics',
);
static Future<void> showIncomingCall(VideoCallPushPayload payload) async {
/// Absolute delays from the start of an iOS present attempt.
static const List<Duration> iosClaimPollDelays = <Duration>[
Duration.zero,
Duration(milliseconds: 300),
Duration(milliseconds: 800),
Duration(milliseconds: 1500),
];
static Future<void> showIncomingCall(
VideoCallPushPayload payload, {
bool? isIos,
VideoCallIncomingClaim? isIncomingCallClaimed,
VideoCallIncomingClaim? reserveIncomingCall,
VideoCallIncomingShow? showCallkitIncoming,
VideoCallIncomingDelay? delay,
List<Duration>? claimPollDelays,
}) async {
final callerName = payload.counterpartName?.trim().isNotEmpty == true
? payload.counterpartName!.trim()
: 'Итвио';
final callId = Platform.isIOS
final ios = isIos ?? Platform.isIOS;
final callId = ios
? VideoCallKitId.iosUuid(
conversationUuid: payload.conversationUuid,
messageId: payload.messageId,
@@ -21,6 +51,22 @@ abstract final class VideoCallIncomingPushPresenter {
payload.conversationUuid
: payload.conversationUuid;
if (ios) {
final claimed = isIncomingCallClaimed ?? _isIncomingCallClaimed;
final reserve = reserveIncomingCall ?? _reserveIncomingCall;
final wait = delay ?? Future<void>.delayed;
final polls = claimPollDelays ?? iosClaimPollDelays;
final started = DateTime.now();
for (final checkpoint in polls) {
final remaining = checkpoint - DateTime.now().difference(started);
if (remaining > Duration.zero) {
await wait(remaining);
}
if (await _claimOrFalse(claimed, callId)) return;
}
if (!await _claimOrFalse(reserve, callId)) return;
}
final params = CallKitParams(
id: callId,
nameCaller: callerName,
@@ -57,10 +103,42 @@ abstract final class VideoCallIncomingPushPresenter {
ios: const IOSParams(
handleType: 'generic',
supportsVideo: true,
configureAudioSession: false,
audioSessionActive: false,
audioSessionMode: 'videoChat',
ringtonePath: 'system_ringtone_default',
),
);
await FlutterCallkitIncoming.showCallkitIncoming(params);
await (showCallkitIncoming ?? FlutterCallkitIncoming.showCallkitIncoming)(
params,
);
}
static Future<bool> _claimOrFalse(
VideoCallIncomingClaim claim,
String callId,
) async {
try {
return await claim(callId);
} catch (_) {
return false;
}
}
static Future<bool> _isIncomingCallClaimed(String callId) async {
final claimed = await _voipChannel.invokeMethod<bool>(
'isIncomingCallClaimed',
<String, String>{'id': callId},
);
return claimed ?? false;
}
static Future<bool> _reserveIncomingCall(String callId) async {
final reserved = await _voipChannel.invokeMethod<bool>(
'reserveIncomingCall',
<String, String>{'id': callId},
);
return reserved ?? false;
}
}