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

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

View File

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

View File

@@ -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 = <CallKitParams>[];
final native = <VideoCallPushPayload>[];
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 {

View File

@@ -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(<String, dynamic>{'endedPush': true}),
isTrue,
);
expect(
callKitExtraMarksEndedPush(<String, dynamic>{'endedPush': 'true'}),
isTrue,
);
expect(callKitExtraMarksEndedPush(<String, dynamic>{}), isFalse);
},
);
test('keeps a connection-loss screen when the link drops', () {
expect(
shouldForceEndCallAfterRoomDisconnect(