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);
+ });
}