From 03f037518e0141bf6edc4b5fdc86a0ef1d81570e Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 15:01:46 +0300 Subject: [PATCH] feat(video_calls): fixed ios and HMS pushes for calls --- .../com/eatvioapp/app/HmsPushService.kt | 85 ++++++++++++++ ios/Runner/AppDelegate.swift | 14 +++ .../video_call_callkit_coordinator.dart | 106 +++++++++++++----- .../video_call_notification_navigator.dart | 4 +- pubspec.yaml | 2 +- 5 files changed, 183 insertions(+), 28 deletions(-) diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index f18b3992..f8db072b 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -2,11 +2,14 @@ package com.eatvioapp.app import android.app.PendingIntent import android.content.Intent +import android.os.Bundle import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import com.hiennv.flutter_callkit_incoming.CallkitConstants import com.huawei.hms.push.HmsMessageService import com.huawei.hms.push.RemoteMessage +import java.util.HashMap class HmsPushService : HmsMessageService() { override fun onNewToken(token: String?) { @@ -20,6 +23,11 @@ class HmsPushService : HmsMessageService() { if (message == null) return + if (isIncomingVideoCall(message.dataOfMap)) { + showIncomingVideoCallNotification(message.dataOfMap) + return + } + // Берём action_url из data-поля, если оно есть. val actionUrl = message.dataOfMap["action_url"] @@ -57,6 +65,83 @@ class HmsPushService : HmsMessageService() { NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification) } + + private fun isIncomingVideoCall(data: Map): Boolean { + val rawType = data["type"] + ?: data["notification_type"] + ?: data["event"] + ?: data["action"] + ?: data["push_type"] + ?: data["call_type"] + val type = rawType?.lowercase()?.replace("-", "_") + return type?.contains("video_call") == true && + !type.contains("end") && + !type.contains("ended") + } + + private fun showIncomingVideoCallNotification(data: Map) { + val conversationUuid = data["conversationUuid"] + ?: data["conversation_uuid"] + ?: data["conversation_id"] + ?: data["chat_uuid"] + ?: data["uuid"] + ?: return + val callerName = data["counterpartName"] + ?: data["counterpart_name"] + ?: data["caller_name"] + ?: data["callerName"] + ?: data["nameCaller"] + ?: data["caller"] + ?: "Итвио" + val avatarUrl = data["counterpartAvatarUrl"] + ?: data["counterpart_avatar_url"] + ?: data["avatar"] + ?: data["avatarUrl"] + ?: data["avatar_url"] + ?: data["caller_avatar"] + ?: "" + val localParticipantName = data["localParticipantName"] + ?: data["local_participant_name"] + + val extra = HashMap() + extra["conversationUuid"] = conversationUuid + extra["counterpartName"] = callerName + if (avatarUrl.isNotBlank()) { + extra["counterpartAvatarUrl"] = avatarUrl + } + if (!localParticipantName.isNullOrBlank()) { + extra["localParticipantName"] = localParticipantName + } + + val callData = Bundle().apply { + putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName) + putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") + putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl) + putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) + putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L) + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer") + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline") + putSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA, extra) + putSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS, HashMap()) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_NOTIFICATION, true) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_SMALL_EX_NOTIFICATION, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_LOGO, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_CALL_ID, false) + putString(CallkitConstants.EXTRA_CALLKIT_RINGTONE_PATH, "system_ringtone_default") + putString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR, "#2787F5") + putString(CallkitConstants.EXTRA_CALLKIT_ACTION_COLOR, "#2787F5") + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR, "#ffffff") + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_FULL_LOCKED_SCREEN, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_FULL_SCREEN, false) + } + + val intent = com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver + .getIntent(this, CallkitConstants.ACTION_CALL_INCOMING, callData) + .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + sendBroadcast(intent) + } } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index a1632593..63277f90 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -86,6 +86,20 @@ import flutter_foreground_task } let dict = payload.dictionaryPayload + if let pushType = ( + dict["type"] as? String + ?? dict["notification_type"] as? String + ?? dict["event"] as? String + ?? dict["action"] as? String + )?.lowercased().replacingOccurrences(of: "-", with: "_"), + pushType.contains("video_call"), + pushType.contains("end") || pushType.contains("cancel") + { + SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls() + completion() + return + } + let nameCaller = (dict["nameCaller"] as? String) ?? (dict["caller_name"] as? String) ?? (dict["callerName"] as? String) 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 47a310c0..8b4f63cf 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -14,7 +14,6 @@ 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, @@ -29,6 +28,7 @@ class VideoCallCallKitCoordinator { final VideoCallSessionCubit? _videoCallSessionCubit; StreamSubscription? _eventSubscription; + AppLifecycleListener? _lifecycleListener; final Map _callKitParamsById = {}; final Set _acceptHandledCallKitIds = {}; final Set _suppressedEndCallKitIds = {}; @@ -43,13 +43,22 @@ class VideoCallCallKitCoordinator { if (Platform.isIOS) { await _firebaseApi.registerPushToken(); + _lifecycleListener = AppLifecycleListener( + onResume: () => unawaited(_openActiveCallKitCallIfNeeded()), + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_openActiveCallKitCallIfNeeded()); + }); return; } if (Platform.isAndroid) { VideoCallFcmHandler.registerForegroundListener(); FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle); - await FlutterCallkitIncoming.requestNotificationPermission({ + await FlutterCallkitIncoming.requestNotificationPermission(< + String, + dynamic + >{ 'title': 'Уведомления', 'rationaleMessagePermission': 'Разрешите уведомления, чтобы получать входящие видеозвонки.', @@ -61,13 +70,14 @@ class VideoCallCallKitCoordinator { void dispose() { unawaited(_eventSubscription?.cancel()); + _lifecycleListener?.dispose(); _eventSubscription = null; + _lifecycleListener = null; _callKitParamsById.clear(); _initialized = false; } - VideoCallManager get _manager => - _videoCallManager ?? sl(); + VideoCallManager get _manager => _videoCallManager ?? sl(); VideoCallSessionCubit get _cubit => _videoCallSessionCubit ?? sl(); @@ -102,6 +112,49 @@ class VideoCallCallKitCoordinator { CallKitParams? _paramsForCallId(String id) => _callKitParamsById[id]; + Future _openActiveCallKitCallIfNeeded() async { + if (!Platform.isIOS) return; + if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return; + + final activeCalls = await FlutterCallkitIncoming.activeCalls(); + if (activeCalls.isEmpty) return; + + for (final params in activeCalls) { + _cacheCallKitParams(params); + final extra = params.extra ?? {}; + final conversationUuid = _parseConversationUuid( + extra, + handle: params.handle, + ); + if (conversationUuid.isEmpty) continue; + + await _deliverIncomingAccept( + callKitId: params.id, + conversationUuid: conversationUuid, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + params.nameCaller, + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + params.avatar, + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + return; + } + } + Future _onNativeAcceptCallHandle(Map body) async { final extra = Map.from(body); final conversationUuid = _parseConversationUuid(extra); @@ -156,17 +209,21 @@ class VideoCallCallKitCoordinator { await _deliverIncomingAccept( callKitId: callKitId, conversationUuid: conversationUuid, - counterpartName: _parseString(extra, const [ - 'counterpartName', - 'caller_name', - 'callerName', - ]) ?? params?.nameCaller, - counterpartAvatarUrl: _parseString(extra, const [ - 'counterpartAvatarUrl', - 'avatar', - 'avatarUrl', - 'caller_avatar', - ]) ?? params?.avatar, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + params?.nameCaller, + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + params?.avatar, localParticipantName: _parseString(extra, const [ 'localParticipantName', 'local_participant_name', @@ -191,12 +248,14 @@ class VideoCallCallKitCoordinator { final binding = WidgetsBinding.instance; final completer = Completer(); binding.addPostFrameCallback((_) { - unawaited(_openConnectingScreenAfterAccept( - conversationUuid: conversationUuid, - counterpartName: counterpartName, - counterpartAvatarUrl: counterpartAvatarUrl, - localParticipantName: localParticipantName, - ).whenComplete(completer.complete)); + unawaited( + _openConnectingScreenAfterAccept( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + ).whenComplete(completer.complete), + ); }); await completer.future; } finally { @@ -284,10 +343,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/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 09d88086..5ffbd983 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -47,7 +47,7 @@ class VideoCallNotificationNavigator { String? counterpartAvatarUrl, String? localParticipantName, }) async { - for (var attempt = 0; attempt < 40; attempt++) { + for (var attempt = 0; attempt < 100; attempt++) { final ctx = AppRouter.navigatorKey.currentContext; if (ctx != null && ctx.mounted) { final router = GoRouter.of(ctx); @@ -68,7 +68,7 @@ class VideoCallNotificationNavigator { } return; } - await Future.delayed(const Duration(milliseconds: 50)); + await Future.delayed(const Duration(milliseconds: 100)); } } } diff --git a/pubspec.yaml b/pubspec.yaml index cfd60cd9..8f353be8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.26+107 +version: 1.3.27+108 publish_to: 'none' environment: