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