From 86e9d0cc3153ad3f9a67ad065ffdfc685cef0b07 Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 16 Oct 2026 15:40:41 +0300 Subject: [PATCH] feat(video_calls): android repeated call after hanging up fix and ios repeated calls after call initiation fix --- .../kotlin/com/eatvioapp/app/MainActivity.kt | 15 +++++ .../com/eatvioapp/app/VideoCallPushBridge.kt | 17 +++-- ios/Runner/AppDelegate.swift | 4 +- .../chat/presentation/pages/chat_screen.dart | 2 + .../managers/video_call_manager.dart | 49 ++++++++++++-- .../video_call_session_cubit.dart | 11 +++- .../presentation/pages/video_call_screen.dart | 22 ++++--- .../video_call_callkit_coordinator.dart | 5 ++ .../video_call_incoming_push_presenter.dart | 32 ++++++++- .../services/video_call_peer_departure.dart | 41 ++++++++++++ .../video_call_manager_lifecycle_test.dart | 42 ++++++++++++ .../video_call_session_cubit_test.dart | 13 ++-- ...deo_call_incoming_push_presenter_test.dart | 21 +++++- .../video_call_peer_departure_test.dart | 65 +++++++++++++++++++ 14 files changed, 309 insertions(+), 30 deletions(-) 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 cb16be41..a30cd589 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -65,6 +65,21 @@ class MainActivity: FlutterActivity() { result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null) } } + "showIncomingCall" -> { + try { + val args = call.arguments as? Map<*, *> ?: emptyMap() + val data = HashMap() + for ((key, value) in args) { + val name = key as? String ?: continue + if (value == null) continue + data[name] = value.toString() + } + VideoCallPushBridge.showIncomingCall(applicationContext, data) + result.success(null) + } catch (e: Exception) { + result.error("SHOW_INCOMING_CALL_FAILED", e.message, null) + } + } "drainPendingCallKitAccept" -> { result.success(PendingCallKitAcceptBridge.drain()) } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index 563c245a..f4aa5333 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -121,17 +121,26 @@ object VideoCallPushBridge { return } - val generation = playbackGeneration.incrementAndGet() - Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}") + val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID) + val seenGeneration = playbackGeneration.get() + Log.i(TAG, "showIncomingCall: conversation=$callId") // Ringtone.play() and stop() must run on the same thread. FCM delivers // pushes on a background thread, while Answer runs on the main thread. + // A second show for the same conversation must not bump the generation: + // that would cancel the timeout of the call that is already ringing. mainHandler.post { synchronized(playbackLock) { - if (generation != playbackGeneration.get()) { - Log.i(TAG, "showIncomingCall: skipped stale playback generation=$generation") + if (seenGeneration != playbackGeneration.get()) { + Log.i(TAG, "showIncomingCall: skipped after silence") return@post } + val alreadyRinging = activeCallBundle?.getString(CallkitConstants.EXTRA_CALLKIT_ID) + if (!callId.isNullOrBlank() && callId == alreadyRinging) { + Log.i(TAG, "showIncomingCall: already ringing $callId") + return@post + } + val generation = playbackGeneration.incrementAndGet() startIncomingCallLocked(appContext, callData, generation) } } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 4608f102..72fbc95b 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -701,7 +701,9 @@ import flutter_foreground_task data.configureAudioSession = false data.audioSessionActive = false data.audioSessionMode = "videoChat" - data.ringtonePath = "system_ringtone_default" + // Do not set ringtonePath. The plugin stores that string as the ringtone + // file name. "system_ringtone_default" is not in the app, so CallKit ends + // the call at once and PushKit delivers the same push again. // Remember the id so the in-app socket path does not report it again. // The push itself must still be reported, or iOS kills the process. _ = reserveIncomingCall(id) diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 0a58510a..99822267 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -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 { ChatRoomStateLoaded state, ChatUserModel chatCounterpart, ) { + sl().permitParticipantTokenRequest(); GoRouter.of(context).pushNamed( VideoCallScreen.name, extra: { diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index d053eceb..c7bdd5a5 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -77,6 +77,8 @@ class VideoCallManager { bool _liveKitDisposed = true; bool _callEndedNotified = true; bool _forceEndRequested = false; + bool _sessionMayRequestToken = false; + final Map _leftConversationAt = {}; final Set _callAcceptNotifiedConversationUuids = {}; bool _callStageEntered = false; bool _joinInProgress = false; @@ -178,13 +180,29 @@ class VideoCallManager { ); } - Future 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 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 onCallScreenVisible({ + Future 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 _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; 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 d9bfa8d9..8c575bee 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 @@ -171,7 +171,7 @@ class VideoCallSessionCubit extends BaseCubit { ); } - Future onCallScreenVisible({ + Future onCallScreenVisible({ required String conversationUuid, String? counterpartName, String? localParticipantName, @@ -225,7 +225,7 @@ class VideoCallSessionCubit extends BaseCubit { WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_applyVideoCallSystemUi()); }); - return; + return true; } if (isResumingActiveCall) { @@ -255,13 +255,17 @@ class VideoCallSessionCubit extends BaseCubit { 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 { WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_applyVideoCallSystemUi()); }); + return true; } Future _ensureSystemPipArmedIfInCall() async { diff --git a/lib/features/video_calls/presentation/pages/video_call_screen.dart b/lib/features/video_calls/presentation/pages/video_call_screen.dart index 76b3934d..933863ab 100644 --- a/lib/features/video_calls/presentation/pages/video_call_screen.dart +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -80,18 +80,22 @@ class _VideoCallScreenState extends State { 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 _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 _initCounterpartMetadata() async { var isOnline = widget.counterpartIsOnline ?? false; var lastSeenText = widget.counterpartLastSeenText ?? ''; 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 a21f8060..d936f139 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -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(); diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart index de029cc8..f8ae7cef 100644 --- a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -9,6 +9,8 @@ import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; typedef VideoCallIncomingClaim = Future Function(String callId); typedef VideoCallIncomingShow = Future Function(CallKitParams params); typedef VideoCallIncomingDelay = Future Function(Duration delay); +typedef VideoCallAndroidIncomingShow = + Future 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 iosClaimPollDelays = [ @@ -38,11 +43,16 @@ abstract final class VideoCallIncomingPushPresenter { VideoCallIncomingShow? showCallkitIncoming, VideoCallIncomingDelay? delay, List? 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 _showAndroidIncoming(VideoCallPushPayload payload) async { + try { + await _androidPushChannel + .invokeMethod('showIncomingCall', { + '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 _claimOrFalse( VideoCallIncomingClaim claim, String callId, diff --git a/lib/features/video_calls/services/video_call_peer_departure.dart b/lib/features/video_calls/services/video_call_peer_departure.dart index f6d28433..d0709ef5 100644 --- a/lib/features/video_calls/services/video_call_peer_departure.dart +++ b/lib/features/video_calls/services/video_call_peer_departure.dart @@ -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? 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, diff --git a/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart b/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart index df5ea33b..f7a271d6 100644 --- a/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart +++ b/test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart @@ -1,4 +1,5 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; +import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart'; import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; @@ -82,6 +83,47 @@ void main() { expect(foreground.startCount, 0); }); + test( + 'decline right after hang up does not notify the backend again', + () async { + manager.markActiveCallForTesting(mockRoom); + + await manager.endCall(); + clearInteractions(mockRepository); + + await manager.declineIncomingCall('test-uuid'); + + verifyNever(mockRepository.notifyCallEnded(any)); + }, + ); + + test( + 'startCall right after hang up does not request a new token', + () async { + manager.markActiveCallForTesting(mockRoom); + await manager.endCall(); + clearInteractions(mockRepository); + + final started = await manager.startCall(conversationUuid: 'test-uuid'); + + expect(started, isFalse); + verifyNever(mockRepository.getParticipantToken(any)); + }, + ); + + test( + 'decline of a call that was never joined notifies the backend', + () async { + when( + mockRepository.notifyCallEnded(any), + ).thenAnswer((_) async => const VideoCallEndedResponse(sent: true)); + + await manager.declineIncomingCall('other-uuid'); + + verify(mockRepository.notifyCallEnded('other-uuid')).called(1); + }, + ); + test('endCall stops foreground service', () async { manager.markActiveCallForTesting(mockRoom); diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index 05f074b7..6c39bcd7 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -199,7 +199,7 @@ void main() { micEnabled: anyNamed('micEnabled'), videoEnabled: anyNamed('videoEnabled'), ), - ).thenAnswer((_) async {}); + ).thenAnswer((_) async => true); await cubit.onCallScreenVisible( conversationUuid: 'uuid', @@ -231,6 +231,7 @@ void main() { linkIssue: VideoCallLinkIssue.none, ), ); + return true; }); when(mockManager.onEnterCallStage()).thenAnswer((_) async => true); @@ -262,7 +263,7 @@ void main() { micEnabled: anyNamed('micEnabled'), videoEnabled: anyNamed('videoEnabled'), ), - ).thenAnswer((_) async {}); + ).thenAnswer((_) async => true); when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {}); when(mockManager.onEnterCallStage()).thenAnswer((_) async => true); @@ -297,7 +298,7 @@ void main() { micEnabled: anyNamed('micEnabled'), videoEnabled: anyNamed('videoEnabled'), ), - ).thenAnswer((_) async {}); + ).thenAnswer((_) async => true); when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {}); when(mockManager.onEnterCallStage()).thenAnswer((_) async => true); @@ -328,7 +329,7 @@ void main() { micEnabled: anyNamed('micEnabled'), videoEnabled: anyNamed('videoEnabled'), ), - ).thenAnswer((_) async {}); + ).thenAnswer((_) async => true); await cubit.onCallScreenVisible( conversationUuid: 'uuid', @@ -362,7 +363,7 @@ void main() { micEnabled: anyNamed('micEnabled'), videoEnabled: anyNamed('videoEnabled'), ), - ).thenAnswer((_) async {}); + ).thenAnswer((_) async => true); cubit.emit( const VideoCallSessionState( @@ -390,7 +391,7 @@ void main() { micEnabled: anyNamed('micEnabled'), videoEnabled: anyNamed('videoEnabled'), ), - ).thenAnswer((_) async {}); + ).thenAnswer((_) async => true); await cubit.onCallScreenVisible(conversationUuid: 'uuid'); expect(cubit.isCallScreenRouteVisible, isTrue); diff --git a/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart b/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart index 29cc686a..64850877 100644 --- a/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart +++ b/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart @@ -74,7 +74,7 @@ void main() { expect(shown.single.ios?.configureAudioSession, isFalse); expect(shown.single.ios?.audioSessionActive, isFalse); expect(shown.single.ios?.audioSessionMode, 'videoChat'); - expect(shown.single.ios?.ringtonePath, 'system_ringtone_default'); + expect(shown.single.ios?.ringtonePath, isNull); }); test('iOS stops polling when the push claims the call', () async { @@ -122,6 +122,25 @@ void main() { expect(shown, isEmpty); }); + test('Android does not open a second CallKit UI', () async { + final shown = []; + final native = []; + + await VideoCallIncomingPushPresenter.showIncomingCall( + payload, + isIos: false, + showAndroidIncoming: (incoming) async { + native.add(incoming); + }, + showCallkitIncoming: (params) async { + shown.add(params); + }, + ); + + expect(shown, isEmpty); + expect(native, [payload]); + }); + test( 'Android shows the incoming call without waiting for a VoIP claim', () async { diff --git a/test/features/video_calls/services/video_call_peer_departure_test.dart b/test/features/video_calls/services/video_call_peer_departure_test.dart index a8ec1136..a8d3c4af 100644 --- a/test/features/video_calls/services/video_call_peer_departure_test.dart +++ b/test/features/video_calls/services/video_call_peer_departure_test.dart @@ -96,6 +96,71 @@ void main() { ); }); + test('a decline just after leaving does not report the end again', () { + final leftAt = DateTime(2026, 10, 16, 12); + expect( + incomingDeclineShouldNotifyBackend( + leftAt: leftAt, + now: leftAt.add(const Duration(seconds: 5)), + ), + isFalse, + ); + expect( + incomingDeclineShouldNotifyBackend( + leftAt: leftAt, + now: leftAt.add(incomingDeclineNotifySuppression), + ), + isTrue, + ); + expect( + incomingDeclineShouldNotifyBackend(leftAt: null, now: leftAt), + isTrue, + ); + }); + + test('a token is not requested again right after leaving', () { + final leftAt = DateTime(2026, 10, 16, 12); + expect( + mayRequestParticipantToken( + sessionPermitted: false, + leftAt: leftAt, + now: leftAt.add(const Duration(seconds: 2)), + ), + isFalse, + ); + expect( + mayRequestParticipantToken( + sessionPermitted: true, + leftAt: leftAt, + now: leftAt.add(const Duration(seconds: 2)), + ), + isTrue, + ); + expect( + mayRequestParticipantToken( + sessionPermitted: false, + leftAt: null, + now: leftAt, + ), + isTrue, + ); + }); + + test( + 'an ended VoIP push is marked so its CallKit close is not a decline', + () { + expect( + callKitExtraMarksEndedPush({'endedPush': true}), + isTrue, + ); + expect( + callKitExtraMarksEndedPush({'endedPush': 'true'}), + isTrue, + ); + expect(callKitExtraMarksEndedPush({}), isFalse); + }, + ); + test('keeps a connection-loss screen when the link drops', () { expect( shouldForceEndCallAfterRoomDisconnect(