forked from eatvio/eatvio_app
feat(video_calls): added new incomng push impl, fixed mic and video sharing while connecting to call
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
145
lib/features/video_calls/services/video_call_push_payload.dart
Normal file
145
lib/features/video_calls/services/video_call_push_payload.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user