Compare commits

...

2 Commits

15 changed files with 357 additions and 34 deletions

View File

@@ -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<Any, Any>()
val data = HashMap<String, String>()
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())
}

View File

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

View File

@@ -686,6 +686,12 @@ import flutter_foreground_task
} else if !nameCaller.isEmpty {
extra["counterpartName"] = nameCaller
}
// Closing this report looks like a user decline. Dart must not tell the
// backend, or the server sends another ended VoIP and the other phone
// shows it as a new incoming call.
if isEndedPush {
extra["endedPush"] = true
}
let data = flutter_callkit_incoming.Data(
id: id,
@@ -701,7 +707,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)

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';
@@ -362,7 +363,7 @@ class VideoCallCallKitCoordinator {
);
return;
}
await _handleDecline(id);
await _handleDecline(id, eventExtra: _callKitEventParams(event)?.extra);
}
Future<void> _handleEndedEvent(CallEvent event) async {
@@ -842,6 +843,7 @@ class VideoCallCallKitCoordinator {
bool? counterpartIsConsultant,
}) async {
FlutterNativeSplash.remove();
_manager.permitParticipantTokenRequest();
return VideoCallNotificationNavigator.openIncomingCall(
conversationUuid: conversationUuid,
@@ -853,7 +855,10 @@ class VideoCallCallKitCoordinator {
);
}
Future<void> _handleDecline(String callKitId) async {
Future<void> _handleDecline(
String callKitId, {
Map<String, dynamic>? eventExtra,
}) async {
if (_suppressedEndCallKitIds.contains(callKitId)) {
log(
'VideoCallCallKitCoordinator: ignore spurious end/decline during accept, callKitId=$callKitId',
@@ -863,11 +868,15 @@ class VideoCallCallKitCoordinator {
}
final params = _paramsForCallId(callKitId);
final extra = params?.extra ?? <String, dynamic>{};
final extra = params?.extra ?? eventExtra ?? <String, dynamic>{};
final conversationUuid = _parseConversationUuid(
extra,
handle: params?.handle ?? callKitId,
);
final endedPushDismissal = callKitDeclineIsEndedPush(
cachedExtra: params?.extra,
eventExtra: eventExtra,
);
_suppressedEndCallKitIds.add(callKitId);
try {
@@ -884,6 +893,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,58 @@ 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;
}
/// Either the cached incoming payload or the decline event itself can carry
/// the marker. The decline often arrives with the push payload, while the
/// cache still holds the original ringing call.
bool callKitDeclineIsEndedPush({
Map<String, dynamic>? cachedExtra,
Map<String, dynamic>? eventExtra,
}) {
return callKitExtraMarksEndedPush(cachedExtra) ||
callKitExtraMarksEndedPush(eventExtra);
}
bool shouldForceEndCallAfterRoomDisconnect({
required bool callStageEntered,
required bool sessionConnected,

View File

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.54+143
version: 1.3.55+144
publish_to: 'none'
environment:

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