From f51a68324b2674778c4e0d11e76522fc0227e32f Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 4 Jun 2026 14:38:27 +0300 Subject: [PATCH] feat(video_calls): ios voip pushes handling --- .gitea/workflows/test-flight-cd.yaml | 3 +- android/app/proguard-rules.pro | 3 + ios/Runner/AppDelegate.swift | 145 ++++++++++++- ios/Runner/Info.plist | 1 + lib/core/di/service_registrations.dart | 4 + lib/core/firebase_api.dart | 29 +++ .../video_call_callkit_coordinator.dart | 194 ++++++++++++++++++ .../video_call_notification_navigator.dart | 54 ++++- lib/main.dart | 4 + pubspec.yaml | 1 + 10 files changed, 420 insertions(+), 18 deletions(-) create mode 100644 lib/features/video_calls/services/video_call_callkit_coordinator.dart diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index c271f919..5fea01c4 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -57,10 +57,9 @@ jobs: - name: Flutter build run: | - flutter upgrade + flutter --version flutter clean flutter pub get - flutter pub upgrade flutter pub run build_runner build --delete-conflicting-outputs # Configure Flutter for release mode cd ios diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index b20e2682..4c01d86a 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -4,6 +4,9 @@ -keep class org.bouncycastle.** { *; } -dontwarn org.bouncycastle.** +-keep class com.hiennv.flutter_callkit_incoming.** { *; } +-dontwarn com.hiennv.flutter_callkit_incoming.** + -keep class com.pravera.flutter_foreground_task.** { *; } -dontwarn com.pravera.flutter_foreground_task.** diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 23324441..c439d251 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,10 +1,15 @@ +import AVFAudio +import CallKit import Flutter +import PushKit import UIKit +import UserNotifications import YandexLoginSDK +import flutter_callkit_incoming import flutter_foreground_task @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -13,22 +18,146 @@ import flutter_foreground_task SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback { registry in GeneratedPluginRegistrant.register(with: registry) } + + let mainQueue = DispatchQueue.main + let voipRegistry = PKPushRegistry(queue: mainQueue) + voipRegistry.delegate = self + voipRegistry.desiredPushTypes = [PKPushType.voIP] + + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self + } + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @available(iOS 8.0, *) - func application( - _ application: UIApplication, continue userActivity: NSUserActivity, - restorationHandler: @escaping ([Any]?) -> Void + override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void ) -> Bool { - YXLSdk.shared.processUserActivity(userActivity) - return true + if YXLSdk.shared.processUserActivity(userActivity) { + return true + } + return super.application( + application, + continue: userActivity, + restorationHandler: restorationHandler + ) } override public func application( - _ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { return YXLSdk.shared.handleOpen( - url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String) + url, + sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String + ) } + + + func pushRegistry( + _ registry: PKPushRegistry, + didUpdate credentials: PKPushCredentials, + for type: PKPushType + ) { + let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined() + SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken) + } + + func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) { + SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("") + } + + func pushRegistry( + _ registry: PKPushRegistry, + didReceiveIncomingPushWith payload: PKPushPayload, + for type: PKPushType, + completion: @escaping () -> Void + ) { + guard type == .voIP else { + completion() + return + } + + let dict = payload.dictionaryPayload + let id = (dict["id"] as? String) + ?? (dict["call_id"] as? String) + ?? (dict["message_id"] as? String) + ?? UUID().uuidString + let nameCaller = (dict["nameCaller"] as? String) + ?? (dict["caller_name"] as? String) + ?? (dict["callerName"] as? String) + ?? "" + let conversationUuid = (dict["conversation_uuid"] as? String) + ?? (dict["conversationUuid"] as? String) + ?? (dict["handle"] as? String) + ?? "" + let isVideo = (dict["isVideo"] as? Bool) + ?? (dict["is_video"] as? Bool) + ?? true + + var extra: [String: Any] = [:] + if !conversationUuid.isEmpty { + extra["conversationUuid"] = conversationUuid + } + if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String + ?? dict["avatarUrl"] as? String + { + extra["counterpartAvatarUrl"] = avatar + } + if let messageId = dict["message_id"] as? String { + extra["messageId"] = messageId + } + if let roomName = dict["room_name"] as? String { + extra["roomName"] = roomName + } + if let localName = dict["localParticipantName"] as? String + ?? dict["local_participant_name"] as? String + { + extra["localParticipantName"] = localName + } + if let counterpartName = dict["counterpartName"] as? String { + extra["counterpartName"] = counterpartName + } else if !nameCaller.isEmpty { + extra["counterpartName"] = nameCaller + } + + let data = flutter_callkit_incoming.Data( + id: id, + nameCaller: nameCaller, + handle: conversationUuid.isEmpty ? nameCaller : conversationUuid, + type: isVideo ? 1 : 0 + ) + data.extra = extra + + SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming( + data, + fromPushKit: true + ) { + completion() + } + } + + + func onAccept(_ call: Call, _ action: CXAnswerCallAction) { + action.fulfill() + } + + func onDecline(_ call: Call, _ action: CXEndCallAction) { + action.fulfill() + } + + func onEnd(_ call: Call, _ action: CXEndCallAction) { + action.fulfill() + } + + func onTimeOut(_ call: Call) {} + + func didActivateAudioSession(_ audioSession: AVAudioSession) {} + + func didDeactivateAudioSession(_ audioSession: AVAudioSession) {} } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7795e135..f217c73d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -46,6 +46,7 @@ UIBackgroundModes audio + voip fetch remote-notification diff --git a/lib/core/di/service_registrations.dart b/lib/core/di/service_registrations.dart index b6756bb1..a5ee09ee 100644 --- a/lib/core/di/service_registrations.dart +++ b/lib/core/di/service_registrations.dart @@ -35,6 +35,7 @@ import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart'; import 'package:eatvio_app/features/registration/services/registration_service.dart'; import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart'; import 'package:eatvio_app/features/settings/services/settings_service.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_service.dart'; import 'package:get_it/get_it.dart'; @@ -75,6 +76,9 @@ class ServiceRegistrations { sl.registerLazySingleton(() => CreatePlanService(sl())); sl.registerLazySingleton(() => NotificationsService(sl())); sl.registerLazySingleton(() => FirebaseApi(errorReporter: sl())); + sl.registerLazySingleton( + () => VideoCallCallKitCoordinator(firebaseApi: sl()), + ); sl.registerLazySingleton(() => ResetPasswordService(sl())); sl.registerLazySingleton(() => BlockedUserService(sl())); sl.registerLazySingleton(() => CommentsService(sl())); diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index 2c8c64c8..089ba23f 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -12,6 +12,7 @@ import 'package:eatvio_app/core/token_storage.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/services.dart' show MethodChannel, PlatformException; import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; class FirebaseApi { final ErrorReporter _errorReporter; @@ -87,6 +88,9 @@ class FirebaseApi { throw TimeoutException('registerPushToken timeout'); }, ); + if (Platform.isIOS) { + await _registerIosVoipPushToken(); + } } catch (e, st) { log(e.toString(), stackTrace: st); await _errorReporter.report(e, st, origin: 'FirebaseApi'); @@ -95,6 +99,31 @@ class FirebaseApi { } } + Future _registerIosVoipPushToken() async { + try { + final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim(); + if (token == null || token.isEmpty) return; + + final deviceInfo = DeviceInfoPlugin(); + await sl().post( + '/api/profile/notification/token/voip/register', + data: { + 'platform': 'ios', + 'hw_id': (await deviceInfo.iosInfo).identifierForVendor, + 'token': token, + }, + ).timeout( + const Duration(seconds: 10), + onTimeout: () { + throw TimeoutException('registerPushToken voip timeout'); + }, + ); + } catch (e, st) { + log(e.toString(), stackTrace: st); + await _errorReporter.report(e, st, origin: 'FirebaseApi'); + } + } + Future _isHuaweiDevice() async { if (!Platform.isAndroid) return false; try { diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart new file mode 100644 index 00000000..0ccd085d --- /dev/null +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -0,0 +1,194 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:eatvio_app/core/firebase_api.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; +import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +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_notification_navigator.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'; + + +class VideoCallCallKitCoordinator { + VideoCallCallKitCoordinator({ + required FirebaseApi firebaseApi, + VideoCallManager? videoCallManager, + VideoCallSessionCubit? videoCallSessionCubit, + }) : _firebaseApi = firebaseApi, + _videoCallManager = videoCallManager, + _videoCallSessionCubit = videoCallSessionCubit; + + final FirebaseApi _firebaseApi; + final VideoCallManager? _videoCallManager; + final VideoCallSessionCubit? _videoCallSessionCubit; + + StreamSubscription? _eventSubscription; + final Map _callKitParamsById = {}; + String? _activeCallKitId; + bool _initialized = false; + + Future initialize() async { + if (_initialized) return; + if (!Platform.isIOS) return; + _initialized = true; + + _eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent); + await _firebaseApi.registerPushToken(); + } + + void dispose() { + unawaited(_eventSubscription?.cancel()); + _eventSubscription = null; + _callKitParamsById.clear(); + _initialized = false; + } + + VideoCallManager get _manager => + _videoCallManager ?? sl(); + + VideoCallSessionCubit get _cubit => + _videoCallSessionCubit ?? sl(); + + Future _onCallKitEvent(CallEvent? event) async { + if (event == null) return; + + switch (event) { + case CallEventActionDidUpdateDevicePushTokenVoip(): + await _firebaseApi.registerPushToken(); + case CallEventActionCallIncoming(:final callKitParams): + _cacheCallKitParams(callKitParams); + case CallEventActionCallAccept(:final id): + await _handleAccept(id); + case CallEventActionCallCallback(:final id): + await _handleAccept(id); + case CallEventActionCallDecline(:final id): + await _handleEnd(id); + case CallEventActionCallEnded(:final id): + await _handleEnd(id); + case CallEventActionCallTimeout(:final id): + await _handleEnd(id); + default: + break; + } + } + + void _cacheCallKitParams(CallKitParams params) { + _callKitParamsById[params.id] = params; + _activeCallKitId = params.id; + } + + CallKitParams? _paramsForCallId(String id) => _callKitParamsById[id]; + + Future _handleAccept(String callKitId) async { + _activeCallKitId = callKitId; + final params = _paramsForCallId(callKitId); + final extra = params?.extra ?? {}; + + final conversationUuid = _parseConversationUuid( + extra, + handle: params?.handle, + ); + if (conversationUuid.isEmpty) { + log( + 'VideoCallCallKitCoordinator: accept without conversationUuid, callKitId=$callKitId', + name: 'VideoCallCallKit', + ); + return; + } + + final counterpartName = _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? params?.nameCaller; + final localParticipantName = _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]); + final avatarUrl = _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? params?.avatar; + + await _cubit.startCall( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + localParticipantName: localParticipantName, + ); + + await VideoCallNotificationNavigator.openIncomingCall( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: avatarUrl, + localParticipantName: localParticipantName, + ); + + _waitForConnectedThenEnterCall(callKitId); + } + + void _waitForConnectedThenEnterCall(String callKitId) { + void onSnapshot(VideoCallManagerSnapshot snapshot) { + if (snapshot.connectPhase != VideoCallConnectPhase.connected) return; + _manager.removeListener(onSnapshot); + unawaited(_cubit.enterCallStage()); + unawaited(FlutterCallkitIncoming.setCallConnected(callKitId)); + } + + if (_manager.snapshot.connectPhase == VideoCallConnectPhase.connected) { + onSnapshot(_manager.snapshot); + return; + } + _manager.addListener(onSnapshot); + } + + Future _handleEnd(String callKitId) async { + await FlutterCallkitIncoming.endCall(callKitId); + _callKitParamsById.remove(callKitId); + if (_activeCallKitId == callKitId) { + _activeCallKitId = null; + } + + if (_manager.isActiveCall || + _cubit.state.connectPhase != VideoCallConnectPhase.idle) { + await _cubit.endCall(); + } + } + + static String _parseConversationUuid( + Map extra, { + String? handle, + }) { + final fromExtra = _parseString(extra, const [ + 'conversationUuid', + 'conversation_uuid', + ]); + if (fromExtra != null && fromExtra.isNotEmpty) return fromExtra; + + final trimmedHandle = handle?.trim(); + if (trimmedHandle != null && trimmedHandle.isNotEmpty) { + return trimmedHandle; + } + + return ''; + } + + static String? _parseString( + Map map, + List keys, + ) { + for (final key in keys) { + final value = map[key]; + if (value is String && value.trim().isNotEmpty) { + return value.trim(); + } + } + return null; + } +} diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 0dfc1592..c648bfe0 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -12,6 +12,48 @@ class VideoCallNotificationNavigator { final uuid = conversationUuid ?? manager.conversationUuid; if (uuid.isEmpty) return; + await _pushCallScreen( + conversationUuid: uuid, + counterpartName: manager.counterpartName, + localParticipantName: manager.localParticipantName, + ); + + await manager.onCallScreenVisible( + conversationUuid: uuid, + counterpartName: manager.counterpartName, + localParticipantName: manager.localParticipantName, + ); + } + + static Future openIncomingCall({ + required String conversationUuid, + String? counterpartName, + String? counterpartAvatarUrl, + String? localParticipantName, + }) async { + if (conversationUuid.isEmpty) return; + + await _pushCallScreen( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + ); + + final manager = sl(); + await manager.onCallScreenVisible( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + localParticipantName: localParticipantName, + ); + } + + static Future _pushCallScreen({ + required String conversationUuid, + String? counterpartName, + String? counterpartAvatarUrl, + String? localParticipantName, + }) async { for (var attempt = 0; attempt < 40; attempt++) { final ctx = AppRouter.navigatorKey.currentContext; if (ctx != null && ctx.mounted) { @@ -24,17 +66,13 @@ class VideoCallNotificationNavigator { await router.pushNamed( VideoCallScreen.name, extra: { - 'conversationUuid': uuid, - 'counterpartName': manager.counterpartName, - 'localParticipantName': manager.localParticipantName, + 'conversationUuid': conversationUuid, + 'counterpartName': counterpartName, + 'counterpartAvatarUrl': counterpartAvatarUrl, + 'localParticipantName': localParticipantName, }, ); } - await manager.onCallScreenVisible( - conversationUuid: uuid, - counterpartName: manager.counterpartName, - localParticipantName: manager.localParticipantName, - ); return; } await Future.delayed(const Duration(milliseconds: 50)); diff --git a/lib/main.dart b/lib/main.dart index 521a55e4..ee56a8e7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,8 +4,11 @@ import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/notifications/push_navigation_handler.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_foreground_bootstrap.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart'; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -32,6 +35,7 @@ class _MyAppState extends State { void initState() { super.initState(); PushNavigationHandler.initialize(); + unawaited(sl().initialize()); } @override diff --git a/pubspec.yaml b/pubspec.yaml index 97b26610..3f87b816 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -87,6 +87,7 @@ dependencies: livekit_client: ^2.6.1 audio_session: ^0.1.25 flutter_foreground_task: ^9.2.1 + flutter_callkit_incoming: ^3.1.1 dev_dependencies: build_runner: ^2.10.4