feat(video_calls): added new incomng push impl, fixed mic and video sharing while connecting to call

This commit is contained in:
misha
2026-06-09 09:55:20 +03:00
parent 8e2b7147d5
commit 27fe4afd81
17 changed files with 784 additions and 95 deletions

View File

@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA" />
@@ -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 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
Replace it with a bridge that routes to the same accept flow as Answer. -->
<activity
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
tools:node="remove" />
<activity
android:name=".EatvioCallkitIncomingBridgeActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:excludeFromRecents="true"
android:noHistory="true"
android:turnScreenOn="true"
android:launchMode="singleTop"
android:configChanges="orientation" />
<activity-alias
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:turnScreenOn="true"
android:launchMode="singleTop"
android:configChanges="orientation"
android:permission="${applicationId}.PERMISSION_CALL">
<intent-filter>
<action android:name="${applicationId}.com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity-alias>
<activity
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
tools:node="remove" />
<activity
android:name=".EatvioCallkitTransparentBridgeActivity"
android:exported="false"
android:theme="@style/TranslucentTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:launchMode="singleTop" />
<activity-alias
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
android:targetActivity=".EatvioCallkitTransparentBridgeActivity"
android:exported="false"
android:theme="@style/TranslucentTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:launchMode="singleTop" />
<service
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:foregroundServiceType="microphone"

View File

@@ -0,0 +1,23 @@
package com.eatvioapp.app
import android.content.Context
import android.content.Intent
import android.os.Bundle
import com.hiennv.flutter_callkit_incoming.AppUtils
import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
object CallKitIntentBridge {
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)
broadcastIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
context.sendBroadcast(broadcastIntent)
if (MainActivity.isInForeground) {
return
}
val activityIntent = AppUtils.getAppIntent(context, action, data) ?: return
activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
context.startActivity(activityIntent)
}
}

View File

@@ -0,0 +1,29 @@
package com.eatvioapp.app
import android.app.Activity
import android.os.Bundle
import com.hiennv.flutter_callkit_incoming.CallkitConstants
/// Redirects notification body / full-screen intent taps to the same accept path as
/// the Answer button, instead of showing the native Callkit incoming UI.
class EatvioCallkitIncomingBridgeActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA)
if (data == null) {
finish()
return
}
CallKitIntentBridge.dispatchCallKitAction(
this,
CallkitConstants.ACTION_CALL_ACCEPT,
data,
)
finish()
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
}
}

View File

@@ -0,0 +1,25 @@
package com.eatvioapp.app
import android.app.Activity
import android.os.Bundle
/// Replaces plugin TransparentActivity: forwards CallKit actions without relaunching
/// MainActivity when the Flutter app is already in the foreground.
class EatvioCallkitTransparentBridgeActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val action = intent.action
if (action.isNullOrBlank()) {
finish()
return
}
val data = intent.getBundleExtra("data")
CallKitIntentBridge.dispatchCallKitAction(this, action, data)
finish()
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
}
}

View File

