feat(video_calls): ios voip push handling fix

This commit is contained in:
misha
2026-10-19 16:09:20 +03:00
parent 4c38026748
commit a6b94f835c
10 changed files with 310 additions and 36 deletions

View File

@@ -15,6 +15,8 @@ import flutter_foreground_task
private var voipDiagnosticsChannel: FlutterMethodChannel?
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
private let pendingAcceptedAtKey = "eatvioAcceptedAt"
private let pendingAcceptedCallTtl: TimeInterval = 120
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
private let appUrlScheme = "eatvio"
@@ -234,6 +236,26 @@ import flutter_foreground_task
return nil
}
private func voipBool(_ value: Any?) -> Bool? {
if let bool = value as? Bool {
return bool
}
if let number = value as? NSNumber {
return number.intValue != 0
}
if let text = value as? String {
switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes":
return true
case "0", "false", "no":
return false
default:
return nil
}
}
return nil
}
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
guard
let actionUrl = actionUrl,
@@ -282,16 +304,31 @@ import flutter_foreground_task
}
private func storePendingAcceptedCall(_ call: Call) {
let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
var payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
payload[pendingAcceptedAtKey] = Date().timeIntervalSince1970
UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey)
}
/// An accept that Flutter never consumed (the process died before the
/// scene opened) must not open the call screen on a later launch: that
/// screen requests a token, and the token request rings the other person.
private func drainPendingAcceptedVoipCall() -> [String: Any]? {
let defaults = UserDefaults.standard
guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
guard var payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
return nil
}
defaults.removeObject(forKey: pendingAcceptedVoipCallKey)
let acceptedAt = (payload.removeValue(forKey: pendingAcceptedAtKey) as? NSNumber)?.doubleValue
if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl {
recordVoipDiagnostic(
"pending_accepted_call_expired",
data: [
"callKitId": payload["id"] as? String ?? "",
"ageSeconds": Date().timeIntervalSince1970 - acceptedAt,
]
)
return nil
}
return payload
}
@@ -515,6 +552,13 @@ import flutter_foreground_task
handle: conversation,
type: 1
)
// The plugin sends this payload with its "ended" event. Dart uses the
// marker to tell a remote end from a decline by the user.
var endExtra: [String: Any] = ["endedPush": true]
if !conversation.isEmpty {
endExtra["conversationUuid"] = conversation
}
endData.extra = endExtra as NSDictionary
plugin.endCall(endData)
releaseIncomingCallClaim(id)
}
@@ -686,12 +730,18 @@ import flutter_foreground_task
} else if !nameCaller.isEmpty {
extra["counterpartName"] = nameCaller
}
if let isConsultant = voipBool(dict["is_consultant"] ?? dict["isConsultant"]) {
extra["counterpartIsConsultant"] = isConsultant
}
// Closing this report looks like a user decline. Dart must not tell the
// backend, or the server sends another ended VoIP and the other phone
// shows it as a new incoming call.
if isEndedPush {
extra["endedPush"] = true
}
// An ended push with no ringing call still has to be reported. That
// report is closed at once, so it must not leave a trace anywhere.
let isPhantomEndedReport = isEndedPush && callKitIdSourceName != "active_conversation"
let data = flutter_callkit_incoming.Data(
id: id,
@@ -699,8 +749,15 @@ import flutter_foreground_task
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
type: isVideo ? 1 : 0
)
data.appName = "Итвио"
data.handleType = "generic"
data.supportsVideo = true
data.duration = 45000
data.extra = extra as NSDictionary
data.missedNotificationSubtitle = "Пропущенный видеозвонок"
data.missedNotificationCallbackText = "Перезвонить"
data.isShowMissedCallNotification = !isEndedPush
data.includesCallsInRecents = !isPhantomEndedReport
// The plugin activates AVAudioSession itself on answer and again 1.2s
// later. That drops the ringing UI and leaves one side silent. CallKit
// and WebRTC own the session instead.
@@ -736,18 +793,25 @@ import flutter_foreground_task
callKitIdSourceName: callKitIdSourceName,
conversationUuid: conversationUuid,
endAfterReport: isEndedPush,
endImmediately: isPhantomEndedReport,
completion: completion
)
}
/// PushKit kills the app unless every VoIP push is reported to CallKit and
/// its completion handler runs. Both happen here, including an "ended" push.
///
/// `endImmediately` is for an ended push that matched no ringing call. The
/// report is closed inside its own completion, before the incoming UI has a
/// chance to show. An end transaction would take one more round trip and
/// would also reach `onDecline`, which resets the in-app audio restart.
private func reportVoipPushToCallKit(
_ data: flutter_callkit_incoming.Data,
callKitId: String,
callKitIdSourceName: String,
conversationUuid: String,
endAfterReport: Bool,
endImmediately: Bool,
completion: @escaping () -> Void
) {
let gate = VoipPushCompletion(completion)
@@ -757,6 +821,12 @@ import flutter_foreground_task
}
let afterReport = {
if endAfterReport, endImmediately,
let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance
{
// Reason 6 is CXCallEndedReason.remoteEnded in the plugin.
plugin.saveEndCall(callKitId, 6)
}
if endAfterReport {
DispatchQueue.main.async {
self.endCallsForEndedPush(

View File

@@ -69,13 +69,13 @@ class FirebaseApi {
}
Future<void> registerPushToken() async {
if (await TokenStorage.haveTokenInStorage()) {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
if (!await TokenStorage.haveTokenInStorage()) return;
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token != null && token.isNotEmpty) {
await sl<Dio>().post(
'/api/profile/notification/token/register',
data: {
@@ -89,14 +89,17 @@ class FirebaseApi {
throw TimeoutException('registerPushToken timeout');
},
);
if (Platform.isIOS) {
await _registerIosVoipPushToken();
}
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
// Не пробрасываем ошибку дальше, так как это не критичная операция
}
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
// Не пробрасываем ошибку дальше, так как это не критичная операция
}
// Incoming calls on iOS come only through the VoIP token. It does not
// depend on the FCM token, so a missing or failed FCM registration must
// not leave the device without calls.
if (Platform.isIOS) {
await _registerIosVoipPushToken();
}
}

View File

@@ -10,6 +10,7 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_callkit_incoming/entities/call_event.dart';
@@ -375,7 +376,7 @@ class VideoCallCallKitCoordinator {
);
return;
}
await _handleEnded(id);
await _handleEnded(id, eventExtra: _callKitEventParams(event)?.extra);
}
Future<void> _captureCallKitEventToSentry(CallEvent event) async {
@@ -873,10 +874,24 @@ class VideoCallCallKitCoordinator {
extra,
handle: params?.handle ?? callKitId,
);
final endedPushDismissal = callKitDeclineIsEndedPush(
cachedExtra: params?.extra,
eventExtra: eventExtra,
);
final remoteEndDismissal =
callKitDeclineIsEndedPush(
cachedExtra: params?.extra,
eventExtra: eventExtra,
) ||
VideoCallRemoteEndRegistry.contains(callKitId);
if (remoteEndDismissal) {
// The call is already closed on the native side. Another endCall is
// not needed: while the plugin's push flag is set it would end the
// last reported call instead of this id, which may be a newer ring.
log(
'VideoCallCallKitCoordinator: remote end dismissal, callKitId=$callKitId',
name: 'VideoCallCallKit',
);
_forgetCallKitId(callKitId);
return;
}
_suppressedEndCallKitIds.add(callKitId);
try {
@@ -888,12 +903,7 @@ class VideoCallCallKitCoordinator {
}),
);
}
_callKitParamsById.remove(callKitId);
if (_activeCallKitId == callKitId) {
_activeCallKitId = null;
}
if (endedPushDismissal) return;
_forgetCallKitId(callKitId);
if (_manager.isActiveCall ||
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
@@ -907,7 +917,10 @@ class VideoCallCallKitCoordinator {
}
}
Future<void> _handleEnded(String callKitId) async {
Future<void> _handleEnded(
String callKitId, {
Map<String, dynamic>? eventExtra,
}) async {
if (_suppressedEndCallKitIds.contains(callKitId)) {
log(
'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId',
@@ -916,16 +929,36 @@ class VideoCallCallKitCoordinator {
return;
}
_callKitParamsById.remove(callKitId);
if (_activeCallKitId == callKitId) {
_activeCallKitId = null;
// iOS reports an ended VoIP push to CallKit and closes it right away.
// That close is marked and must be remembered before the decline (or the
// later timeout) event for the same id arrives with no marker.
final remoteEnd = callKitDeclineIsEndedPush(
cachedExtra: _paramsForCallId(callKitId)?.extra,
eventExtra: eventExtra,
);
if (remoteEnd) {
VideoCallRemoteEndRegistry.mark(callKitId);
}
_forgetCallKitId(callKitId);
// Native already ends a remote-ended call. Calling endCall again here
// overwrites the plugin's stored payload, so the decline that follows
// would lose the marker.
if (remoteEnd) return;
try {
await FlutterCallkitIncoming.endCall(callKitId);
} catch (_) {}
}
void _forgetCallKitId(String callKitId) {
_callKitParamsById.remove(callKitId);
if (_activeCallKitId == callKitId) {
_activeCallKitId = null;
}
}
static String _parseConversationUuid(
Map<String, Object?> extra, {
String? handle,

View File

@@ -2,6 +2,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:eatvio_app/features/video_calls/services/video_call_remote_end_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';
@@ -53,16 +54,23 @@ abstract final class VideoCallEndedPushHandler {
return false;
}
/// 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 {
final uuid = conversationUuid?.trim();
try {
final activeCalls = await FlutterCallkitIncoming.activeCalls();
if (uuid == null || uuid.isEmpty) {
for (final call in activeCalls) {
VideoCallRemoteEndRegistry.mark(call.id);
}
await FlutterCallkitIncoming.endAllCalls();
return;
}
for (final call in activeCalls) {
if (!callMatchesConversation(call, uuid)) continue;
VideoCallRemoteEndRegistry.mark(call.id);
try {
await FlutterCallkitIncoming.endCall(call.id);
} catch (_) {}

View File

@@ -20,6 +20,10 @@ typedef VideoCallAndroidIncomingShow =
/// never arrived.
abstract final class VideoCallIncomingPushPresenter {
static const String _androidAccentColor = '#2787F5';
/// Same texts as the native VoIP path in `AppDelegate.swift`.
static const String iosMissedCallSubtitle = 'Пропущенный видеозвонок';
static const String iosMissedCallCallbackText = 'Перезвонить';
static const MethodChannel _voipChannel = MethodChannel(
'com.eatvioapp/voip_diagnostics',
);
@@ -97,6 +101,11 @@ abstract final class VideoCallIncomingPushPresenter {
'counterpartIsConsultant': payload.counterpartIsConsultant,
},
callingNotification: const NotificationParams(showNotification: false),
missedCallNotification: const NotificationParams(
showNotification: true,
subtitle: iosMissedCallSubtitle,
callbackText: iosMissedCallCallbackText,
),
android: const AndroidParams(
isCustomNotification: true,
isShowLogo: false,

View File

@@ -0,0 +1,37 @@
/// CallKit ids that were closed because the call ended remotely.
///
/// On iOS every close of a ringing call comes back from the plugin as a
/// decline, whether the user tapped Decline or the app ended the call after a
/// `video_call_ended` signal. Only a decline by the user may be reported to
/// the backend; the other kind would make the server send one more ended
/// push and the other phone would show it as a new incoming call.
abstract final class VideoCallRemoteEndRegistry {
/// Longer than the 45 s ring timeout: the plugin still emits a timeout
/// event for a call it reported and closed right away.
static const Duration ttl = Duration(minutes: 2);
static final Map<String, DateTime> _markedAt = <String, DateTime>{};
static void mark(String callKitId, {DateTime? now}) {
final id = _normalize(callKitId);
if (id.isEmpty) return;
final at = now ?? DateTime.now();
_prune(at);
_markedAt[id] = at;
}
static bool contains(String callKitId, {DateTime? now}) {
final id = _normalize(callKitId);
if (id.isEmpty) return false;
_prune(now ?? DateTime.now());
return _markedAt.containsKey(id);
}
static void clear() => _markedAt.clear();
static String _normalize(String callKitId) => callKitId.trim().toLowerCase();
static void _prune(DateTime now) {
_markedAt.removeWhere((_, markedAt) => now.difference(markedAt) >= ttl);
}
}

View File

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.57+146
version: 1.3.58+147
publish_to: 'none'
environment:

View File

@@ -1,9 +1,15 @@
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
import 'package:flutter/services.dart';
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const conversation = '11111111-1111-4111-8111-111111111111';
const otherConversation = '22222222-2222-4222-8222-222222222222';
const callKitChannel = MethodChannel('flutter_callkit_incoming');
group('VideoCallEndedPushHandler.callMatchesConversation', () {
test('matches the conversation stored as the call handle', () {
@@ -32,10 +38,7 @@ void main() {
});
test('leaves a call from another chat alone', () {
const call = CallKitParams(
id: 'other-id',
handle: '22222222-2222-4222-8222-222222222222',
);
const call = CallKitParams(id: 'other-id', handle: otherConversation);
expect(
VideoCallEndedPushHandler.callMatchesConversation(call, conversation),
@@ -43,4 +46,62 @@ void main() {
);
});
});
group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () {
final calls = <MethodCall>[];
late List<Map<String, Object?>> activeCalls;
setUp(() {
calls.clear();
VideoCallRemoteEndRegistry.clear();
activeCalls = <Map<String, Object?>>[
<String, Object?>{
'id': 'ringing-id',
'handle': conversation,
'extra': <String, Object?>{'conversationUuid': conversation},
},
<String, Object?>{
'id': 'other-id',
'handle': otherConversation,
'extra': <String, Object?>{'conversationUuid': otherConversation},
},
];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(callKitChannel, (call) async {
calls.add(call);
if (call.method == 'activeCalls') return activeCalls;
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(callKitChannel, null);
});
test('ends only the matching call and marks it as a remote end', () async {
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: conversation,
);
final ended = calls
.where((call) => call.method == 'endCall')
.map((call) => (call.arguments as Map)['id'])
.toList();
expect(ended, <String>['ringing-id']);
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
expect(VideoCallRemoteEndRegistry.contains('other-id'), isFalse);
});
test(
'without a conversation ends every call and marks all of them',
() async {
await VideoCallEndedPushHandler.dismissIncomingCallUi();
expect(calls.map((call) => call.method), contains('endAllCalls'));
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
expect(VideoCallRemoteEndRegistry.contains('other-id'), isTrue);
},
);
});
}

View File

@@ -75,6 +75,15 @@ void main() {
expect(shown.single.ios?.audioSessionActive, isFalse);
expect(shown.single.ios?.audioSessionMode, 'videoChat');
expect(shown.single.ios?.ringtonePath, isNull);
expect(shown.single.missedCallNotification?.showNotification, isTrue);
expect(
shown.single.missedCallNotification?.subtitle,
VideoCallIncomingPushPresenter.iosMissedCallSubtitle,
);
expect(
shown.single.missedCallNotification?.callbackText,
VideoCallIncomingPushPresenter.iosMissedCallCallbackText,
);
});
test('iOS stops polling when the push claims the call', () async {

View File

@@ -0,0 +1,44 @@
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
setUp(VideoCallRemoteEndRegistry.clear);
group('VideoCallRemoteEndRegistry', () {
test('remembers a marked id regardless of case and whitespace', () {
VideoCallRemoteEndRegistry.mark(' 11111111-1111-4111-8111-111111111111 ');
expect(
VideoCallRemoteEndRegistry.contains(
'11111111-1111-4111-8111-111111111111'.toUpperCase(),
),
isTrue,
);
});
test('does not know an id that was never marked', () {
expect(VideoCallRemoteEndRegistry.contains('other'), isFalse);
expect(VideoCallRemoteEndRegistry.contains(''), isFalse);
});
test('outlives the 45 s ring timeout and expires after the ttl', () {
final markedAt = DateTime(2026, 10, 19, 12);
VideoCallRemoteEndRegistry.mark('call', now: markedAt);
expect(
VideoCallRemoteEndRegistry.contains(
'call',
now: markedAt.add(const Duration(seconds: 50)),
),
isTrue,
);
expect(
VideoCallRemoteEndRegistry.contains(
'call',
now: markedAt.add(VideoCallRemoteEndRegistry.ttl),
),
isFalse,
);
});
});
}