forked from eatvio/eatvio_app
Merge pull request 'feat(video_calls): android repeated call after hanging up fix and ios repeated calls after call initiation fix' (#1079) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1079
This commit is contained in:
@@ -65,6 +65,21 @@ class MainActivity: FlutterActivity() {
|
|||||||
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
|
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" -> {
|
"drainPendingCallKitAccept" -> {
|
||||||
result.success(PendingCallKitAcceptBridge.drain())
|
result.success(PendingCallKitAcceptBridge.drain())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,17 +121,26 @@ object VideoCallPushBridge {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val generation = playbackGeneration.incrementAndGet()
|
val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)
|
||||||
Log.i(TAG, "showIncomingCall: conversation=${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
|
// Ringtone.play() and stop() must run on the same thread. FCM delivers
|
||||||
// pushes on a background thread, while Answer runs on the main thread.
|
// 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 {
|
mainHandler.post {
|
||||||
synchronized(playbackLock) {
|
synchronized(playbackLock) {
|
||||||
if (generation != playbackGeneration.get()) {
|
if (seenGeneration != playbackGeneration.get()) {
|
||||||
Log.i(TAG, "showIncomingCall: skipped stale playback generation=$generation")
|
Log.i(TAG, "showIncomingCall: skipped after silence")
|
||||||
return@post
|
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)
|
startIncomingCallLocked(appContext, callData, generation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -686,6 +686,12 @@ import flutter_foreground_task
|
|||||||
} else if !nameCaller.isEmpty {
|
} else if !nameCaller.isEmpty {
|
||||||
extra["counterpartName"] = nameCaller
|
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(
|
let data = flutter_callkit_incoming.Data(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -701,7 +707,9 @@ import flutter_foreground_task
|
|||||||
data.configureAudioSession = false
|
data.configureAudioSession = false
|
||||||
data.audioSessionActive = false
|
data.audioSessionActive = false
|
||||||
data.audioSessionMode = "videoChat"
|
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.
|
// 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.
|
// The push itself must still be reported, or iOS kills the process.
|
||||||
_ = reserveIncomingCall(id)
|
_ = reserveIncomingCall(id)
|
||||||
|
|||||||
@@ -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/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/plans/enums/chosen_plan_state_enum.dart';
|
||||||
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.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/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
@@ -1009,6 +1010,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
ChatRoomStateLoaded state,
|
ChatRoomStateLoaded state,
|
||||||
ChatUserModel chatCounterpart,
|
ChatUserModel chatCounterpart,
|
||||||
) {
|
) {
|
||||||
|
sl<VideoCallManager>().permitParticipantTokenRequest();
|
||||||
GoRouter.of(context).pushNamed(
|
GoRouter.of(context).pushNamed(
|
||||||
VideoCallScreen.name,
|
VideoCallScreen.name,
|
||||||
extra: <String, dynamic>{
|
extra: <String, dynamic>{
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ class VideoCallManager {
|
|||||||
bool _liveKitDisposed = true;
|
bool _liveKitDisposed = true;
|
||||||
bool _callEndedNotified = true;
|
bool _callEndedNotified = true;
|
||||||
bool _forceEndRequested = false;
|
bool _forceEndRequested = false;
|
||||||
|
bool _sessionMayRequestToken = false;
|
||||||
|
final Map<String, DateTime> _leftConversationAt = {};
|
||||||
final Set<String> _callAcceptNotifiedConversationUuids = {};
|
final Set<String> _callAcceptNotifiedConversationUuids = {};
|
||||||
bool _callStageEntered = false;
|
bool _callStageEntered = false;
|
||||||
bool _joinInProgress = 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,
|
required String conversationUuid,
|
||||||
String? counterpartName,
|
String? counterpartName,
|
||||||
String? localParticipantName,
|
String? localParticipantName,
|
||||||
bool micEnabled = false,
|
bool micEnabled = false,
|
||||||
bool videoEnabled = false,
|
bool videoEnabled = false,
|
||||||
}) async {
|
}) async {
|
||||||
|
final allowed = mayRequestParticipantToken(
|
||||||
|
sessionPermitted: _sessionMayRequestToken,
|
||||||
|
leftAt: _leftConversationAt[conversationUuid],
|
||||||
|
now: DateTime.now(),
|
||||||
|
);
|
||||||
|
if (!allowed) return false;
|
||||||
|
|
||||||
if (_room != null &&
|
if (_room != null &&
|
||||||
!_liveKitDisposed &&
|
!_liveKitDisposed &&
|
||||||
_snapshot.conversationUuid == conversationUuid) {
|
_snapshot.conversationUuid == conversationUuid) {
|
||||||
@@ -199,7 +217,7 @@ class VideoCallManager {
|
|||||||
);
|
);
|
||||||
_ensureLifecycleAttached();
|
_ensureLifecycleAttached();
|
||||||
unawaited(_startCallAudioSession());
|
unawaited(_startCallAudioSession());
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (!_callStageEntered &&
|
if (!_callStageEntered &&
|
||||||
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
||||||
@@ -213,11 +231,12 @@ class VideoCallManager {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
_ensureLifecycleAttached();
|
_ensureLifecycleAttached();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await endCall();
|
await endCall();
|
||||||
|
_sessionMayRequestToken = true;
|
||||||
|
|
||||||
_resetSessionFlagsForNewRoom();
|
_resetSessionFlagsForNewRoom();
|
||||||
_callEndedNotified = false;
|
_callEndedNotified = false;
|
||||||
@@ -255,9 +274,10 @@ class VideoCallManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await _prepareLiveKitConnection();
|
await _prepareLiveKitConnection();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> onCallScreenVisible({
|
Future<bool> onCallScreenVisible({
|
||||||
required String conversationUuid,
|
required String conversationUuid,
|
||||||
String? counterpartName,
|
String? counterpartName,
|
||||||
String? localParticipantName,
|
String? localParticipantName,
|
||||||
@@ -267,7 +287,7 @@ class VideoCallManager {
|
|||||||
if (_snapshot.conversationUuid != conversationUuid ||
|
if (_snapshot.conversationUuid != conversationUuid ||
|
||||||
_room == null ||
|
_room == null ||
|
||||||
_liveKitDisposed) {
|
_liveKitDisposed) {
|
||||||
await startCall(
|
return startCall(
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
counterpartName: counterpartName,
|
counterpartName: counterpartName,
|
||||||
localParticipantName: localParticipantName,
|
localParticipantName: localParticipantName,
|
||||||
@@ -286,6 +306,7 @@ class VideoCallManager {
|
|||||||
unawaited(_ensureLocalParticipantNamePublished());
|
unawaited(_ensureLocalParticipantNamePublished());
|
||||||
_syncRemoteParticipantNameFromRoom();
|
_syncRemoteParticipantNameFromRoom();
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +434,9 @@ class VideoCallManager {
|
|||||||
);
|
);
|
||||||
if (uuid.isNotEmpty) {
|
if (uuid.isNotEmpty) {
|
||||||
_callEndedNotified = true;
|
_callEndedNotified = true;
|
||||||
|
_leftConversationAt[uuid] = DateTime.now();
|
||||||
}
|
}
|
||||||
|
_sessionMayRequestToken = false;
|
||||||
_disposeRoomEphemerals();
|
_disposeRoomEphemerals();
|
||||||
await _disconnectLiveKitOnly();
|
await _disconnectLiveKitOnly();
|
||||||
if (shouldNotifyBackend) {
|
if (shouldNotifyBackend) {
|
||||||
@@ -466,6 +489,12 @@ class VideoCallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await endCall();
|
await endCall();
|
||||||
|
if (!incomingDeclineShouldNotifyBackend(
|
||||||
|
leftAt: _leftConversationAt[uuid],
|
||||||
|
now: DateTime.now(),
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await _videoCallRepository.notifyCallEnded(uuid);
|
await _videoCallRepository.notifyCallEnded(uuid);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -977,6 +1006,7 @@ class VideoCallManager {
|
|||||||
Future<void> _prepareLiveKitConnection() async {
|
Future<void> _prepareLiveKitConnection() async {
|
||||||
final room = _room;
|
final room = _room;
|
||||||
if (room == null) return;
|
if (room == null) return;
|
||||||
|
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
|
||||||
_emitSnapshot(_snapshot.copyWith(connectError: null));
|
_emitSnapshot(_snapshot.copyWith(connectError: null));
|
||||||
try {
|
try {
|
||||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
||||||
@@ -1025,6 +1055,7 @@ class VideoCallManager {
|
|||||||
var wsUrl = _preparedWsUrl;
|
var wsUrl = _preparedWsUrl;
|
||||||
var token = _preparedToken;
|
var token = _preparedToken;
|
||||||
if (wsUrl == null || token == null) {
|
if (wsUrl == null || token == null) {
|
||||||
|
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
|
||||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
||||||
_snapshot.conversationUuid,
|
_snapshot.conversationUuid,
|
||||||
);
|
);
|
||||||
@@ -1103,6 +1134,14 @@ class VideoCallManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _tokenRequestAllowed(String conversationUuid) {
|
||||||
|
return mayRequestParticipantToken(
|
||||||
|
sessionPermitted: _sessionMayRequestToken,
|
||||||
|
leftAt: _leftConversationAt[conversationUuid],
|
||||||
|
now: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool _roomHasRemoteUser() {
|
bool _roomHasRemoteUser() {
|
||||||
final room = _room;
|
final room = _room;
|
||||||
if (room == null) return false;
|
if (room == null) return false;
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> onCallScreenVisible({
|
Future<bool> onCallScreenVisible({
|
||||||
required String conversationUuid,
|
required String conversationUuid,
|
||||||
String? counterpartName,
|
String? counterpartName,
|
||||||
String? localParticipantName,
|
String? localParticipantName,
|
||||||
@@ -225,7 +225,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
unawaited(_applyVideoCallSystemUi());
|
unawaited(_applyVideoCallSystemUi());
|
||||||
});
|
});
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isResumingActiveCall) {
|
if (isResumingActiveCall) {
|
||||||
@@ -255,13 +255,17 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
entryMode: effectiveEntryMode,
|
entryMode: effectiveEntryMode,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await _videoCallManager.startCall(
|
final started = await _videoCallManager.startCall(
|
||||||
conversationUuid: conversationUuid,
|
conversationUuid: conversationUuid,
|
||||||
counterpartName: counterpartName,
|
counterpartName: counterpartName,
|
||||||
localParticipantName: localParticipantName,
|
localParticipantName: localParticipantName,
|
||||||
micEnabled: false,
|
micEnabled: false,
|
||||||
videoEnabled: false,
|
videoEnabled: false,
|
||||||
);
|
);
|
||||||
|
if (!started) {
|
||||||
|
if (!isClosed) emit(const VideoCallSessionState());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
emit(state.copyWith(entryMode: effectiveEntryMode));
|
emit(state.copyWith(entryMode: effectiveEntryMode));
|
||||||
if (isIncomingAccepted) {
|
if (isIncomingAccepted) {
|
||||||
await _tryAutoEnterCallStage();
|
await _tryAutoEnterCallStage();
|
||||||
@@ -273,6 +277,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
unawaited(_applyVideoCallSystemUi());
|
unawaited(_applyVideoCallSystemUi());
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _ensureSystemPipArmedIfInCall() async {
|
Future<void> _ensureSystemPipArmedIfInCall() async {
|
||||||
|
|||||||
@@ -80,18 +80,22 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
unawaited(_initCounterpartMetadata());
|
unawaited(_initCounterpartMetadata());
|
||||||
unawaited(
|
unawaited(_openCallSession());
|
||||||
_cubit.onCallScreenVisible(
|
|
||||||
conversationUuid: widget.conversationUuid,
|
|
||||||
counterpartName: widget.counterpartName,
|
|
||||||
localParticipantName: widget.localParticipantName,
|
|
||||||
counterpartIsConsultant: widget.counterpartIsConsultant,
|
|
||||||
entryMode: widget.entryMode,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
Future<void> _initCounterpartMetadata() async {
|
||||||
var isOnline = widget.counterpartIsOnline ?? false;
|
var isOnline = widget.counterpartIsOnline ?? false;
|
||||||
var lastSeenText = widget.counterpartLastSeenText ?? '';
|
var lastSeenText = widget.counterpartLastSeenText ?? '';
|
||||||
|
|||||||
@@ -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_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/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_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:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@@ -362,7 +363,7 @@ class VideoCallCallKitCoordinator {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _handleDecline(id);
|
await _handleDecline(id, eventExtra: _callKitEventParams(event)?.extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleEndedEvent(CallEvent event) async {
|
Future<void> _handleEndedEvent(CallEvent event) async {
|
||||||
@@ -842,6 +843,7 @@ class VideoCallCallKitCoordinator {
|
|||||||
bool? counterpartIsConsultant,
|
bool? counterpartIsConsultant,
|
||||||
}) async {
|
}) async {
|
||||||
FlutterNativeSplash.remove();
|
FlutterNativeSplash.remove();
|
||||||
|
_manager.permitParticipantTokenRequest();
|
||||||
|
|
||||||
return VideoCallNotificationNavigator.openIncomingCall(
|
return VideoCallNotificationNavigator.openIncomingCall(
|
||||||
conversationUuid: conversationUuid,
|
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)) {
|
if (_suppressedEndCallKitIds.contains(callKitId)) {
|
||||||
log(
|
log(
|
||||||
'VideoCallCallKitCoordinator: ignore spurious end/decline during accept, callKitId=$callKitId',
|
'VideoCallCallKitCoordinator: ignore spurious end/decline during accept, callKitId=$callKitId',
|
||||||
@@ -863,11 +868,15 @@ class VideoCallCallKitCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final params = _paramsForCallId(callKitId);
|
final params = _paramsForCallId(callKitId);
|
||||||
final extra = params?.extra ?? <String, dynamic>{};
|
final extra = params?.extra ?? eventExtra ?? <String, dynamic>{};
|
||||||
final conversationUuid = _parseConversationUuid(
|
final conversationUuid = _parseConversationUuid(
|
||||||
extra,
|
extra,
|
||||||
handle: params?.handle ?? callKitId,
|
handle: params?.handle ?? callKitId,
|
||||||
);
|
);
|
||||||
|
final endedPushDismissal = callKitDeclineIsEndedPush(
|
||||||
|
cachedExtra: params?.extra,
|
||||||
|
eventExtra: eventExtra,
|
||||||
|
);
|
||||||
|
|
||||||
_suppressedEndCallKitIds.add(callKitId);
|
_suppressedEndCallKitIds.add(callKitId);
|
||||||
try {
|
try {
|
||||||
@@ -884,6 +893,8 @@ class VideoCallCallKitCoordinator {
|
|||||||
_activeCallKitId = null;
|
_activeCallKitId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (endedPushDismissal) return;
|
||||||
|
|
||||||
if (_manager.isActiveCall ||
|
if (_manager.isActiveCall ||
|
||||||
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
|
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
|
||||||
await _cubit.endCall();
|
await _cubit.endCall();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
|||||||
typedef VideoCallIncomingClaim = Future<bool> Function(String callId);
|
typedef VideoCallIncomingClaim = Future<bool> Function(String callId);
|
||||||
typedef VideoCallIncomingShow = Future<void> Function(CallKitParams params);
|
typedef VideoCallIncomingShow = Future<void> Function(CallKitParams params);
|
||||||
typedef VideoCallIncomingDelay = Future<void> Function(Duration delay);
|
typedef VideoCallIncomingDelay = Future<void> Function(Duration delay);
|
||||||
|
typedef VideoCallAndroidIncomingShow =
|
||||||
|
Future<void> Function(VideoCallPushPayload payload);
|
||||||
|
|
||||||
/// Shows the system incoming-call UI.
|
/// Shows the system incoming-call UI.
|
||||||
///
|
///
|
||||||
@@ -21,6 +23,9 @@ abstract final class VideoCallIncomingPushPresenter {
|
|||||||
static const MethodChannel _voipChannel = MethodChannel(
|
static const MethodChannel _voipChannel = MethodChannel(
|
||||||
'com.eatvioapp/voip_diagnostics',
|
'com.eatvioapp/voip_diagnostics',
|
||||||
);
|
);
|
||||||
|
static const MethodChannel _androidPushChannel = MethodChannel(
|
||||||
|
'com.eatvioapp/video_call_push',
|
||||||
|
);
|
||||||
|
|
||||||
/// Absolute delays from the start of an iOS present attempt.
|
/// Absolute delays from the start of an iOS present attempt.
|
||||||
static const List<Duration> iosClaimPollDelays = <Duration>[
|
static const List<Duration> iosClaimPollDelays = <Duration>[
|
||||||
@@ -38,11 +43,16 @@ abstract final class VideoCallIncomingPushPresenter {
|
|||||||
VideoCallIncomingShow? showCallkitIncoming,
|
VideoCallIncomingShow? showCallkitIncoming,
|
||||||
VideoCallIncomingDelay? delay,
|
VideoCallIncomingDelay? delay,
|
||||||
List<Duration>? claimPollDelays,
|
List<Duration>? claimPollDelays,
|
||||||
|
VideoCallAndroidIncomingShow? showAndroidIncoming,
|
||||||
}) async {
|
}) async {
|
||||||
final callerName = payload.counterpartName?.trim().isNotEmpty == true
|
final callerName = payload.counterpartName?.trim().isNotEmpty == true
|
||||||
? payload.counterpartName!.trim()
|
? payload.counterpartName!.trim()
|
||||||
: 'Итвио';
|
: 'Итвио';
|
||||||
final ios = isIos ?? Platform.isIOS;
|
final ios = isIos ?? Platform.isIOS;
|
||||||
|
if (!ios && (showAndroidIncoming != null || Platform.isAndroid)) {
|
||||||
|
await (showAndroidIncoming ?? _showAndroidIncoming)(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final callId = ios
|
final callId = ios
|
||||||
? VideoCallKitId.iosUuid(
|
? VideoCallKitId.iosUuid(
|
||||||
conversationUuid: payload.conversationUuid,
|
conversationUuid: payload.conversationUuid,
|
||||||
@@ -106,7 +116,6 @@ abstract final class VideoCallIncomingPushPresenter {
|
|||||||
configureAudioSession: false,
|
configureAudioSession: false,
|
||||||
audioSessionActive: false,
|
audioSessionActive: false,
|
||||||
audioSessionMode: 'videoChat',
|
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(
|
static Future<bool> _claimOrFalse(
|
||||||
VideoCallIncomingClaim claim,
|
VideoCallIncomingClaim claim,
|
||||||
String callId,
|
String callId,
|
||||||
|
|||||||
@@ -30,6 +30,58 @@ bool shouldNotifyCallEnded({
|
|||||||
return true;
|
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({
|
bool shouldForceEndCallAfterRoomDisconnect({
|
||||||
required bool callStageEntered,
|
required bool callStageEntered,
|
||||||
required bool sessionConnected,
|
required bool sessionConnected,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: eatvio_app
|
name: eatvio_app
|
||||||
description: A new Flutter project.
|
description: A new Flutter project.
|
||||||
version: 1.3.54+143
|
version: 1.3.55+144
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
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_foreground_coordinator.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.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';
|
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
|
||||||
@@ -82,6 +83,47 @@ void main() {
|
|||||||
expect(foreground.startCount, 0);
|
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 {
|
test('endCall stops foreground service', () async {
|
||||||
manager.markActiveCallForTesting(mockRoom);
|
manager.markActiveCallForTesting(mockRoom);
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,18 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('syncPipUiStateFromSystem skips overlay when call screen is visible', () async {
|
test('syncPipUiStateFromSystem skips overlay when call screen is visible', () async {
|
||||||
|
final mockRoom = MockRoom();
|
||||||
|
when(mockManager.room).thenReturn(mockRoom);
|
||||||
|
when(
|
||||||
|
mockManager.onCallScreenVisible(
|
||||||
|
conversationUuid: anyNamed('conversationUuid'),
|
||||||
|
counterpartName: anyNamed('counterpartName'),
|
||||||
|
localParticipantName: anyNamed('localParticipantName'),
|
||||||
|
micEnabled: anyNamed('micEnabled'),
|
||||||
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
cubit.emit(
|
cubit.emit(
|
||||||
const VideoCallSessionState(
|
const VideoCallSessionState(
|
||||||
conversationUuid: 'uuid',
|
conversationUuid: 'uuid',
|
||||||
@@ -199,7 +211,7 @@ void main() {
|
|||||||
micEnabled: anyNamed('micEnabled'),
|
micEnabled: anyNamed('micEnabled'),
|
||||||
videoEnabled: anyNamed('videoEnabled'),
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await cubit.onCallScreenVisible(
|
await cubit.onCallScreenVisible(
|
||||||
conversationUuid: 'uuid',
|
conversationUuid: 'uuid',
|
||||||
@@ -231,6 +243,7 @@ void main() {
|
|||||||
linkIssue: VideoCallLinkIssue.none,
|
linkIssue: VideoCallLinkIssue.none,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
@@ -262,7 +275,7 @@ void main() {
|
|||||||
micEnabled: anyNamed('micEnabled'),
|
micEnabled: anyNamed('micEnabled'),
|
||||||
videoEnabled: anyNamed('videoEnabled'),
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async => true);
|
||||||
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
|
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
|
||||||
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
@@ -297,7 +310,7 @@ void main() {
|
|||||||
micEnabled: anyNamed('micEnabled'),
|
micEnabled: anyNamed('micEnabled'),
|
||||||
videoEnabled: anyNamed('videoEnabled'),
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async => true);
|
||||||
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
|
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
|
||||||
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
@@ -328,7 +341,7 @@ void main() {
|
|||||||
micEnabled: anyNamed('micEnabled'),
|
micEnabled: anyNamed('micEnabled'),
|
||||||
videoEnabled: anyNamed('videoEnabled'),
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await cubit.onCallScreenVisible(
|
await cubit.onCallScreenVisible(
|
||||||
conversationUuid: 'uuid',
|
conversationUuid: 'uuid',
|
||||||
@@ -362,7 +375,7 @@ void main() {
|
|||||||
micEnabled: anyNamed('micEnabled'),
|
micEnabled: anyNamed('micEnabled'),
|
||||||
videoEnabled: anyNamed('videoEnabled'),
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
cubit.emit(
|
cubit.emit(
|
||||||
const VideoCallSessionState(
|
const VideoCallSessionState(
|
||||||
@@ -390,7 +403,7 @@ void main() {
|
|||||||
micEnabled: anyNamed('micEnabled'),
|
micEnabled: anyNamed('micEnabled'),
|
||||||
videoEnabled: anyNamed('videoEnabled'),
|
videoEnabled: anyNamed('videoEnabled'),
|
||||||
),
|
),
|
||||||
).thenAnswer((_) async {});
|
).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await cubit.onCallScreenVisible(conversationUuid: 'uuid');
|
await cubit.onCallScreenVisible(conversationUuid: 'uuid');
|
||||||
expect(cubit.isCallScreenRouteVisible, isTrue);
|
expect(cubit.isCallScreenRouteVisible, isTrue);
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ void main() {
|
|||||||
expect(shown.single.ios?.configureAudioSession, isFalse);
|
expect(shown.single.ios?.configureAudioSession, isFalse);
|
||||||
expect(shown.single.ios?.audioSessionActive, isFalse);
|
expect(shown.single.ios?.audioSessionActive, isFalse);
|
||||||
expect(shown.single.ios?.audioSessionMode, 'videoChat');
|
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 {
|
test('iOS stops polling when the push claims the call', () async {
|
||||||
@@ -122,6 +122,25 @@ void main() {
|
|||||||
expect(shown, isEmpty);
|
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(
|
test(
|
||||||
'Android shows the incoming call without waiting for a VoIP claim',
|
'Android shows the incoming call without waiting for a VoIP claim',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -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', () {
|
test('keeps a connection-loss screen when the link drops', () {
|
||||||
expect(
|
expect(
|
||||||
shouldForceEndCallAfterRoomDisconnect(
|
shouldForceEndCallAfterRoomDisconnect(
|
||||||
|
|||||||
Reference in New Issue
Block a user