feat(video_calls): fixed naviagtion from push notification to call and added wake lock

This commit is contained in:
misha
2026-07-06 11:18:05 +03:00
parent 9ce7b035ff
commit 219908684f
10 changed files with 752 additions and 99 deletions

View File

@@ -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<String, Any?>? {
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<String, Any?>? {
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
}
}

View File

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

View File

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

View File

@@ -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<String, Any?>? = null
private val mainHandler = Handler(Looper.getMainLooper())
fun store(payload: Map<String, Any?>) {
pendingPayload = payload
Log.i(TAG, "stored pending accept id=${payload["id"]}")
}
fun peek(): Map<String, Any?>? = pendingPayload
fun drain(): Map<String, Any?>? {
val payload = pendingPayload
pendingPayload = null
if (payload != null) {
Log.i(TAG, "drained pending accept id=${payload["id"]}")
}
return payload
}
fun peekPending(): Map<String, Any?>? = pendingPayload
fun scheduleDeliverToFlutter(
deliver: () -> Unit,
delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L),
) {
for (delayMs in delaysMs) {
mainHandler.postDelayed({ deliver() }, delayMs)
}
}
}

View File

@@ -1136,4 +1136,4 @@ class VideoCallManager {
await _audioSession.stop();
} catch (_) {}
}
}
}

View File

@@ -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(

View File

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

View File

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

View File

@@ -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 (_) {}
}
}

View File

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