feat(video_calls): voip push handling fix

This commit is contained in:
misha
2026-06-16 19:28:16 +03:00
parent b13ff74220
commit e82866f936
2 changed files with 183 additions and 61 deletions

View File

@@ -11,6 +11,9 @@ import flutter_foreground_task
@main
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
private var voipRegistry: PKPushRegistry?
private var voipDiagnosticsChannel: FlutterMethodChannel?
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
override func application(
_ application: UIApplication,
@@ -30,7 +33,24 @@ import flutter_foreground_task
UNUserNotificationCenter.current().delegate = self
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
if let controller = window?.rootViewController as? FlutterViewController {
let channel = FlutterMethodChannel(
name: voipDiagnosticsChannelName,
binaryMessenger: controller.binaryMessenger
)
voipDiagnosticsChannel = channel
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "drainVoipDiagnostics" else {
result(FlutterMethodNotImplemented)
return
}
result(self?.drainVoipDiagnostics() ?? [])
}
}
return launched
}
@available(iOS 8.0, *)
@@ -88,6 +108,54 @@ import flutter_foreground_task
return nil
}
private func sanitizedDiagnosticValue(_ value: Any?) -> Any {
guard let value = value else {
return ""
}
if let string = value as? String {
return string
}
if let number = value as? NSNumber {
return number
}
if let bool = value as? Bool {
return bool
}
if let dictionary = value as? [AnyHashable: Any] {
return dictionary.reduce(into: [String: Any]()) { result, entry in
result[String(describing: entry.key)] = sanitizedDiagnosticValue(entry.value)
}
}
if let array = value as? [Any] {
return array.map { sanitizedDiagnosticValue($0) }
}
return String(describing: value)
}
private func recordVoipDiagnostic(_ name: String, data: [String: Any]) {
let defaults = UserDefaults.standard
var events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? []
var event: [String: Any] = [
"name": name,
"timestamp": ISO8601DateFormatter().string(from: Date()),
"appState": UIApplication.shared.applicationState.rawValue,
]
event["data"] = sanitizedDiagnosticValue(data)
events.append(event)
if events.count > 20 {
events = Array(events.suffix(20))
}
defaults.set(events, forKey: voipDiagnosticsStorageKey)
voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
}
private func drainVoipDiagnostics() -> [[String: Any]] {
let defaults = UserDefaults.standard
let events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? []
defaults.removeObject(forKey: voipDiagnosticsStorageKey)
return events
}
func pushRegistry(
_ registry: PKPushRegistry,
@@ -126,6 +194,13 @@ import flutter_foreground_task
if pushMarkers.contains(where: { $0.contains("video_call") })
&& pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) {
recordVoipDiagnostic(
"voip_end_or_cancel_received",
data: [
"markers": pushMarkers,
"payload": dict,
]
)
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls()
completion()
return
@@ -147,10 +222,10 @@ import flutter_foreground_task
?? (dict["handle"] as? String)
?? ""
let id = [
conversationUuid,
dict["call_id"] as? String,
dict["id"] as? String,
dict["message_id"] as? String,
conversationUuid,
]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.first { !$0.isEmpty && UUID(uuidString: $0) != nil }
@@ -194,21 +269,43 @@ import flutter_foreground_task
id: id,
nameCaller: nameCaller,
handle: conversationUuid.isEmpty ? nameCaller : conversationUuid,
type: 0
type: isVideo ? 1 : 0
)
data.appName = "Итвио"
data.duration = 45000
data.handleType = "generic"
data.supportsVideo = isVideo
data.ringtonePath = "system_ringtone_default"
data.configureAudioSession = false
data.includesCallsInRecents = false
data.extra = extra as NSDictionary
NSLog(
"[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
)
recordVoipDiagnostic(
"voip_incoming_received",
data: [
"callKitId": id,
"conversationUuid": conversationUuid,
"nameCaller": nameCaller,
"isVideo": isVideo,
"markers": pushMarkers,
"payload": dict,
]
)
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming(
data,
fromPushKit: true
) {
let activeCalls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
NSLog(
"[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)"
)
self.recordVoipDiagnostic(
"callkit_report_completed",
data: [
"callKitId": id,
"conversationUuid": conversationUuid,
"activeCallIds": activeIds,
"activeCallsCount": activeCalls.count,
]
)
completion()
}
}

View File

