diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c800247d..4fa2445b 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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() } } diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 860525b3..a6006f7d 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -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? _eventSubscription; - AppLifecycleListener? _lifecycleListener; final Map _callKitParamsById = {}; final Set _acceptHandledCallKitIds = {}; final Set _suppressedEndCallKitIds = {}; String? _activeCallKitId; bool _initialized = false; + bool _nativeDiagnosticsDrainScheduled = false; + + static const MethodChannel _voipDiagnosticsChannel = MethodChannel( + 'com.eatvioapp/voip_diagnostics', + ); Future 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(); + void _scheduleNativeVoipDiagnosticsDrain() { + if (_nativeDiagnosticsDrainScheduled) return; + _nativeDiagnosticsDrainScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _nativeDiagnosticsDrainScheduled = false; + unawaited(_drainNativeVoipDiagnosticsToSentry()); + }); + } + + Future _drainNativeVoipDiagnosticsToSentry() async { + if (!Platform.isIOS) return; + try { + final events = await _voipDiagnosticsChannel.invokeListMethod( + '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 _onCallKitEvent(CallEvent? event) async { if (event == null) return; @@ -124,50 +176,6 @@ class VideoCallCallKitCoordinator { return _paramsForCallId(id); } - Future _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 ?? {}; - 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 _onNativeAcceptCallHandle(Map body) async { final extra = Map.from(body); final conversationUuid = _parseConversationUuid(extra); @@ -365,4 +373,21 @@ class VideoCallCallKitCoordinator { } return null; } + + static Map _stringKeyedMap(Object? value) { + if (value is! Map) return {}; + return value.map((key, value) => MapEntry(key.toString(), value)); + } + + static Map _stringifyMapValues(Map 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()); + }); + } }