diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt new file mode 100644 index 00000000..3b6d3732 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt @@ -0,0 +1,53 @@ +package com.eatvioapp.app + +import android.content.Intent +import android.os.Bundle +import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin + +object CallKitAcceptIntentMapper { + fun payloadFromBundle(data: Bundle, action: String): Map? { + if (!action.contains(CallkitConstants.ACTION_CALL_ACCEPT) && + !action.contains(CallkitConstants.ACTION_CALL_CALLBACK) + ) { + return null + } + + return hashMapOf( + "action" to action, + "id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""), + "handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""), + "nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""), + "avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""), + "extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA), + ) + } + + fun payloadFromIntent(intent: Intent?): Map? { + if (intent == null) return null + + val data = intent.getBundleExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA) + ?: return null + val action = intent.action ?: return null + if (!action.contains(CallkitConstants.ACTION_CALL_ACCEPT) && + !action.contains(CallkitConstants.ACTION_CALL_CALLBACK) + ) { + return null + } + + return hashMapOf( + "action" to action, + "id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""), + "handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""), + "nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""), + "avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""), + "extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA), + ) + } + + fun clearCallKitLaunchIntent(intent: Intent?) { + if (intent == null) return + intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA) + intent.action = null + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt index 2b412781..e433b4fc 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt @@ -8,16 +8,31 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver object CallKitIntentBridge { fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) { + if (data != null) { + CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload -> + PendingCallKitAcceptBridge.store(payload) + } + } + val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data) broadcastIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND) context.sendBroadcast(broadcastIntent) - if (MainActivity.isInForeground) { + val shouldLaunchMainActivity = + action == com.hiennv.flutter_callkit_incoming.CallkitConstants.ACTION_CALL_ACCEPT || + action == com.hiennv.flutter_callkit_incoming.CallkitConstants.ACTION_CALL_CALLBACK || + !MainActivity.isInForeground + + if (!shouldLaunchMainActivity) { return } val activityIntent = AppUtils.getAppIntent(context, action, data) ?: return - activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + activityIntent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_REORDER_TO_FRONT, + ) context.startActivity(activityIntent) } } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index c6b2fb43..1ae3354f 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.os.Build +import android.os.Bundle import android.util.Rational import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -34,6 +35,10 @@ class MainActivity: FlutterActivity() { override fun onResume() { super.onResume() isInForeground = true + captureCallKitLaunchIntent(intent) + if (PendingCallKitAcceptBridge.peek() != null) { + scheduleDeliverPendingCallKitAccept() + } } override fun onPause() { @@ -57,6 +62,12 @@ class MainActivity: FlutterActivity() { result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null) } } + "drainPendingCallKitAccept" -> { + result.success(PendingCallKitAcceptBridge.drain()) + } + "peekPendingCallKitAccept" -> { + result.success(PendingCallKitAcceptBridge.peekPending()) + } else -> result.notImplemented() } } @@ -124,6 +135,7 @@ class MainActivity: FlutterActivity() { // Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления). handleIntentForActionUrl(intent) + captureCallKitLaunchIntent(intent) } override fun onUserLeaveHint() { @@ -134,10 +146,49 @@ class MainActivity: FlutterActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) + captureCallKitLaunchIntent(intent) // Обработка нового интента, когда Activity уже запущена (тап по уведомлению). handleIntentForActionUrl(intent) } + private fun captureCallKitLaunchIntent(intent: Intent?) { + val payload = CallKitAcceptIntentMapper.payloadFromIntent(intent) ?: return + + android.util.Log.i( + "VideoCallPush", + "captureCallKitLaunchIntent: action=${payload["action"]} id=${payload["id"]}", + ) + + PendingCallKitAcceptBridge.store(payload) + CallKitAcceptIntentMapper.clearCallKitLaunchIntent(intent) + scheduleDeliverPendingCallKitAccept() + } + + private fun scheduleDeliverPendingCallKitAccept() { + PendingCallKitAcceptBridge.scheduleDeliverToFlutter( + deliver = { deliverPendingCallKitAcceptToFlutter() }, + ) + } + + private fun deliverPendingCallKitAcceptToFlutter() { + val payload = PendingCallKitAcceptBridge.peek() ?: return + val channel = videoCallPushChannel + if (channel == null) { + android.util.Log.w( + "VideoCallPush", + "deliverPendingCallKitAcceptToFlutter: channel not ready", + ) + return + } + + android.util.Log.i( + "VideoCallPush", + "deliverPendingCallKitAcceptToFlutter: id=${payload["id"]}", + ) + channel.invokeMethod("onCallKitLaunchIntent", payload) + } + private fun handleIntentForActionUrl(intent: Intent?) { if (intent == null) return diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitAcceptBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitAcceptBridge.kt new file mode 100644 index 00000000..8be98e66 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitAcceptBridge.kt @@ -0,0 +1,42 @@ +package com.eatvioapp.app + +import android.os.Handler +import android.os.Looper +import android.util.Log + +/// Stores accepted call payloads until the Flutter engine is ready to drain them. +object PendingCallKitAcceptBridge { + private const val TAG = "PendingCallKitAccept" + + @Volatile + private var pendingPayload: Map? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + + fun store(payload: Map) { + pendingPayload = payload + Log.i(TAG, "stored pending accept id=${payload["id"]}") + } + + fun peek(): Map? = pendingPayload + + fun drain(): Map? { + val payload = pendingPayload + pendingPayload = null + if (payload != null) { + Log.i(TAG, "drained pending accept id=${payload["id"]}") + } + return payload + } + + fun peekPending(): Map? = pendingPayload + + fun scheduleDeliverToFlutter( + deliver: () -> Unit, + delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L), + ) { + for (delayMs in delaysMs) { + mainHandler.postDelayed({ deliver() }, delayMs) + } + } +} diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 2501a772..12832204 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1136,4 +1136,4 @@ class VideoCallManager { await _audioSession.stop(); } catch (_) {} } -} +} \ No newline at end of file diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 220ec4fd..c35b9b34 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -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 { 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 { 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 { VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { _callScreenRouteVisible = true; + await _screenWakeLock.acquire(); final effectiveEntryMode = state.entryMode == VideoCallEntryMode.incomingAccepted || @@ -507,6 +513,7 @@ class VideoCallSessionCubit extends BaseCubit { } Future endCall() async { + await _screenWakeLock.release(); await _systemPipCoordinator.disarm(); await _videoCallManager.endCall(); } @@ -567,6 +574,7 @@ class VideoCallSessionCubit extends BaseCubit { } Future _restoreSystemUiAfterCall() async { + await _screenWakeLock.release(); try { await SystemChrome.setPreferredOrientations(const []); await SystemChrome.setEnabledSystemUIMode( diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 57c718fa..8facc589 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -32,7 +32,8 @@ class VideoCallCallKitCoordinator { StreamSubscription? _eventSubscription; final Map _callKitParamsById = {}; - final Set _acceptHandledCallKitIds = {}; + final Set _acceptNavigationCompletedIds = {}; + final Map> _acceptDeliveriesInFlight = {}; final Set _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 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 _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 _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.delayed(const Duration(milliseconds: 300)); + } + unawaited(_drainPendingIncomingAcceptWithRetries()); + } + + Future _hasNativePendingAndroidCallAccept() async { + try { + final pending = await _androidCallPushChannel.invokeMethod( + 'peekPendingCallKitAccept', + ); + return pending != null; + } catch (_) { + return false; + } + } + + Future _processNativePendingAndroidCallAccept() async { + try { + final pending = await _androidCallPushChannel.invokeMethod( + '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 _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 _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 _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 _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 _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(); - 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.delayed(const Duration(seconds: 2), () { - _acceptHandledCallKitIds.remove(callKitId); - _suppressedEndCallKitIds.remove(callKitId); - }), - ); + if (identical(_acceptDeliveriesInFlight[callKitId], delivery)) { + _acceptDeliveriesInFlight.remove(callKitId); + } } } - Future _openConnectingScreenAfterAccept({ + Future _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( + '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.delayed(const Duration(seconds: 2), () { + _suppressedEndCallKitIds.remove(callKitId); + _acceptNavigationCompletedIds.remove(callKitId); + }), + ); + } + } + + Future _drainPendingIncomingAcceptWithRetries() async { + if (_pendingIncomingAccept == null) return; + if (_pendingAcceptDrainScheduled) return; + + _pendingAcceptDrainScheduled = true; + try { + const retryDelaysMs = [0, 250, 600, 1200, 2000, 3200]; + for (final delayMs in retryDelaysMs) { + if (_pendingIncomingAccept == null) return; + if (delayMs > 0) { + await Future.delayed(Duration(milliseconds: delayMs)); + } + await _drainPendingIncomingAccept(); + if (_pendingIncomingAccept == null) return; + } + } finally { + _pendingAcceptDrainScheduled = false; + } + } + + Future _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( + 'drainPendingCallKitAccept', + ); + } catch (_) {} + } + try { + await FlutterCallkitIncoming.endCall(pending.callKitId); + } catch (_) {} + } + + Future _settleUiAfterResume() async { + final binding = WidgetsBinding.instance; + for (var frame = 0; frame < 3; frame++) { + await binding.endOfFrame; + } + await Future.delayed(const Duration(milliseconds: 80)); + } + + Future _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.delayed(const Duration(milliseconds: 50)); + } + } + + Future _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(); + } + } +} diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 5ed4bc16..8462baf0 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -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 openActiveCall({String? conversationUuid}) async { final manager = sl(); final uuid = conversationUuid ?? manager.conversationUuid; @@ -27,7 +32,8 @@ class VideoCallNotificationNavigator { ); } - static Future openIncomingCall({ + /// Returns `true` when the call UI is visible or navigation succeeded. + static Future 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().onCallScreenVisible( + for (var attempt = 0; attempt < 8; attempt++) { + if (attempt > 0) { + await Future.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 _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.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 _waitForRouteToSettle() async { + for (var frame = 0; frame < 4; frame++) { + await SchedulerBinding.instance.endOfFrame; + } + await Future.delayed(const Duration(milliseconds: 80)); + } + + static Future _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.delayed(const Duration(milliseconds: 30)); + } + return isOnCallRoute(); } static Future _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 = { + '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: { - '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.delayed(const Duration(milliseconds: 100)); + + return _waitForCallRouteVisible(); + } finally { + _pushInFlight = false; } - return false; } } diff --git a/lib/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart b/lib/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart new file mode 100644 index 00000000..522177de --- /dev/null +++ b/lib/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart @@ -0,0 +1,28 @@ +import 'package:wakelock_plus/wakelock_plus.dart'; + + +class VideoCallScreenWakeLockCoordinator { + bool _held = false; + + bool get isHeld => _held; + + Future acquire() async { + if (_held) return; + + try { + await WakelockPlus.enable(); + _held = true; + } catch (_) { + _held = false; + } + } + + Future release() async { + if (!_held) return; + + _held = false; + try { + await WakelockPlus.disable(); + } catch (_) {} + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 94fc5131..81aa249c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.33+122 +version: 1.3.34+123 publish_to: 'none' environment: @@ -89,6 +89,7 @@ dependencies: flutter_foreground_task: ^9.2.1 flutter_callkit_incoming: ^3.1.1 floating: ^6.0.0 + wakelock_plus: ^1.5.2 dev_dependencies: build_runner: ^2.10.4