feat(video_calls): android repeated call after hanging up fix and ios repeated calls after call initiation fix

This commit is contained in:
misha
2026-10-16 15:40:41 +03:00
parent 6d2935400a
commit 86e9d0cc31
14 changed files with 309 additions and 30 deletions

View File

@@ -30,6 +30,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:file_picker/file_picker.dart';
@@ -1009,6 +1010,7 @@ class _ChatScreenState extends State<ChatScreen> {
ChatRoomStateLoaded state,
ChatUserModel chatCounterpart,
) {
sl<VideoCallManager>().permitParticipantTokenRequest();
GoRouter.of(context).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{

View File

@@ -77,6 +77,8 @@ class VideoCallManager {
bool _liveKitDisposed = true;
bool _callEndedNotified = true;
bool _forceEndRequested = false;
bool _sessionMayRequestToken = false;
final Map<String, DateTime> _leftConversationAt = {};
final Set<String> _callAcceptNotifiedConversationUuids = {};
bool _callStageEntered = false;
bool _joinInProgress = false;
@@ -178,13 +180,29 @@ class VideoCallManager {
);
}
Future<void> startCall({
/// Lets the next [startCall] ask the server for a token.
///
/// Only the call button and an accepted incoming call set this. A screen
/// that reappears after hang-up must not request a token: that request is
/// what rings the other person again.
void permitParticipantTokenRequest() {
_sessionMayRequestToken = true;
}
Future<bool> startCall({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
bool micEnabled = false,
bool videoEnabled = false,
}) async {
final allowed = mayRequestParticipantToken(
sessionPermitted: _sessionMayRequestToken,
leftAt: _leftConversationAt[conversationUuid],
now: DateTime.now(),
);
if (!allowed) return false;
if (_room != null &&
!_liveKitDisposed &&
_snapshot.conversationUuid == conversationUuid) {
@@ -199,7 +217,7 @@ class VideoCallManager {
);
_ensureLifecycleAttached();
unawaited(_startCallAudioSession());
return;
return true;
}
if (!_callStageEntered &&
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
@@ -213,11 +231,12 @@ class VideoCallManager {
),
);
_ensureLifecycleAttached();
return;
return true;
}
}
await endCall();
_sessionMayRequestToken = true;
_resetSessionFlagsForNewRoom();
_callEndedNotified = false;
@@ -255,9 +274,10 @@ class VideoCallManager {
);
await _prepareLiveKitConnection();
return true;
}
Future<void> onCallScreenVisible({
Future<bool> onCallScreenVisible({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
@@ -267,7 +287,7 @@ class VideoCallManager {
if (_snapshot.conversationUuid != conversationUuid ||
_room == null ||
_liveKitDisposed) {
await startCall(
return startCall(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
@@ -286,6 +306,7 @@ class VideoCallManager {
unawaited(_ensureLocalParticipantNamePublished());
_syncRemoteParticipantNameFromRoom();
}
return true;
}
}
@@ -413,7 +434,9 @@ class VideoCallManager {
);
if (uuid.isNotEmpty) {
_callEndedNotified = true;
_leftConversationAt[uuid] = DateTime.now();
}
_sessionMayRequestToken = false;
_disposeRoomEphemerals();
await _disconnectLiveKitOnly();
if (shouldNotifyBackend) {
@@ -466,6 +489,12 @@ class VideoCallManager {
}
await endCall();
if (!incomingDeclineShouldNotifyBackend(
leftAt: _leftConversationAt[uuid],
now: DateTime.now(),
)) {
return;
}
try {
await _videoCallRepository.notifyCallEnded(uuid);
} catch (_) {}
@@ -977,6 +1006,7 @@ class VideoCallManager {
Future<void> _prepareLiveKitConnection() async {
final room = _room;
if (room == null) return;
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
_emitSnapshot(_snapshot.copyWith(connectError: null));
try {
final tokenResponse = await _videoCallRepository.getParticipantToken(
@@ -1025,6 +1055,7 @@ class VideoCallManager {
var wsUrl = _preparedWsUrl;
var token = _preparedToken;
if (wsUrl == null || token == null) {
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
final tokenResponse = await _videoCallRepository.getParticipantToken(
_snapshot.conversationUuid,
);
@@ -1103,6 +1134,14 @@ class VideoCallManager {
return false;
}
bool _tokenRequestAllowed(String conversationUuid) {
return mayRequestParticipantToken(
sessionPermitted: _sessionMayRequestToken,
leftAt: _leftConversationAt[conversationUuid],
now: DateTime.now(),
);
}
bool _roomHasRemoteUser() {
final room = _room;
if (room == null) return false;

View File

@@ -171,7 +171,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
);
}
Future<void> onCallScreenVisible({
Future<bool> onCallScreenVisible({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
@@ -225,7 +225,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
return;
return true;
}
if (isResumingActiveCall) {
@@ -255,13 +255,17 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
entryMode: effectiveEntryMode,
),
);
await _videoCallManager.startCall(
final started = await _videoCallManager.startCall(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: false,
videoEnabled: false,
);
if (!started) {
if (!isClosed) emit(const VideoCallSessionState());
return false;
}
emit(state.copyWith(entryMode: effectiveEntryMode));
if (isIncomingAccepted) {
await _tryAutoEnterCallStage();
@@ -273,6 +277,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
return true;
}
Future<void> _ensureSystemPipArmedIfInCall() async {

View File

@@ -80,18 +80,22 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_initCounterpartMetadata());
unawaited(
_cubit.onCallScreenVisible(
conversationUuid: widget.conversationUuid,
counterpartName: widget.counterpartName,
localParticipantName: widget.localParticipantName,
counterpartIsConsultant: widget.counterpartIsConsultant,
entryMode: widget.entryMode,
),
);
unawaited(_openCallSession());
});
}
Future<void> _openCallSession() async {
final started = await _cubit.onCallScreenVisible(
conversationUuid: widget.conversationUuid,
counterpartName: widget.counterpartName,
localParticipantName: widget.localParticipantName,
counterpartIsConsultant: widget.counterpartIsConsultant,
entryMode: widget.entryMode,
);
if (!mounted || started) return;
if (context.canPop()) context.pop();
}
Future<void> _initCounterpartMetadata() async {
var isOnline = widget.counterpartIsOnline ?? false;
var lastSeenText = widget.counterpartLastSeenText ?? '';

View File

@@ -8,6 +8,7 @@ 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/services/video_call_fcm_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -842,6 +843,7 @@ class VideoCallCallKitCoordinator {
bool? counterpartIsConsultant,
}) async {
FlutterNativeSplash.remove();
_manager.permitParticipantTokenRequest();
return VideoCallNotificationNavigator.openIncomingCall(
conversationUuid: conversationUuid,
@@ -868,6 +870,7 @@ class VideoCallCallKitCoordinator {
extra,
handle: params?.handle ?? callKitId,
);
final endedPushDismissal = callKitExtraMarksEndedPush(extra);
_suppressedEndCallKitIds.add(callKitId);
try {
@@ -884,6 +887,8 @@ class VideoCallCallKitCoordinator {
_activeCallKitId = null;
}
if (endedPushDismissal) return;
if (_manager.isActiveCall ||
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
await _cubit.endCall();

View File

@@ -9,6 +9,8 @@ import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
typedef VideoCallIncomingClaim = Future<bool> Function(String callId);
typedef VideoCallIncomingShow = Future<void> Function(CallKitParams params);
typedef VideoCallIncomingDelay = Future<void> Function(Duration delay);
typedef VideoCallAndroidIncomingShow =
Future<void> Function(VideoCallPushPayload payload);
/// Shows the system incoming-call UI.
///
@@ -21,6 +23,9 @@ abstract final class VideoCallIncomingPushPresenter {
static const MethodChannel _voipChannel = MethodChannel(
'com.eatvioapp/voip_diagnostics',
);
static const MethodChannel _androidPushChannel = MethodChannel(
'com.eatvioapp/video_call_push',
);
/// Absolute delays from the start of an iOS present attempt.
static const List<Duration> iosClaimPollDelays = <Duration>[
@@ -38,11 +43,16 @@ abstract final class VideoCallIncomingPushPresenter {
VideoCallIncomingShow? showCallkitIncoming,
VideoCallIncomingDelay? delay,
List<Duration>? claimPollDelays,
VideoCallAndroidIncomingShow? showAndroidIncoming,
}) async {
final callerName = payload.counterpartName?.trim().isNotEmpty == true
? payload.counterpartName!.trim()
: 'Итвио';
final ios = isIos ?? Platform.isIOS;
if (!ios && (showAndroidIncoming != null || Platform.isAndroid)) {
await (showAndroidIncoming ?? _showAndroidIncoming)(payload);
return;
}
final callId = ios
? VideoCallKitId.iosUuid(
conversationUuid: payload.conversationUuid,
@@ -106,7 +116,6 @@ abstract final class VideoCallIncomingPushPresenter {
configureAudioSession: false,
audioSessionActive: false,
audioSessionMode: 'videoChat',
ringtonePath: 'system_ringtone_default',
),
);
@@ -115,6 +124,27 @@ abstract final class VideoCallIncomingPushPresenter {
);
}
static Future<void> _showAndroidIncoming(VideoCallPushPayload payload) async {
try {
await _androidPushChannel
.invokeMethod<void>('showIncomingCall', <String, String>{
'conversation_uuid': payload.conversationUuid,
if (payload.counterpartName != null)
'caller_name': payload.counterpartName!,
if (payload.counterpartAvatarUrl != null)
'caller_avatar': payload.counterpartAvatarUrl!,
if (payload.localParticipantName != null)
'local_participant_name': payload.localParticipantName!,
if (payload.counterpartIsConsultant != null)
'is_consultant': payload.counterpartIsConsultant!
? 'true'
: 'false',
if (payload.messageId != null)
'message_id': payload.messageId.toString(),
});
} catch (_) {}
}
static Future<bool> _claimOrFalse(
VideoCallIncomingClaim claim,
String callId,

View File

@@ -30,6 +30,47 @@ bool shouldNotifyCallEnded({
return true;
}
/// An ended VoIP push has to be reported to CallKit, then closed. That close
/// arrives as a decline. Reporting it to the backend sends another ended VoIP,
/// which the other phone shows as a new incoming call.
const Duration incomingDeclineNotifySuppression = Duration(seconds: 60);
bool incomingDeclineShouldNotifyBackend({
required DateTime? leftAt,
required DateTime now,
Duration suppressFor = incomingDeclineNotifySuppression,
}) {
if (leftAt == null) return true;
return now.difference(leftAt) >= suppressFor;
}
/// A token request after hang-up makes the server send a new incoming call.
/// Automatic retries stay blocked until the user starts or accepts a call.
bool mayRequestParticipantToken({
required bool sessionPermitted,
required DateTime? leftAt,
required DateTime now,
Duration suppressFor = incomingDeclineNotifySuppression,
}) {
if (sessionPermitted) return true;
return incomingDeclineShouldNotifyBackend(
leftAt: leftAt,
now: now,
suppressFor: suppressFor,
);
}
bool callKitExtraMarksEndedPush(Map<String, dynamic>? extra) {
final value = extra?['endedPush'];
if (value is bool) return value;
if (value is num) return value != 0;
if (value is String) {
final normalized = value.trim().toLowerCase();
return normalized == 'true' || normalized == '1';
}
return false;
}
bool shouldForceEndCallAfterRoomDisconnect({
required bool callStageEntered,
required bool sessionConnected,