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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user