feat(video_calls): ios voip pushes handling

This commit is contained in:
misha
2026-06-04 14:38:27 +03:00
parent 634d83fd61
commit f51a68324b
10 changed files with 420 additions and 18 deletions

View File

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

View File

@@ -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.**

View File

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

View File

@@ -46,6 +46,7 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>voip</string>
<string>fetch</string>
<string>remote-notification</string>
</array>

View File

@@ -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>(() => CreatePlanService(sl()));
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl()));
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
);
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));

View File

@@ -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<void> _registerIosVoipPushToken() async {
try {
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
if (token == null || token.isEmpty) return;
final deviceInfo = DeviceInfoPlugin();
await sl<Dio>().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<bool> _isHuaweiDevice() async {
if (!Platform.isAndroid) return false;
try {

View File

@@ -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<CallEvent?>? _eventSubscription;
final Map<String, CallKitParams> _callKitParamsById = {};
String? _activeCallKitId;
bool _initialized = false;
Future<void> 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<VideoCallManager>();
VideoCallSessionCubit get _cubit =>
_videoCallSessionCubit ?? sl<VideoCallSessionCubit>();
Future<void> _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<void> _handleAccept(String callKitId) async {
_activeCallKitId = callKitId;
final params = _paramsForCallId(callKitId);
final extra = params?.extra ?? <String, dynamic>{};
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<void> _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<String, dynamic> 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<String, dynamic> map,
List<String> keys,
) {
for (final key in keys) {
final value = map[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
}
return null;
}
}

View File

@@ -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<void> 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<VideoCallManager>();
await manager.onCallScreenVisible(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
);
}
static Future<void> _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: <String, dynamic>{
'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<void>.delayed(const Duration(milliseconds: 50));

View File

@@ -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<MyApp> {
void initState() {
super.initState();
PushNavigationHandler.initialize();
unawaited(sl<VideoCallCallKitCoordinator>().initialize());
}
@override

View File

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