feat(video_calls): fixed navigation from voip push

This commit is contained in:
misha
2026-06-17 11:06:22 +03:00
parent 95c1d40c79
commit 65d529b0af
5 changed files with 210 additions and 8 deletions

View File

@@ -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()
}

View File

@@ -91,6 +91,14 @@
<string>yx5399b03636104c6d920b87c1ad329254</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>Eatvio</string>
<key>CFBundleURLSchemes</key>
<array>
<string>eatvio</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -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<void> _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<void> _handleAccept(String callKitId) async {
if (_acceptHandledCallKitIds.contains(callKitId)) return;
@@ -384,7 +438,7 @@ class VideoCallCallKitCoordinator {
}
static String _parseConversationUuid(
Map<String, dynamic> extra, {
Map<String, Object?> extra, {
String? handle,
}) {
final fromExtra = _parseString(extra, const [
@@ -401,7 +455,7 @@ class VideoCallCallKitCoordinator {
return '';
}
static String? _parseString(Map<String, dynamic> map, List<String> keys) {
static String? _parseString(Map<String, Object?> map, List<String> keys) {
for (final key in keys) {
final value = map[key];
if (value is String && value.trim().isNotEmpty) {

View File

@@ -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:

View File

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