From 27fe4afd819be5cb11a29c74f8bb7f114a8a6e57 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 9 Jun 2026 09:55:20 +0300 Subject: [PATCH] feat(video_calls): added new incomng push impl, fixed mic and video sharing while connecting to call --- android/app/src/main/AndroidManifest.xml | 61 +++++- .../com/eatvioapp/app/CallKitIntentBridge.kt | 23 ++ .../EatvioCallkitIncomingBridgeActivity.kt | 29 +++ .../EatvioCallkitTransparentBridgeActivity.kt | 25 +++ .../kotlin/com/eatvioapp/app/MainActivity.kt | 21 ++ lib/core/app_initializer.dart | 2 + .../push_navigation_handler.dart | 24 ++- .../managers/video_call_manager.dart | 154 +++++++++++--- .../video_call_session_cubit.dart | 5 +- .../video_call_session_state.dart | 1 - .../video_call_callkit_coordinator.dart | 199 +++++++++++++----- .../services/video_call_fcm_handler.dart | 75 +++++++ .../video_call_incoming_push_presenter.dart | 53 +++++ .../video_call_notification_navigator.dart | 7 - .../services/video_call_push_payload.dart | 145 +++++++++++++ lib/main.dart | 4 +- .../video_call_push_payload_test.dart | 51 +++++ 17 files changed, 784 insertions(+), 95 deletions(-) create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/EatvioCallkitIncomingBridgeActivity.kt create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/EatvioCallkitTransparentBridgeActivity.kt create mode 100644 lib/features/video_calls/services/video_call_fcm_handler.dart create mode 100644 lib/features/video_calls/services/video_call_incoming_push_presenter.dart create mode 100644 lib/features/video_calls/services/video_call_push_payload.dart create mode 100644 test/features/video_calls/services/video_call_push_payload_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d63acff8..91bcaa52 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -36,7 +37,6 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" - android:taskAffinity="" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" @@ -80,6 +80,63 @@ + + + + + + + + + + + + + + + + + + ().buildType == AppBuildType.prod) { try { await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5)); diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index a8d1d5d0..1c3b98a8 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -1,6 +1,10 @@ import 'dart:developer' as developer; +import 'dart:async'; + import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -50,7 +54,25 @@ class PushNavigationHandler { } static void _handleMessageInternal(RemoteMessage message, {required String source}) { - final data = message.data; + final data = Map.from(message.data); + + final videoCallPayload = VideoCallPushPayload.tryParse(data); + if (videoCallPayload != null) { + developer.log( + 'PushNavigationHandler: opening incoming video call, source=$source, conversation=${videoCallPayload.conversationUuid}', + name: 'PushNavigationHandler', + ); + unawaited( + VideoCallNotificationNavigator.openIncomingCall( + conversationUuid: videoCallPayload.conversationUuid, + counterpartName: videoCallPayload.counterpartName, + counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl, + localParticipantName: videoCallPayload.localParticipantName, + ), + ); + return; + } + final actionUrl = data['action_url'] as String?; if (actionUrl == null || actionUrl.isEmpty) { developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler'); diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 961dcf07..87bafb3b 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -62,6 +62,9 @@ class VideoCallManager { bool _liveKitDisposed = true; bool _callEndedNotified = true; bool _callStageEntered = false; + bool _joinInProgress = false; + String? _preparedWsUrl; + String? _preparedToken; bool _videoEnabledBeforeBackground = false; bool _videoPausedForBackground = false; bool? _micEnabledBeforeSystemInterruption; @@ -171,18 +174,34 @@ class VideoCallManager { }) async { if (_room != null && !_liveKitDisposed && - _snapshot.conversationUuid == conversationUuid && - _room!.connectionState == lk.ConnectionState.connected) { - _emitSnapshot( - _snapshot.copyWith( - counterpartName: counterpartName ?? _snapshot.counterpartName, - localParticipantName: - localParticipantName ?? _snapshot.localParticipantName, - ), - ); - _ensureLifecycleAttached(); - unawaited(_startCallAudioSession()); - return; + _snapshot.conversationUuid == conversationUuid) { + if (_callStageEntered && + _room!.connectionState == lk.ConnectionState.connected) { + _emitSnapshot( + _snapshot.copyWith( + counterpartName: counterpartName ?? _snapshot.counterpartName, + localParticipantName: + localParticipantName ?? _snapshot.localParticipantName, + ), + ); + _ensureLifecycleAttached(); + unawaited(_startCallAudioSession()); + return; + } + if (!_callStageEntered && + _snapshot.connectPhase == VideoCallConnectPhase.connected && + _preparedWsUrl != null && + _preparedToken != null) { + _emitSnapshot( + _snapshot.copyWith( + counterpartName: counterpartName ?? _snapshot.counterpartName, + localParticipantName: + localParticipantName ?? _snapshot.localParticipantName, + ), + ); + _ensureLifecycleAttached(); + return; + } } await endCall(); @@ -217,7 +236,7 @@ class VideoCallManager { ), ); - await _connectLiveKit(); + await _prepareLiveKitConnection(); } Future onCallScreenVisible({ @@ -248,13 +267,33 @@ class VideoCallManager { } } - Future onEnterCallStage() async { - if (_callStageEntered) return; - if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; - _callStageEntered = true; - await _publishLocalMedia(); - await _startForegroundIfPossible(); - await _startCallAudioSession(); + Future onEnterCallStage() async { + if (_callStageEntered) return true; + if (_joinInProgress) return false; + if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return false; + + _joinInProgress = true; + try { + await _joinLiveKitRoom(); + if (_liveKitDisposed) return false; + + _callStageEntered = true; + await _publishLocalMedia(); + if (_liveKitDisposed || !_callStageEntered) return false; + await _startForegroundIfPossible(); + await _startCallAudioSession(); + return true; + } catch (e) { + _emitSnapshot( + _snapshot.copyWith( + connectError: e.toString(), + linkIssue: VideoCallLinkIssue.lost, + ), + ); + return false; + } finally { + _joinInProgress = false; + } } Future retryAfterConnectionLoss() async { @@ -282,7 +321,7 @@ class VideoCallManager { connectError: null, ), ); - await _connectLiveKit(); + await _prepareLiveKitConnection(); } Future endCall() async { @@ -306,6 +345,25 @@ class VideoCallManager { _emitSnapshot(VideoCallManagerSnapshot.initial); } + /// Decline from incoming UI before joining the LiveKit room. + Future declineIncomingCall(String conversationUuid) async { + final uuid = conversationUuid.trim(); + if (uuid.isEmpty) { + await endCall(); + return; + } + + if (_snapshot.conversationUuid == uuid) { + await endCall(); + return; + } + + await endCall(); + try { + await _videoCallRepository.notifyCallEnded(uuid); + } catch (_) {} + } + /// [currentUiEnabled] reflects what the user sees on the mic control /// ([VideoCallSessionState.isMicActiveForUi]), not only stored intent. Future toggleMic(bool currentUiEnabled) async { @@ -550,10 +608,17 @@ class VideoCallManager { _recordingOverlayScheduled = false; _micEnabledBeforeSystemInterruption = null; _audioInterruptionHoldCount = 0; + _joinInProgress = false; + _clearPreparedConnectionCredentials(); _syncSystemInterruptionOverlay(show: false); _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false)); } + void _clearPreparedConnectionCredentials() { + _preparedWsUrl = null; + _preparedToken = null; + } + void _disposeRoomEphemerals() { _mutedMicPollTimer?.cancel(); _mutedMicPollTimer = null; @@ -704,7 +769,7 @@ class VideoCallManager { } catch (_) {} } - Future _connectLiveKit() async { + Future _prepareLiveKitConnection() async { final room = _room; if (room == null) return; _emitSnapshot(_snapshot.copyWith(connectError: null)); @@ -714,15 +779,15 @@ class VideoCallManager { ); if (_liveKitDisposed) return; + _preparedWsUrl = tokenResponse.wsUrl; + _preparedToken = tokenResponse.token; + await room.prepareConnection( tokenResponse.wsUrl, tokenResponse.token, ); if (_liveKitDisposed) return; - await room.connect(tokenResponse.wsUrl, tokenResponse.token); - if (_liveKitDisposed) return; - _emitSnapshot( _snapshot.copyWith( connectPhase: VideoCallConnectPhase.connected, @@ -730,16 +795,8 @@ class VideoCallManager { clearConnectError: true, ), ); - _syncPoorConnectionOverlay( - room.localParticipant?.connectionQuality ?? - lk.ConnectionQuality.unknown, - ); - - if (_callStageEntered) { - await _publishLocalMedia(); - await _startCallAudioSession(); - } } catch (e) { + _clearPreparedConnectionCredentials(); _emitSnapshot( _snapshot.copyWith( connectError: e.toString(), @@ -750,6 +807,35 @@ class VideoCallManager { } } + Future _joinLiveKitRoom() async { + final room = _room; + if (room == null) { + throw StateError('VideoCallManager: room is null'); + } + if (room.connectionState == lk.ConnectionState.connected) return; + + var wsUrl = _preparedWsUrl; + var token = _preparedToken; + if (wsUrl == null || token == null) { + final tokenResponse = await _videoCallRepository.getParticipantToken( + _snapshot.conversationUuid, + ); + if (_liveKitDisposed) return; + wsUrl = tokenResponse.wsUrl; + token = tokenResponse.token; + _preparedWsUrl = wsUrl; + _preparedToken = token; + } + + await room.connect(wsUrl, token); + if (_liveKitDisposed) return; + + _syncPoorConnectionOverlay( + room.localParticipant?.connectionQuality ?? + lk.ConnectionQuality.unknown, + ); + } + Future _disconnectLiveKitOnly() async { if (_room == null) return; await _stopCallAudioSession(); diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 82657459..5eb112db 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -192,8 +192,11 @@ class VideoCallSessionCubit extends BaseCubit { if (isClosed) return; if (state.connectPhase != VideoCallConnectPhase.connected) return; if (state.callStageEntered) return; + + final entered = await _videoCallManager.onEnterCallStage(); + if (!entered || isClosed) return; + emit(state.copyWith(callStageEntered: true)); - await _videoCallManager.onEnterCallStage(); WidgetsBinding.instance.addPostFrameCallback((_) { if (isClosed) return; _videoCallManager.scheduleRecordingNoticeOnce(); diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart index f02c6c16..a7c38545 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart @@ -33,6 +33,5 @@ abstract class VideoCallSessionState with _$VideoCallSessionState { @Default(false) bool systemAudioInterrupted, }) = _VideoCallSessionState; - /// UI for mic control: off while GSM / system audio holds the session. bool get isMicActiveForUi => micEnabled && !systemAudioInterrupted; } 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 0ccd085d..47a310c0 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -5,10 +5,11 @@ 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_fcm_handler.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; +import 'package:flutter/widgets.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'; @@ -29,16 +30,33 @@ class VideoCallCallKitCoordinator { StreamSubscription? _eventSubscription; final Map _callKitParamsById = {}; + final Set _acceptHandledCallKitIds = {}; + final Set _suppressedEndCallKitIds = {}; 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(); + + if (Platform.isIOS) { + await _firebaseApi.registerPushToken(); + return; + } + + if (Platform.isAndroid) { + VideoCallFcmHandler.registerForegroundListener(); + FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle); + await FlutterCallkitIncoming.requestNotificationPermission({ + 'title': 'Уведомления', + 'rationaleMessagePermission': + 'Разрешите уведомления, чтобы получать входящие видеозвонки.', + 'postNotificationMessageRequired': + 'Разрешите уведомления в настройках, чтобы получать входящие видеозвонки.', + }); + } } void dispose() { @@ -67,11 +85,11 @@ class VideoCallCallKitCoordinator { case CallEventActionCallCallback(:final id): await _handleAccept(id); case CallEventActionCallDecline(:final id): - await _handleEnd(id); + await _handleDecline(id); case CallEventActionCallEnded(:final id): - await _handleEnd(id); + await _handleDecline(id); case CallEventActionCallTimeout(:final id): - await _handleEnd(id); + await _handleDecline(id); default: break; } @@ -84,7 +102,41 @@ class VideoCallCallKitCoordinator { CallKitParams? _paramsForCallId(String id) => _callKitParamsById[id]; + Future _onNativeAcceptCallHandle(Map body) async { + final extra = Map.from(body); + final conversationUuid = _parseConversationUuid(extra); + if (conversationUuid.isEmpty) { + log( + 'VideoCallCallKitCoordinator: native accept without conversationUuid, extra=$extra', + name: 'VideoCallCallKit', + ); + return; + } + + await _deliverIncomingAccept( + callKitId: conversationUuid, + conversationUuid: conversationUuid, + counterpartName: _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]), + counterpartAvatarUrl: _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]), + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + } + Future _handleAccept(String callKitId) async { + if (_acceptHandledCallKitIds.contains(callKitId)) return; + _activeCallKitId = callKitId; final params = _paramsForCallId(callKitId); final extra = params?.extra ?? {}; @@ -101,55 +153,102 @@ class VideoCallCallKitCoordinator { 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( + await _deliverIncomingAccept( + callKitId: callKitId, conversationUuid: conversationUuid, - counterpartName: counterpartName, - localParticipantName: localParticipantName, + 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', + ]), ); - - 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)); - } + Future _deliverIncomingAccept({ + required String callKitId, + required String conversationUuid, + String? counterpartName, + String? counterpartAvatarUrl, + String? localParticipantName, + }) async { + if (_acceptHandledCallKitIds.contains(callKitId)) return; + _acceptHandledCallKitIds.add(callKitId); + _suppressedEndCallKitIds.add(callKitId); - if (_manager.snapshot.connectPhase == VideoCallConnectPhase.connected) { - onSnapshot(_manager.snapshot); + try { + await FlutterCallkitIncoming.endCall(callKitId); + + final binding = WidgetsBinding.instance; + final completer = Completer(); + binding.addPostFrameCallback((_) { + unawaited(_openConnectingScreenAfterAccept( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + ).whenComplete(completer.complete)); + }); + await completer.future; + } finally { + unawaited( + Future.delayed(const Duration(seconds: 2), () { + _acceptHandledCallKitIds.remove(callKitId); + _suppressedEndCallKitIds.remove(callKitId); + }), + ); + } + } + + Future _openConnectingScreenAfterAccept({ + required String conversationUuid, + String? counterpartName, + String? counterpartAvatarUrl, + String? localParticipantName, + }) { + return VideoCallNotificationNavigator.openIncomingCall( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + ); + } + + Future _handleDecline(String callKitId) async { + if (_suppressedEndCallKitIds.contains(callKitId)) { + log( + 'VideoCallCallKitCoordinator: ignore spurious end/decline during accept, callKitId=$callKitId', + name: 'VideoCallCallKit', + ); return; } - _manager.addListener(onSnapshot); - } - Future _handleEnd(String callKitId) async { - await FlutterCallkitIncoming.endCall(callKitId); + final params = _paramsForCallId(callKitId); + final extra = params?.extra ?? {}; + final conversationUuid = _parseConversationUuid( + extra, + handle: params?.handle ?? callKitId, + ); + + _suppressedEndCallKitIds.add(callKitId); + try { + await FlutterCallkitIncoming.endCall(callKitId); + } finally { + unawaited( + Future.delayed(const Duration(seconds: 1), () { + _suppressedEndCallKitIds.remove(callKitId); + }), + ); + } _callKitParamsById.remove(callKitId); if (_activeCallKitId == callKitId) { _activeCallKitId = null; @@ -158,6 +257,12 @@ class VideoCallCallKitCoordinator { if (_manager.isActiveCall || _cubit.state.connectPhase != VideoCallConnectPhase.idle) { await _cubit.endCall(); + return; + } + + await _manager.declineIncomingCall(conversationUuid); + if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) { + await _cubit.onCallEndedExternally(); } } diff --git a/lib/features/video_calls/services/video_call_fcm_handler.dart b/lib/features/video_calls/services/video_call_fcm_handler.dart new file mode 100644 index 00000000..6f058c5b --- /dev/null +++ b/lib/features/video_calls/services/video_call_fcm_handler.dart @@ -0,0 +1,75 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; +import 'package:eatvio_app/firebase_options.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/widgets.dart'; + +/// Top-level FCM background handler (separate isolate). +@pragma('vm:entry-point') +Future videoCallFirebaseMessagingBackgroundHandler( + RemoteMessage message, +) async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + await VideoCallFcmHandler.handleRemoteMessage( + message, + source: 'background', + ); +} + +abstract final class VideoCallFcmHandler { + static bool _foregroundListenerRegistered = false; + + static void registerBackgroundHandler() { + FirebaseMessaging.onBackgroundMessage( + videoCallFirebaseMessagingBackgroundHandler, + ); + } + + static void registerForegroundListener() { + if (_foregroundListenerRegistered) return; + _foregroundListenerRegistered = true; + + FirebaseMessaging.onMessage.listen((message) { + handleRemoteMessage(message, source: 'foreground'); + }); + } + + static Future handleRemoteMessage( + RemoteMessage message, { + required String source, + }) async { + if (!Platform.isAndroid) return; + + final data = Map.from(message.data); + if (data.isEmpty) return; + + final payload = VideoCallPushPayload.tryParse(data); + if (payload == null) { + log( + 'VideoCallFcmHandler: skip non-video-call push, source=$source, data=$data', + name: 'VideoCallFcm', + ); + return; + } + + log( + 'VideoCallFcmHandler: show incoming call UI, source=$source, conversation=${payload.conversationUuid}', + name: 'VideoCallFcm', + ); + + try { + await VideoCallIncomingPushPresenter.showIncomingCall(payload); + } catch (e, st) { + log( + 'VideoCallFcmHandler: failed to show incoming call UI: $e', + name: 'VideoCallFcm', + stackTrace: st, + ); + } + } +} diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart new file mode 100644 index 00000000..c3325be9 --- /dev/null +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -0,0 +1,53 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; +import 'package:flutter_callkit_incoming/entities/entities.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; + +abstract final class VideoCallIncomingPushPresenter { + static const String _androidAccentColor = '#2787F5'; + + static Future showIncomingCall(VideoCallPushPayload payload) async { + final callerName = + payload.counterpartName?.trim().isNotEmpty == true + ? payload.counterpartName!.trim() + : 'Итвио'; + + final params = CallKitParams( + id: payload.conversationUuid, + nameCaller: callerName, + appName: 'Итвио', + avatar: payload.counterpartAvatarUrl, + handle: payload.conversationUuid, + type: 0, + duration: 45000, + extra: { + 'conversationUuid': payload.conversationUuid, + if (payload.counterpartName != null) + 'counterpartName': payload.counterpartName, + if (payload.counterpartAvatarUrl != null) + 'counterpartAvatarUrl': payload.counterpartAvatarUrl, + if (payload.localParticipantName != null) + 'localParticipantName': payload.localParticipantName, + }, + android: const AndroidParams( + isCustomNotification: true, + isShowLogo: false, + isShowFullLockedScreen: false, + isFullScreen: false, + ringtonePath: 'system_ringtone_default', + backgroundColor: _androidAccentColor, + actionColor: _androidAccentColor, + textColor: '#ffffff', + textAccept: 'Answer', + textDecline: 'Decline', + isShowCallID: false, + ), + ios: const IOSParams( + handleType: 'generic', + supportsVideo: true, + ringtonePath: 'system_ringtone_default', + ), + ); + + await FlutterCallkitIncoming.showCallkitIncoming(params); + } +} 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 c648bfe0..09d88086 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -39,13 +39,6 @@ class VideoCallNotificationNavigator { counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, ); - - final manager = sl(); - await manager.onCallScreenVisible( - conversationUuid: conversationUuid, - counterpartName: counterpartName, - localParticipantName: localParticipantName, - ); } static Future _pushCallScreen({ diff --git a/lib/features/video_calls/services/video_call_push_payload.dart b/lib/features/video_calls/services/video_call_push_payload.dart new file mode 100644 index 00000000..3b0decac --- /dev/null +++ b/lib/features/video_calls/services/video_call_push_payload.dart @@ -0,0 +1,145 @@ +class VideoCallPushPayload { + const VideoCallPushPayload({ + required this.conversationUuid, + this.counterpartName, + this.counterpartAvatarUrl, + this.localParticipantName, + }); + + final String conversationUuid; + final String? counterpartName; + final String? counterpartAvatarUrl; + final String? localParticipantName; + + static VideoCallPushPayload? tryParse(Map data) { + if (!isIncomingVideoCall(data)) return null; + + final conversationUuid = _conversationUuid(data); + if (conversationUuid == null || conversationUuid.isEmpty) return null; + + return VideoCallPushPayload( + conversationUuid: conversationUuid, + counterpartName: _string(data, const [ + 'counterpartName', + 'counterpart_name', + 'caller_name', + 'callerName', + 'nameCaller', + 'caller', + ]), + counterpartAvatarUrl: _resolveAvatarUrl( + _string(data, const [ + 'counterpartAvatarUrl', + 'counterpart_avatar_url', + 'avatar', + 'avatarUrl', + 'avatar_url', + 'caller_avatar', + ]), + ), + localParticipantName: _string(data, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + } + + static bool isIncomingVideoCall(Map data) { + final type = _string(data, const [ + 'type', + 'notification_type', + 'event', + 'action', + 'push_type', + 'call_type', + ]); + if (type != null) { + final normalized = type.toLowerCase().replaceAll('-', '_'); + if (normalized.contains('video_call')) { + if (normalized.contains('end') || normalized.contains('ended')) { + return false; + } + return true; + } + if (normalized == 'incoming_call' || + normalized == 'video_call_incoming' || + normalized == 'incoming_video_call') { + return true; + } + } + + if (_isTruthy(data['is_video_call']) || + _isTruthy(data['incoming_call']) || + _isTruthy(data['is_incoming_call'])) { + return _conversationUuid(data) != null; + } + + final actionUrl = _string(data, const ['action_url']); + if (actionUrl != null && + (actionUrl.contains('video-call') || actionUrl.contains('videoCall'))) { + return true; + } + + return false; + } + + static String? _conversationUuid(Map data) { + final fromData = _string(data, const [ + 'conversationUuid', + 'conversation_uuid', + 'conversation_id', + 'chat_uuid', + 'uuid', + ]); + if (fromData != null && fromData.isNotEmpty) return fromData; + + final actionUrl = _string(data, const ['action_url']); + if (actionUrl == null) return null; + + final uri = Uri.tryParse(actionUrl); + if (uri == null) return null; + + final fromQuery = uri.queryParameters['uuid'] ?? + uri.queryParameters['conversationUuid'] ?? + uri.queryParameters['conversation_uuid']; + if (fromQuery != null && fromQuery.trim().isNotEmpty) { + return fromQuery.trim(); + } + + final segments = uri.pathSegments; + for (var i = 0; i < segments.length - 1; i++) { + if (segments[i] == 'video-call' && segments[i + 1].isNotEmpty) { + return segments[i + 1]; + } + } + + return null; + } + + static String? _string(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; + } + + static bool _isTruthy(Object? value) { + if (value == null) return false; + if (value is bool) return value; + final normalized = value.toString().trim().toLowerCase(); + return normalized == '1' || + normalized == 'true' || + normalized == 'yes'; + } + + static String? _resolveAvatarUrl(String? raw) { + if (raw == null || raw.isEmpty) return null; + if (raw.startsWith('http://') || raw.startsWith('https://')) { + return raw; + } + return raw; + } +} diff --git a/lib/main.dart b/lib/main.dart index ee56a8e7..2f8373da 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,8 @@ Future main() async { FlutterForegroundTask.initCommunicationPort(); VideoCallForegroundBootstrap.ensureInitialized(); await AppInitializer.initialize(buildType: AppBuildType.prod); + await sl().initialize(); + await PushNavigationHandler.initialize(); await AppInitializer.runAppWithSentry(() => runApp(const MyApp())); } @@ -34,8 +36,6 @@ class _MyAppState extends State { @override void initState() { super.initState(); - PushNavigationHandler.initialize(); - unawaited(sl().initialize()); } @override diff --git a/test/features/video_calls/services/video_call_push_payload_test.dart b/test/features/video_calls/services/video_call_push_payload_test.dart new file mode 100644 index 00000000..9ddae36e --- /dev/null +++ b/test/features/video_calls/services/video_call_push_payload_test.dart @@ -0,0 +1,51 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('VideoCallPushPayload', () { + test('parses video_call type with conversation uuid', () { + final payload = VideoCallPushPayload.tryParse({ + 'type': 'video_call_incoming', + 'conversation_uuid': 'abc-123', + 'caller_name': 'Anna', + 'avatar': 'https://cdn.example/avatar.jpg', + }); + + expect(payload, isNotNull); + expect(payload!.conversationUuid, 'abc-123'); + expect(payload.counterpartName, 'Anna'); + expect(payload.counterpartAvatarUrl, 'https://cdn.example/avatar.jpg'); + }); + + test('parses action_url with video-call path', () { + final payload = VideoCallPushPayload.tryParse({ + 'action_url': 'https://eatvio.ru/video-call-screen?uuid=room-42', + 'counterpartName': 'Ivan', + }); + + expect(payload, isNotNull); + expect(payload!.conversationUuid, 'room-42'); + expect(payload.counterpartName, 'Ivan'); + }); + + test('ignores ended video call push', () { + expect( + VideoCallPushPayload.tryParse({ + 'type': 'video_call_ended', + 'conversation_uuid': 'abc-123', + }), + isNull, + ); + }); + + test('ignores regular chat push', () { + expect( + VideoCallPushPayload.tryParse({ + 'type': 'chat_message', + 'action_url': 'https://eatvio.ru/chat/123', + }), + isNull, + ); + }); + }); +}