From 65d529b0afb4dc272d60051115773c51f0c145e1 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 17 Jun 2026 11:06:22 +0300 Subject: [PATCH] feat(video_calls): fixed navigation from voip push --- ios/Runner/AppDelegate.swift | 113 ++++++++++++++++++ ios/Runner/Info.plist | 8 ++ .../video_call_callkit_coordinator.dart | 62 +++++++++- pubspec.yaml | 2 +- .../widgets/video_call_top_overlay_test.dart | 33 ++++- 5 files changed, 210 insertions(+), 8 deletions(-) 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 02c7c3fd..8c28fbab 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -49,8 +49,11 @@ 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(); @@ -245,6 +248,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; @@ -384,7 +438,7 @@ class VideoCallCallKitCoordinator { } static String _parseConversationUuid( - Map extra, { + Map extra, { String? handle, }) { final fromExtra = _parseString(extra, const [ @@ -401,7 +455,7 @@ class VideoCallCallKitCoordinator { return ''; } - static String? _parseString(Map map, List keys) { + static String? _parseString(Map map, List keys) { for (final key in keys) { final value = map[key]; if (value is String && value.trim().isNotEmpty) { diff --git a/pubspec.yaml b/pubspec.yaml index 96f67cf1..3cbf6779 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+113 +version: 1.3.27+114 publish_to: 'none' environment: diff --git a/test/features/video_calls/widgets/video_call_top_overlay_test.dart b/test/features/video_calls/widgets/video_call_top_overlay_test.dart index 4b2aea2d..77d7c312 100644 --- a/test/features/video_calls/widgets/video_call_top_overlay_test.dart +++ b/test/features/video_calls/widgets/video_call_top_overlay_test.dart @@ -3,8 +3,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('insertBanner shows message and can be removed manually', (tester) async { - await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox.shrink()))); + testWidgets('insertBanner shows message and can be removed manually', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox.shrink())), + ); final context = tester.element(find.byType(SizedBox)); final entry = VideoCallTopOverlay.insertBanner( @@ -23,7 +27,9 @@ void main() { }); testWidgets('insertBanner auto removes entry', (tester) async { - await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox.shrink()))); + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox.shrink())), + ); final context = tester.element(find.byType(SizedBox)); VideoCallTopOverlay.insertBanner( @@ -40,4 +46,25 @@ void main() { await tester.pump(const Duration(milliseconds: 40)); expect(find.text('Muted mic'), findsNothing); }); + + testWidgets('insertBanner does not throw without an overlay', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.shrink(), + ), + ); + + final context = tester.element(find.byType(SizedBox)); + final entry = VideoCallTopOverlay.insertBanner( + context, + message: 'Recording notice', + backgroundColor: Colors.grey, + svgAsset: VideoCallTopOverlay.warningSvg, + ); + + expect(entry.mounted, isFalse); + await tester.pump(); + expect(find.text('Recording notice'), findsNothing); + }); }