@@ -9,10 +9,12 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_callkit_incoming/entities/call_event.dart';
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class VideoCallCallKitCoordinator {
VideoCallCallKitCoordinator({
@@ -28,12 +30,16 @@ class VideoCallCallKitCoordinator {
final VideoCallSessionCubit? _videoCallSessionCubit;
StreamSubscription<CallEvent?>? _eventSubscription;
AppLifecycleListener? _lifecycleListener;
final Map<String, CallKitParams> _callKitParamsById = {};
final Set<String> _acceptHandledCallKitIds = {};
final Set<String> _suppressedEndCallKitIds = {};
String? _activeCallKitId;
bool _initialized = false;
bool _nativeDiagnosticsDrainScheduled = false;
static const MethodChannel _voipDiagnosticsChannel = MethodChannel(
'com.eatvioapp/voip_diagnostics',
);
Future<void> initialize() async {
if (_initialized) return;
@@ -42,12 +48,14 @@ class VideoCallCallKitCoordinator {
_eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent);
if (Platform.isIOS) {
_voipDiagnosticsChannel.setMethodCallHandler((call) async {
if (call.method == 'voipDiagnosticsUpdated') {
_scheduleNativeVoipDiagnosticsDrain();
}
});
await _firebaseApi.registerPushToken();
_lifecycleListener = AppLifecycleListener(
onResume: () => unawaited(_openActiveCallKitCallIfNeeded()),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_openActiveCallKitCallIfNeeded());
_scheduleNativeVoipDiagnosticsDrain();
});
return;
}
@@ -70,9 +78,7 @@ class VideoCallCallKitCoordinator {
void dispose() {
unawaited(_eventSubscription?.cancel());
_lifecycleListener?.dispose();
_eventSubscription = null;
_lifecycleListener = null;
_callKitParamsById.clear();
_initialized = false;
}
@@ -82,6 +88,52 @@ class VideoCallCallKitCoordinator {
VideoCallSessionCubit get _cubit =>
_videoCallSessionCubit ?? sl<VideoCallSessionCubit>();
void _scheduleNativeVoipDiagnosticsDrain() {
if (_nativeDiagnosticsDrainScheduled) return;
_nativeDiagnosticsDrainScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_nativeDiagnosticsDrainScheduled = false;
unawaited(_drainNativeVoipDiagnosticsToSentry());
});
}
Future<void> _drainNativeVoipDiagnosticsToSentry() async {
if (!Platform.isIOS) return;
try {
final events = await _voipDiagnosticsChannel.invokeListMethod<dynamic>(
'drainVoipDiagnostics',
);
if (events == null || events.isEmpty) return;
for (final rawEvent in events) {
final event = _stringKeyedMap(rawEvent);
final name = event['name']?.toString() ?? 'unknown';
await Sentry.captureMessage(
'iOS VoIP diagnostic: $name',
level: SentryLevel.info,
withScope: (scope) {
scope.setTag('origin', 'iOS VoIP');
scope.setTag('voip_diagnostic', 'true');
scope.setTag('voip_event', name);
scope.setContexts('voip_diagnostic', _stringifyMapValues(event));
},
);
}
} on PlatformException catch (e, st) {
log(
'VideoCallCallKitCoordinator: failed to drain native VoIP diagnostics: $e',
name: 'VideoCallCallKit',
stackTrace: st,
);
} catch (e, st) {
log(
'VideoCallCallKitCoordinator: failed to send native VoIP diagnostics: $e',
name: 'VideoCallCallKit',
stackTrace: st,
);
}
}
Future<void> _onCallKitEvent(CallEvent? event) async {
if (event == null) return;
@@ -124,50 +176,6 @@ class VideoCallCallKitCoordinator {
return _paramsForCallId(id);
}
Future<void> _openActiveCallKitCallIfNeeded() async {
if (!Platform.isIOS) return;
if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return;
final activeCalls = await FlutterCallkitIncoming.activeCalls();
if (activeCalls.isEmpty) return;
for (final params in activeCalls) {
_cacheCallKitParams(params);
if (!params.isAccepted) continue;
final extra = params.extra ?? <String, dynamic>{};
final conversationUuid = _parseConversationUuid(
extra,
handle: params.handle,
);
if (conversationUuid.isEmpty) continue;
await _deliverIncomingAccept(
callKitId: params.id,
conversationUuid: conversationUuid,
counterpartName:
_parseString(extra, const [
'counterpartName',
'caller_name',
'callerName',
]) ??
params.nameCaller,
counterpartAvatarUrl:
_parseString(extra, const [
'counterpartAvatarUrl',
'avatar',
'avatarUrl',
'caller_avatar',
]) ??
params.avatar,
localParticipantName: _parseString(extra, const [
'localParticipantName',
'local_participant_name',
]),
);
return;
}
}
Future<void> _onNativeAcceptCallHandle(Map<dynamic, dynamic> body) async {
final extra = Map<String, dynamic>.from(body);
final conversationUuid = _parseConversationUuid(extra);
@@ -365,4 +373,21 @@ class VideoCallCallKitCoordinator {
}
return null;
}
static Map<String, Object?> _stringKeyedMap(Object? value) {
if (value is! Map) return <String, Object?>{};
return value.map((key, value) => MapEntry(key.toString(), value));
}
static Map<String, Object?> _stringifyMapValues(Map<String, Object?> map) {
return map.map((key, value) {
if (value is Map) {
return MapEntry(key, _stringifyMapValues(_stringKeyedMap(value)));
}
if (value is Iterable) {
return MapEntry(key, value.map((item) => item?.toString()).toList());
}
return MapEntry(key, value?.toString());
});
}
}