forked from eatvio/eatvio_app
feat(video_calls): fixed naviagtion from push notification to call and added wake lock
This commit is contained in:
@@ -1136,4 +1136,4 @@ class VideoCallManager {
|
||||
await _audioSession.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:eatvio_app/core/cubit/base_cubit.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_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -14,9 +15,12 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
VideoCallSessionCubit({
|
||||
required VideoCallManager videoCallManager,
|
||||
VideoCallSystemPipCoordinator? systemPipCoordinator,
|
||||
VideoCallScreenWakeLockCoordinator? screenWakeLockCoordinator,
|
||||
}) : _videoCallManager = videoCallManager,
|
||||
_systemPipCoordinator =
|
||||
systemPipCoordinator ?? VideoCallSystemPipCoordinator(),
|
||||
_screenWakeLock =
|
||||
screenWakeLockCoordinator ?? VideoCallScreenWakeLockCoordinator(),
|
||||
super(const VideoCallSessionState()) {
|
||||
_videoCallManager.addListener(_onManagerSnapshot);
|
||||
_syncFromManager(_videoCallManager.snapshot);
|
||||
@@ -24,6 +28,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
|
||||
final VideoCallManager _videoCallManager;
|
||||
final VideoCallSystemPipCoordinator _systemPipCoordinator;
|
||||
final VideoCallScreenWakeLockCoordinator _screenWakeLock;
|
||||
bool _callScreenRouteVisible = false;
|
||||
bool _autoEnterInFlight = false;
|
||||
bool _counterpartPresentWhenLobbyReady = false;
|
||||
@@ -168,6 +173,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing,
|
||||
}) async {
|
||||
_callScreenRouteVisible = true;
|
||||
await _screenWakeLock.acquire();
|
||||
|
||||
final effectiveEntryMode =
|
||||
state.entryMode == VideoCallEntryMode.incomingAccepted ||
|
||||
@@ -507,6 +513,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
}
|
||||
|
||||
Future<void> endCall() async {
|
||||
await _screenWakeLock.release();
|
||||
await _systemPipCoordinator.disarm();
|
||||
await _videoCallManager.endCall();
|
||||
}
|
||||
@@ -567,6 +574,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
}
|
||||
|
||||
Future<void> _restoreSystemUiAfterCall() async {
|
||||
await _screenWakeLock.release();
|
||||
try {
|
||||
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[]);
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
|
||||
@@ -32,7 +32,8 @@ class VideoCallCallKitCoordinator {
|
||||
|
||||
StreamSubscription<CallEvent?>? _eventSubscription;
|
||||
final Map<String, CallKitParams> _callKitParamsById = {};
|
||||
final Set<String> _acceptHandledCallKitIds = {};
|
||||
final Set<String> _acceptNavigationCompletedIds = {};
|
||||
final Map<String, Future<void>> _acceptDeliveriesInFlight = {};
|
||||
final Set<String> _suppressedEndCallKitIds = {};
|
||||
String? _activeCallKitId;
|
||||
bool _initialized = false;
|
||||
@@ -41,12 +42,29 @@ class VideoCallCallKitCoordinator {
|
||||
static const MethodChannel _voipDiagnosticsChannel = MethodChannel(
|
||||
'com.eatvioapp/voip_diagnostics',
|
||||
);
|
||||
static const MethodChannel _androidCallPushChannel = MethodChannel(
|
||||
'com.eatvioapp/video_call_push',
|
||||
);
|
||||
|
||||
_PendingIncomingAccept? _pendingIncomingAccept;
|
||||
_AndroidAcceptResumeObserver? _androidResumeObserver;
|
||||
bool _pendingAcceptDrainScheduled = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
|
||||
_eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent);
|
||||
_eventSubscription = FlutterCallkitIncoming.onEvent.listen(
|
||||
_onCallKitEvent,
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: CallKit event stream error: $error',
|
||||
name: 'VideoCallCallKit',
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
VideoCallFcmHandler.registerForegroundListener();
|
||||
|
||||
@@ -70,6 +88,11 @@ class VideoCallCallKitCoordinator {
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle);
|
||||
_androidResumeObserver = _AndroidAcceptResumeObserver(
|
||||
onResumed: _onAndroidAppResumed,
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(_androidResumeObserver!);
|
||||
_androidCallPushChannel.setMethodCallHandler(_onAndroidCallPushMethodCall);
|
||||
await FlutterCallkitIncoming.requestNotificationPermission(<
|
||||
String,
|
||||
dynamic
|
||||
@@ -80,13 +103,142 @@ class VideoCallCallKitCoordinator {
|
||||
'postNotificationMessageRequired':
|
||||
'Разрешите уведомления в настройках, чтобы получать входящие видеозвонки.',
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_pollNativePendingAcceptAfterResume());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAndroidCallPushMethodCall(MethodCall call) async {
|
||||
if (call.method == 'onCallKitLaunchIntent') {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native',
|
||||
name: 'VideoCallCallKit',
|
||||
);
|
||||
await _handleAndroidCallKitLaunchIntent(call.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
void _onAndroidAppResumed() {
|
||||
unawaited(_pollNativePendingAcceptAfterResume());
|
||||
}
|
||||
|
||||
Future<void> _pollNativePendingAcceptAfterResume() async {
|
||||
for (var attempt = 0; attempt < 16; attempt++) {
|
||||
await _processNativePendingAndroidCallAccept();
|
||||
if (_pendingIncomingAccept == null) {
|
||||
if (VideoCallNotificationNavigator.isOnCallRoute()) {
|
||||
return;
|
||||
}
|
||||
final hasNativePending = await _hasNativePendingAndroidCallAccept();
|
||||
if (!hasNativePending && attempt >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
}
|
||||
unawaited(_drainPendingIncomingAcceptWithRetries());
|
||||
}
|
||||
|
||||
Future<bool> _hasNativePendingAndroidCallAccept() async {
|
||||
try {
|
||||
final pending = await _androidCallPushChannel.invokeMethod<Object?>(
|
||||
'peekPendingCallKitAccept',
|
||||
);
|
||||
return pending != null;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processNativePendingAndroidCallAccept() async {
|
||||
try {
|
||||
final pending = await _androidCallPushChannel.invokeMethod<Object?>(
|
||||
'peekPendingCallKitAccept',
|
||||
);
|
||||
if (pending == null) return;
|
||||
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: processing native pending accept',
|
||||
name: 'VideoCallCallKit',
|
||||
);
|
||||
await _handleAndroidCallKitLaunchIntent(pending);
|
||||
} on PlatformException catch (e, stackTrace) {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: failed to peek native pending accept: $e',
|
||||
name: 'VideoCallCallKit',
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: failed to process native pending accept: $e',
|
||||
name: 'VideoCallCallKit',
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleAndroidCallKitLaunchIntent(Object? arguments) async {
|
||||
final data = _stringKeyedMap(arguments);
|
||||
final action = data['action']?.toString() ?? '';
|
||||
if (!action.contains('ACTION_CALL_ACCEPT') &&
|
||||
!action.contains('ACTION_CALL_CALLBACK')) {
|
||||
return;
|
||||
}
|
||||
|
||||
final extra = _stringKeyedMap(data['extra']);
|
||||
final callKitId =
|
||||
_parseString(data, const ['id', 'uuid']) ??
|
||||
_parseString(extra, const ['conversationUuid', 'conversation_uuid']);
|
||||
if (callKitId == null) return;
|
||||
|
||||
final conversationUuid = _parseConversationUuid(
|
||||
extra,
|
||||
handle: _parseString(data, const ['handle']) ?? callKitId,
|
||||
);
|
||||
if (conversationUuid.isEmpty) return;
|
||||
|
||||
await _deliverIncomingAccept(
|
||||
callKitId: callKitId,
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName:
|
||||
_parseString(extra, const [
|
||||
'counterpartName',
|
||||
'caller_name',
|
||||
'callerName',
|
||||
]) ??
|
||||
_parseString(data, const ['nameCaller']),
|
||||
counterpartAvatarUrl:
|
||||
_parseString(extra, const [
|
||||
'counterpartAvatarUrl',
|
||||
'avatar',
|
||||
'avatarUrl',
|
||||
'caller_avatar',
|
||||
]) ??
|
||||
_parseString(data, const ['avatar']),
|
||||
localParticipantName: _parseString(extra, const [
|
||||
'localParticipantName',
|
||||
'local_participant_name',
|
||||
]),
|
||||
counterpartIsConsultant: _parseBool(extra, const [
|
||||
'counterpartIsConsultant',
|
||||
'counterpart_is_consultant',
|
||||
'is_consultant',
|
||||
'isConsultant',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
final observer = _androidResumeObserver;
|
||||
if (observer != null) {
|
||||
WidgetsBinding.instance.removeObserver(observer);
|
||||
_androidResumeObserver = null;
|
||||
}
|
||||
unawaited(_eventSubscription?.cancel());
|
||||
_eventSubscription = null;
|
||||
_callKitParamsById.clear();
|
||||
_pendingIncomingAccept = null;
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
@@ -166,25 +318,34 @@ class VideoCallCallKitCoordinator {
|
||||
|
||||
Future<void> _onCallKitEvent(CallEvent? event) async {
|
||||
if (event == null) return;
|
||||
unawaited(_captureCallKitEventToSentry(event));
|
||||
|
||||
switch (event) {
|
||||
case CallEventActionDidUpdateDevicePushTokenVoip():
|
||||
await _firebaseApi.registerPushToken();
|
||||
case CallEventActionCallIncoming(:final callKitParams):
|
||||
_cacheCallKitParams(callKitParams);
|
||||
case CallEventActionCallAccept():
|
||||
await _handleAcceptEvent(event);
|
||||
case CallEventActionCallCallback():
|
||||
await _handleAcceptEvent(event);
|
||||
case CallEventActionCallDecline():
|
||||
await _handleDeclineEvent(event);
|
||||
case CallEventActionCallEnded():
|
||||
await _handleEndedEvent(event);
|
||||
case CallEventActionCallTimeout():
|
||||
await _handleDeclineEvent(event);
|
||||
default:
|
||||
break;
|
||||
try {
|
||||
unawaited(_captureCallKitEventToSentry(event));
|
||||
|
||||
switch (event) {
|
||||
case CallEventActionDidUpdateDevicePushTokenVoip():
|
||||
await _firebaseApi.registerPushToken();
|
||||
case CallEventActionCallIncoming(:final callKitParams):
|
||||
_cacheCallKitParams(callKitParams);
|
||||
case CallEventActionCallAccept():
|
||||
await _handleAcceptEvent(event);
|
||||
case CallEventActionCallCallback():
|
||||
await _handleAcceptEvent(event);
|
||||
case CallEventActionCallDecline():
|
||||
await _handleDeclineEvent(event);
|
||||
case CallEventActionCallEnded():
|
||||
await _handleEndedEvent(event);
|
||||
case CallEventActionCallTimeout():
|
||||
await _handleDeclineEvent(event);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: failed to handle CallKit event $event: $error',
|
||||
name: 'VideoCallCallKit',
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +475,7 @@ class VideoCallCallKitCoordinator {
|
||||
|
||||
Future<CallKitParams?> _paramsForCallIdOrActiveCall(String id) async {
|
||||
final cached = _paramsForCallId(id);
|
||||
if (cached != null || !Platform.isIOS) return cached;
|
||||
if (cached != null) return cached;
|
||||
|
||||
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final params in activeCalls) {
|
||||
@@ -420,7 +581,7 @@ class VideoCallCallKitCoordinator {
|
||||
}
|
||||
|
||||
Future<void> _handleAccept(String callKitId) async {
|
||||
if (_acceptHandledCallKitIds.contains(callKitId)) return;
|
||||
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
|
||||
|
||||
_activeCallKitId = callKitId;
|
||||
final params = await _paramsForCallIdOrActiveCall(callKitId);
|
||||
@@ -469,6 +630,24 @@ class VideoCallCallKitCoordinator {
|
||||
);
|
||||
}
|
||||
|
||||
void _rememberPendingIncomingAccept({
|
||||
required String callKitId,
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? counterpartAvatarUrl,
|
||||
String? localParticipantName,
|
||||
bool? counterpartIsConsultant,
|
||||
}) {
|
||||
_pendingIncomingAccept = _PendingIncomingAccept(
|
||||
callKitId: callKitId,
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deliverIncomingAccept({
|
||||
required String callKitId,
|
||||
required String conversationUuid,
|
||||
@@ -477,57 +656,202 @@ class VideoCallCallKitCoordinator {
|
||||
String? localParticipantName,
|
||||
bool? counterpartIsConsultant,
|
||||
}) async {
|
||||
if (_acceptHandledCallKitIds.contains(callKitId)) return;
|
||||
_acceptHandledCallKitIds.add(callKitId);
|
||||
_suppressedEndCallKitIds.add(callKitId);
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: deliverIncomingAccept callKitId=$callKitId',
|
||||
name: 'VideoCallCallKit',
|
||||
);
|
||||
|
||||
unawaited(_manager.notifyIncomingCallAccepted(conversationUuid));
|
||||
_rememberPendingIncomingAccept(
|
||||
callKitId: callKitId,
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
);
|
||||
|
||||
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
|
||||
|
||||
final inFlight = _acceptDeliveriesInFlight[callKitId];
|
||||
if (inFlight != null) {
|
||||
await inFlight;
|
||||
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
|
||||
}
|
||||
|
||||
final delivery = _runIncomingAcceptDelivery(
|
||||
callKitId: callKitId,
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
);
|
||||
_acceptDeliveriesInFlight[callKitId] = delivery;
|
||||
try {
|
||||
if (!Platform.isIOS) {
|
||||
await FlutterCallkitIncoming.endCall(callKitId);
|
||||
}
|
||||
|
||||
final binding = WidgetsBinding.instance;
|
||||
final completer = Completer<void>();
|
||||
binding.addPostFrameCallback((_) {
|
||||
unawaited(
|
||||
_openConnectingScreenAfterAccept(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
).whenComplete(completer.complete),
|
||||
);
|
||||
});
|
||||
await completer.future;
|
||||
|
||||
if (Platform.isIOS) {
|
||||
await FlutterCallkitIncoming.endCall(callKitId);
|
||||
}
|
||||
await delivery;
|
||||
} finally {
|
||||
unawaited(
|
||||
Future<void>.delayed(const Duration(seconds: 2), () {
|
||||
_acceptHandledCallKitIds.remove(callKitId);
|
||||
_suppressedEndCallKitIds.remove(callKitId);
|
||||
}),
|
||||
);
|
||||
if (identical(_acceptDeliveriesInFlight[callKitId], delivery)) {
|
||||
_acceptDeliveriesInFlight.remove(callKitId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openConnectingScreenAfterAccept({
|
||||
Future<void> _runIncomingAcceptDelivery({
|
||||
required String callKitId,
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? counterpartAvatarUrl,
|
||||
String? localParticipantName,
|
||||
bool? counterpartIsConsultant,
|
||||
}) async {
|
||||
if (Platform.isIOS) {
|
||||
FlutterNativeSplash.remove();
|
||||
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
|
||||
|
||||
_suppressedEndCallKitIds.add(callKitId);
|
||||
unawaited(_manager.notifyIncomingCallAccepted(conversationUuid));
|
||||
|
||||
try {
|
||||
await _waitForAcceptNavigationReady();
|
||||
|
||||
final navigated = await _openConnectingScreenAfterAccept(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
);
|
||||
|
||||
if (navigated) {
|
||||
_acceptNavigationCompletedIds.add(callKitId);
|
||||
_pendingIncomingAccept = null;
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
await _androidCallPushChannel.invokeMethod<Object?>(
|
||||
'drainPendingCallKitAccept',
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
} else {
|
||||
_pendingIncomingAccept = _PendingIncomingAccept(
|
||||
callKitId: callKitId,
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
);
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: accept navigation deferred until UI is ready, callKitId=$callKitId',
|
||||
name: 'VideoCallCallKit',
|
||||
);
|
||||
unawaited(_drainPendingIncomingAcceptWithRetries());
|
||||
}
|
||||
|
||||
await FlutterCallkitIncoming.endCall(callKitId);
|
||||
} catch (error, stackTrace) {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: accept delivery failed, callKitId=$callKitId: $error',
|
||||
name: 'VideoCallCallKit',
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
} finally {
|
||||
unawaited(
|
||||
Future<void>.delayed(const Duration(seconds: 2), () {
|
||||
_suppressedEndCallKitIds.remove(callKitId);
|
||||
_acceptNavigationCompletedIds.remove(callKitId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _drainPendingIncomingAcceptWithRetries() async {
|
||||
if (_pendingIncomingAccept == null) return;
|
||||
if (_pendingAcceptDrainScheduled) return;
|
||||
|
||||
_pendingAcceptDrainScheduled = true;
|
||||
try {
|
||||
const retryDelaysMs = <int>[0, 250, 600, 1200, 2000, 3200];
|
||||
for (final delayMs in retryDelaysMs) {
|
||||
if (_pendingIncomingAccept == null) return;
|
||||
if (delayMs > 0) {
|
||||
await Future<void>.delayed(Duration(milliseconds: delayMs));
|
||||
}
|
||||
await _drainPendingIncomingAccept();
|
||||
if (_pendingIncomingAccept == null) return;
|
||||
}
|
||||
} finally {
|
||||
_pendingAcceptDrainScheduled = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _drainPendingIncomingAccept() async {
|
||||
final pending = _pendingIncomingAccept;
|
||||
if (pending == null) return;
|
||||
if (_acceptNavigationCompletedIds.contains(pending.callKitId)) {
|
||||
_pendingIncomingAccept = null;
|
||||
return;
|
||||
}
|
||||
|
||||
await VideoCallNotificationNavigator.openIncomingCall(
|
||||
await _waitForAcceptNavigationReady();
|
||||
await _settleUiAfterResume();
|
||||
|
||||
final navigated = await _openConnectingScreenAfterAccept(
|
||||
conversationUuid: pending.conversationUuid,
|
||||
counterpartName: pending.counterpartName,
|
||||
counterpartAvatarUrl: pending.counterpartAvatarUrl,
|
||||
localParticipantName: pending.localParticipantName,
|
||||
counterpartIsConsultant: pending.counterpartIsConsultant,
|
||||
);
|
||||
|
||||
if (!navigated) return;
|
||||
|
||||
_acceptNavigationCompletedIds.add(pending.callKitId);
|
||||
_pendingIncomingAccept = null;
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
await _androidCallPushChannel.invokeMethod<Object?>(
|
||||
'drainPendingCallKitAccept',
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
await FlutterCallkitIncoming.endCall(pending.callKitId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _settleUiAfterResume() async {
|
||||
final binding = WidgetsBinding.instance;
|
||||
for (var frame = 0; frame < 3; frame++) {
|
||||
await binding.endOfFrame;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||
}
|
||||
|
||||
Future<void> _waitForAcceptNavigationReady({
|
||||
Duration timeout = const Duration(seconds: 10),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
final lifecycle = WidgetsBinding.instance.lifecycleState;
|
||||
if (lifecycle == AppLifecycleState.resumed) {
|
||||
for (var frame = 0; frame < 2; frame++) {
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _openConnectingScreenAfterAccept({
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? counterpartAvatarUrl,
|
||||
String? localParticipantName,
|
||||
bool? counterpartIsConsultant,
|
||||
}) async {
|
||||
FlutterNativeSplash.remove();
|
||||
|
||||
return VideoCallNotificationNavigator.openIncomingCall(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
@@ -663,3 +987,34 @@ class VideoCallCallKitCoordinator {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingIncomingAccept {
|
||||
const _PendingIncomingAccept({
|
||||
required this.callKitId,
|
||||
required this.conversationUuid,
|
||||
this.counterpartName,
|
||||
this.counterpartAvatarUrl,
|
||||
this.localParticipantName,
|
||||
this.counterpartIsConsultant,
|
||||
});
|
||||
|
||||
final String callKitId;
|
||||
final String conversationUuid;
|
||||
final String? counterpartName;
|
||||
final String? counterpartAvatarUrl;
|
||||
final String? localParticipantName;
|
||||
final bool? counterpartIsConsultant;
|
||||
}
|
||||
|
||||
class _AndroidAcceptResumeObserver with WidgetsBindingObserver {
|
||||
_AndroidAcceptResumeObserver({required this.onResumed});
|
||||
|
||||
final VoidCallback onResumed;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
onResumed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.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/presentation/pages/video_call_screen.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
|
||||
class VideoCallNotificationNavigator {
|
||||
static bool _pushInFlight = false;
|
||||
|
||||
static Future<void> openActiveCall({String? conversationUuid}) async {
|
||||
final manager = sl<VideoCallManager>();
|
||||
final uuid = conversationUuid ?? manager.conversationUuid;
|
||||
@@ -27,7 +32,8 @@ class VideoCallNotificationNavigator {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> openIncomingCall({
|
||||
/// Returns `true` when the call UI is visible or navigation succeeded.
|
||||
static Future<bool> openIncomingCall({
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? counterpartAvatarUrl,
|
||||
@@ -35,26 +41,103 @@ class VideoCallNotificationNavigator {
|
||||
bool? counterpartIsConsultant,
|
||||
VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing,
|
||||
}) async {
|
||||
if (conversationUuid.isEmpty) return;
|
||||
if (conversationUuid.isEmpty) return false;
|
||||
|
||||
final pushed = await _pushCallScreen(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
entryMode: entryMode,
|
||||
);
|
||||
FlutterNativeSplash.remove();
|
||||
final readySince = DateTime.now();
|
||||
await _waitUntilNavigationReady(since: readySince);
|
||||
|
||||
if (!pushed) {
|
||||
await sl<VideoCallSessionCubit>().onCallScreenVisible(
|
||||
for (var attempt = 0; attempt < 8; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await Future<void>.delayed(Duration(milliseconds: 200 * attempt));
|
||||
await _waitUntilNavigationReady(since: readySince);
|
||||
}
|
||||
|
||||
if (isOnCallRoute()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final pushed = await _pushCallScreen(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
entryMode: entryMode,
|
||||
);
|
||||
|
||||
if (pushed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return isOnCallRoute();
|
||||
}
|
||||
|
||||
static bool isOnCallRoute() {
|
||||
final matches =
|
||||
AppRouter.appRouter.routerDelegate.currentConfiguration.matches;
|
||||
if (matches.isEmpty) return false;
|
||||
return matches.last.matchedLocation.contains(VideoCallScreen.path);
|
||||
}
|
||||
|
||||
static Future<void> _waitUntilNavigationReady({
|
||||
required DateTime since,
|
||||
Duration timeout = const Duration(seconds: 12),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
final lifecycle = WidgetsBinding.instance.lifecycleState;
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
final routerReady = _isRouterReadyForCallNavigation(since: since);
|
||||
|
||||
if (lifecycle == AppLifecycleState.resumed &&
|
||||
context != null &&
|
||||
context.mounted &&
|
||||
routerReady) {
|
||||
for (var frame = 0; frame < 2; frame++) {
|
||||
await SchedulerBinding.instance.endOfFrame;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
|
||||
static bool _isRouterReadyForCallNavigation({required DateTime since}) {
|
||||
final matches =
|
||||
AppRouter.appRouter.routerDelegate.currentConfiguration.matches;
|
||||
if (matches.isEmpty) return false;
|
||||
|
||||
final topLocation = matches.last.matchedLocation;
|
||||
if (topLocation == RouteConstants.updateScreen) {
|
||||
// Cold start: wait for version-check redirect unless it is taking too long.
|
||||
return DateTime.now().difference(since) >
|
||||
const Duration(seconds: 6);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static Future<void> _waitForRouteToSettle() async {
|
||||
for (var frame = 0; frame < 4; frame++) {
|
||||
await SchedulerBinding.instance.endOfFrame;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||
}
|
||||
|
||||
static Future<bool> _waitForCallRouteVisible({
|
||||
Duration timeout = const Duration(seconds: 3),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (isOnCallRoute()) {
|
||||
await _waitForRouteToSettle();
|
||||
return isOnCallRoute();
|
||||
}
|
||||
await SchedulerBinding.instance.endOfFrame;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
}
|
||||
return isOnCallRoute();
|
||||
}
|
||||
|
||||
static Future<bool> _pushCallScreen({
|
||||
@@ -65,32 +148,49 @@ class VideoCallNotificationNavigator {
|
||||
bool? counterpartIsConsultant,
|
||||
VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing,
|
||||
}) async {
|
||||
for (var attempt = 0; attempt < 100; attempt++) {
|
||||
final ctx = AppRouter.navigatorKey.currentContext;
|
||||
if (ctx != null && ctx.mounted) {
|
||||
final router = GoRouter.of(ctx);
|
||||
final matches = router.routerDelegate.currentConfiguration.matches;
|
||||
final onCallRoute = matches.any(
|
||||
(m) => m.matchedLocation.contains(VideoCallScreen.path),
|
||||
);
|
||||
if (!onCallRoute) {
|
||||
await router.pushNamed(
|
||||
if (isOnCallRoute()) {
|
||||
return true;
|
||||
}
|
||||
if (_pushInFlight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null || !context.mounted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final extra = <String, dynamic>{
|
||||
'conversationUuid': conversationUuid,
|
||||
'counterpartName': counterpartName,
|
||||
'counterpartAvatarUrl': counterpartAvatarUrl,
|
||||
'localParticipantName': localParticipantName,
|
||||
'counterpartIsConsultant': ?counterpartIsConsultant,
|
||||
'entryMode': entryMode.name,
|
||||
};
|
||||
|
||||
_pushInFlight = true;
|
||||
try {
|
||||
try {
|
||||
// pushNamed completes when the route is popped, not when it opens.
|
||||
unawaited(
|
||||
AppRouter.appRouter.pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': conversationUuid,
|
||||
'counterpartName': counterpartName,
|
||||
'counterpartAvatarUrl': counterpartAvatarUrl,
|
||||
'localParticipantName': localParticipantName,
|
||||
'counterpartIsConsultant': ?counterpartIsConsultant,
|
||||
'entryMode': entryMode.name,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
extra: extra,
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
log(
|
||||
'VideoCallNotificationNavigator: pushNamed failed: $error',
|
||||
name: 'VideoCallCallKit',
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
return _waitForCallRouteVisible();
|
||||
} finally {
|
||||
_pushInFlight = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
|
||||
class VideoCallScreenWakeLockCoordinator {
|
||||
bool _held = false;
|
||||
|
||||
bool get isHeld => _held;
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_held) return;
|
||||
|
||||
try {
|
||||
await WakelockPlus.enable();
|
||||
_held = true;
|
||||
} catch (_) {
|
||||
_held = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> release() async {
|
||||
if (!_held) return;
|
||||
|
||||
_held = false;
|
||||
try {
|
||||
await WakelockPlus.disable();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user