@@ -6,6 +6,7 @@ import android.content.pm.PackageManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.huawei.agconnect.config.AGConnectServicesConfig
import com.huawei.hms.aaid.HmsInstanceId
import kotlinx.coroutines.CoroutineScope
@@ -13,9 +14,25 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainActivity: FlutterActivity() {
companion object {
@JvmStatic
var isInForeground: Boolean = false
private set
}
private val channelName = "com.eatvioapp/hms_push"
private var methodChannel: MethodChannel? = null
override fun onResume() {
super.onResume()
isInForeground = true
}
override fun onPause() {
isInForeground = false
super.onPause()
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
@@ -86,6 +103,10 @@ class MainActivity: FlutterActivity() {
private fun handleIntentForActionUrl(intent: Intent?) {
if (intent == null) return
if (intent.hasExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)) {
return
}
// Вариант 1: action_url лежит в extras.
val fromExtras = intent.extras?.getString("action_url")

View File

@@ -6,6 +6,7 @@ import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
import 'package:eatvio_app/core/locator_service.dart' as di;
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
import 'package:eatvio_app/firebase_options.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
@@ -47,6 +48,7 @@ class AppInitializer {
throw TimeoutException('Firebase initialization timeout');
},
);
VideoCallFcmHandler.registerBackgroundHandler();
if (sl<AppConfig>().buildType == AppBuildType.prod) {
try {
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));

View File

@@ -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<String, dynamic>.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');

View File

@@ -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<void> onCallScreenVisible({
@@ -248,13 +267,33 @@ class VideoCallManager {
}
}
Future<void> onEnterCallStage() async {
if (_callStageEntered) return;
if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return;
_callStageEntered = true;
await _publishLocalMedia();
await _startForegroundIfPossible();
await _startCallAudioSession();
Future<bool> 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<void> retryAfterConnectionLoss() async {
@@ -282,7 +321,7 @@ class VideoCallManager {
connectError: null,
),
);
await _connectLiveKit();
await _prepareLiveKitConnection();
}
Future<void> endCall() async {
@@ -306,6 +345,25 @@ class VideoCallManager {
_emitSnapshot(VideoCallManagerSnapshot.initial);
}
/// Decline from incoming UI before joining the LiveKit room.
Future<void> 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<void> 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<void> _connectLiveKit() async {
Future<void> _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<void> _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<void> _disconnectLiveKitOnly() async {
if (_room == null) return;
await _stopCallAudioSession();

View File

@@ -192,8 +192,11 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
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();

View File

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

View File

@@ -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<CallEvent?>? _eventSubscription;
final Map<String, CallKitParams> _callKitParamsById = {};
final Set<String> _acceptHandledCallKitIds = {};
final Set<String> _suppressedEndCallKitIds = {};
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();
if (Platform.isIOS) {
await _firebaseApi.registerPushToken();
return;
}
if (Platform.isAndroid) {
VideoCallFcmHandler.registerForegroundListener();
FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle);
await FlutterCallkitIncoming.requestNotificationPermission(<String, dynamic>{
'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<void> _onNativeAcceptCallHandle(Map<dynamic, dynamic> body) async {
final extra = Map<String, dynamic>.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<void> _handleAccept(String callKitId) async {
if (_acceptHandledCallKitIds.contains(callKitId)) return;
_activeCallKitId = callKitId;
final params = _paramsForCallId(callKitId);
final extra = params?.extra ?? <String, dynamic>{};
@@ -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<void> _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<void>();
binding.addPostFrameCallback((_) {
unawaited(_openConnectingScreenAfterAccept(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
counterpartAvatarUrl: counterpartAvatarUrl,
localParticipantName: localParticipantName,
).whenComplete(completer.complete));
});
await completer.future;
} finally {
unawaited(
Future<void>.delayed(const Duration(seconds: 2), () {
_acceptHandledCallKitIds.remove(callKitId);
_suppressedEndCallKitIds.remove(callKitId);
}),
);
}
}
Future<void> _openConnectingScreenAfterAccept({
required String conversationUuid,
String? counterpartName,
String? counterpartAvatarUrl,
String? localParticipantName,
}) {
return VideoCallNotificationNavigator.openIncomingCall(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
counterpartAvatarUrl: counterpartAvatarUrl,
localParticipantName: localParticipantName,
);
}
Future<void> _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<void> _handleEnd(String callKitId) async {
await FlutterCallkitIncoming.endCall(callKitId);
final params = _paramsForCallId(callKitId);
final extra = params?.extra ?? <String, dynamic>{};
final conversationUuid = _parseConversationUuid(
extra,
handle: params?.handle ?? callKitId,
);
_suppressedEndCallKitIds.add(callKitId);
try {
await FlutterCallkitIncoming.endCall(callKitId);
} finally {
unawaited(
Future<void>.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();
}
}

View File

@@ -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<void> 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<void> handleRemoteMessage(
RemoteMessage message, {
required String source,
}) async {
if (!Platform.isAndroid) return;
final data = Map<String, dynamic>.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,
);
}
}
}

View File

@@ -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<void> 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: <String, dynamic>{
'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);
}
}

View File

@@ -39,13 +39,6 @@ class VideoCallNotificationNavigator {
counterpartAvatarUrl: counterpartAvatarUrl,
localParticipantName: localParticipantName,
);
final manager = sl<VideoCallManager>();
await manager.onCallScreenVisible(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
);
}
static Future<void> _pushCallScreen({

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<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;
}
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;
}
}

View File

@@ -20,6 +20,8 @@ Future<void> main() async {
FlutterForegroundTask.initCommunicationPort();
VideoCallForegroundBootstrap.ensureInitialized();
await AppInitializer.initialize(buildType: AppBuildType.prod);
await sl<VideoCallCallKitCoordinator>().initialize();
await PushNavigationHandler.initialize();
await AppInitializer.runAppWithSentry(() => runApp(const MyApp()));
}
@@ -34,8 +36,6 @@ class _MyAppState extends State<MyApp> {
@override
void initState() {
super.initState();
PushNavigationHandler.initialize();
unawaited(sl<VideoCallCallKitCoordinator>().initialize());
}
@override

View File

@@ -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,
);
});
});
}