diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e2f16042..ed5fa3dd 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -14,6 +14,7 @@ import flutter_foreground_task private var voipDiagnosticsChannel: FlutterMethodChannel? private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" + private let appUrlScheme = "eatvio" override func application( _ application: UIApplication, @@ -74,6 +75,37 @@ import flutter_foreground_task open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { + if url.scheme == appUrlScheme { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let queryItems = components?.queryItems ?? [] + let conversationUuid = queryItems.first { $0.name == "conversationUuid" }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let callKitId = queryItems.first { $0.name == "callKitId" }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) + recordVoipDiagnostic( + "accepted_call_url_opened", + data: [ + "callKitId": callKitId ?? "", + "hasCallKitId": callKitId != nil, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + ] + ) + if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty { + voipDiagnosticsChannel?.invokeMethod( + "acceptedVoipCall", + arguments: [ + "id": callKitId, + "handle": conversationUuid, + "extra": [ + "conversationUuid": conversationUuid + ], + ] + ) + } + return true + } + return YXLSdk.shared.handleOpen( url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String @@ -108,6 +140,75 @@ import flutter_foreground_task return nil } + private func conversationUuid(fromCall call: Call) -> String? { + let extra = call.data.extra as? [String: Any] ?? [:] + for key in ["conversationUuid", "conversation_uuid", "conversation_id", "chat_uuid", "uuid"] { + if let value = extra[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + + let handle = call.data.handle.trimmingCharacters(in: .whitespacesAndNewlines) + if !handle.isEmpty { + return handle + } + + return nil + } + + private func openAppForAcceptedCall(_ call: Call) { + guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else { + recordVoipDiagnostic( + "accepted_call_missing_conversation_uuid", + data: ["callKitId": call.data.uuid] + ) + return + } + + var components = URLComponents() + components.scheme = appUrlScheme + components.host = "video-call" + components.queryItems = [ + URLQueryItem(name: "callKitId", value: call.data.uuid), + URLQueryItem(name: "conversationUuid", value: conversationUuid) + ] + + guard let url = components.url else { + recordVoipDiagnostic( + "accepted_call_url_build_failed", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + ] + ) + return + } + + recordVoipDiagnostic( + "accepted_call_open_app_requested", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + "appState": UIApplication.shared.applicationState.rawValue, + ] + ) + + UIApplication.shared.open(url, options: [:]) { [weak self] success in + self?.recordVoipDiagnostic( + success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + ] + ) + } + } + private func sanitizedDiagnosticValue(_ value: Any?) -> Any { guard let value = value else { return "" @@ -345,6 +446,18 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { + recordVoipDiagnostic( + "callkit_accept_native", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid(fromCall: call) ?? "", + "data": call.data.toJSON(), + ] + ) + voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) + DispatchQueue.main.async { [weak self] in + self?.openAppForAcceptedCall(call) + } action.fulfill() } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c35ead46..5bcdccbd 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -91,6 +91,14 @@ yx5399b03636104c6d920b87c1ad329254 + + CFBundleURLName + Eatvio + CFBundleURLSchemes + + eatvio + + 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 0bfff5a5..55f5821e 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -30,7 +30,6 @@ class VideoCallCallKitCoordinator { final VideoCallSessionCubit? _videoCallSessionCubit; StreamSubscription? _eventSubscription; - AppLifecycleListener? _lifecycleListener; final Map _callKitParamsById = {}; final Set _acceptHandledCallKitIds = {}; final Set _suppressedEndCallKitIds = {}; @@ -50,17 +49,16 @@ class VideoCallCallKitCoordinator { if (Platform.isIOS) { _voipDiagnosticsChannel.setMethodCallHandler((call) async { - if (call.method == 'voipDiagnosticsUpdated') { - _scheduleNativeVoipDiagnosticsDrain(); + switch (call.method) { + case 'voipDiagnosticsUpdated': + _scheduleNativeVoipDiagnosticsDrain(); + case 'acceptedVoipCall': + await _handleNativeAcceptedVoipCall(call.arguments); } }); await _firebaseApi.registerPushToken(); - _lifecycleListener = AppLifecycleListener( - onResume: () => unawaited(_openActiveCallKitCallIfNeeded()), - ); WidgetsBinding.instance.addPostFrameCallback((_) { _scheduleNativeVoipDiagnosticsDrain(); - unawaited(_openActiveCallKitCallIfNeeded()); }); return; } @@ -83,9 +81,7 @@ class VideoCallCallKitCoordinator { void dispose() { unawaited(_eventSubscription?.cancel()); - _lifecycleListener?.dispose(); _eventSubscription = null; - _lifecycleListener = null; _callKitParamsById.clear(); _initialized = false; } @@ -289,53 +285,6 @@ class VideoCallCallKitCoordinator { return _paramsForCallId(id); } - /// When the user opens the app from the system CallKit UI (e.g. taps "Итвио"), - /// navigate to the in-app call screen for an already-accepted active call. - 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); @@ -368,6 +317,57 @@ class VideoCallCallKitCoordinator { ); } + Future _handleNativeAcceptedVoipCall(Object? arguments) async { + if (!Platform.isIOS) return; + + final data = _stringKeyedMap(arguments); + final extra = _stringKeyedMap(data['extra']); + final callKitId = _parseString(data, const ['id', 'uuid']); + if (callKitId == null) { + log( + 'VideoCallCallKitCoordinator: native acceptedVoipCall without callKitId, data=$data', + name: 'VideoCallCallKit', + ); + return; + } + + final conversationUuid = _parseConversationUuid( + extra, + handle: _parseString(data, const ['handle']), + ); + if (conversationUuid.isEmpty) { + log( + 'VideoCallCallKitCoordinator: native acceptedVoipCall without conversationUuid, data=$data', + name: 'VideoCallCallKit', + ); + return; + } + + await _deliverIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + _parseString(data, const ['nameCaller']), + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + _parseString(data, const ['avatar']), + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + } + Future _handleAccept(String callKitId) async { if (_acceptHandledCallKitIds.contains(callKitId)) return; @@ -387,14 +387,6 @@ class VideoCallCallKitCoordinator { return; } - if (Platform.isIOS) { - log( - 'VideoCallCallKitCoordinator: iOS accept received (keeping CallKit UI), callKitId=$callKitId, conversationUuid=$conversationUuid', - name: 'VideoCallCallKit', - ); - return; - } - await _deliverIncomingAccept( callKitId: callKitId, conversationUuid: conversationUuid, diff --git a/pubspec.yaml b/pubspec.yaml index 7732b34a..3cbf6779 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+115 +version: 1.3.27+114 publish_to: 'none' environment: