forked from eatvio/eatvio_app
Merge pull request 'feat(video_calls): ios calle mic fix, infinite loading after accepting call on an oppened app fix, incaming call declining after declining on a different device' (#1095) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1095
This commit is contained in:
@@ -13,14 +13,12 @@ object CallKitAcceptIntentMapper {
|
||||
return null
|
||||
}
|
||||
|
||||
return hashMapOf(
|
||||
"action" to action,
|
||||
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
||||
"handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""),
|
||||
"nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""),
|
||||
"avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""),
|
||||
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
|
||||
)
|
||||
return payloadMap(data, action)
|
||||
}
|
||||
|
||||
fun declinePayloadFromBundle(data: Bundle, action: String): Map<String, Any?>? {
|
||||
if (!action.contains(CallkitConstants.ACTION_CALL_DECLINE)) return null
|
||||
return payloadMap(data, action)
|
||||
}
|
||||
|
||||
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
|
||||
@@ -35,6 +33,16 @@ object CallKitAcceptIntentMapper {
|
||||
return null
|
||||
}
|
||||
|
||||
return payloadMap(data, action)
|
||||
}
|
||||
|
||||
fun clearCallKitLaunchIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
|
||||
intent.action = null
|
||||
}
|
||||
|
||||
private fun payloadMap(data: Bundle, action: String): Map<String, Any?> {
|
||||
return hashMapOf(
|
||||
"action" to action,
|
||||
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
||||
@@ -44,10 +52,4 @@ object CallKitAcceptIntentMapper {
|
||||
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
|
||||
)
|
||||
}
|
||||
|
||||
fun clearCallKitLaunchIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
|
||||
intent.action = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ object CallKitIntentBridge {
|
||||
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
|
||||
PendingCallKitAcceptBridge.store(payload)
|
||||
}
|
||||
CallKitAcceptIntentMapper.declinePayloadFromBundle(data, action)?.let { payload ->
|
||||
PendingCallKitDeclineBridge.store(payload)
|
||||
if (MainActivity.isInForeground) {
|
||||
MainActivity.onPendingDecline?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)
|
||||
|
||||
@@ -25,6 +25,9 @@ class MainActivity: FlutterActivity() {
|
||||
@JvmStatic
|
||||
var isInForeground: Boolean = false
|
||||
private set
|
||||
|
||||
@JvmStatic
|
||||
var onPendingDecline: (() -> Unit)? = null
|
||||
}
|
||||
|
||||
private val channelName = "com.eatvioapp/hms_push"
|
||||
@@ -38,13 +41,18 @@ class MainActivity: FlutterActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
isInForeground = true
|
||||
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
|
||||
captureCallKitLaunchIntent(intent)
|
||||
if (PendingCallKitAcceptBridge.peek() != null) {
|
||||
scheduleDeliverPendingCallKitAccept()
|
||||
}
|
||||
if (PendingCallKitDeclineBridge.peek() != null) {
|
||||
scheduleDeliverPendingCallKitDecline()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
onPendingDecline = null
|
||||
isInForeground = false
|
||||
super.onPause()
|
||||
}
|
||||
@@ -55,6 +63,7 @@ class MainActivity: FlutterActivity() {
|
||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
||||
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
|
||||
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
|
||||
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
|
||||
videoCallPushChannel?.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"dismissIncomingCalls" -> {
|
||||
@@ -95,6 +104,9 @@ class MainActivity: FlutterActivity() {
|
||||
"peekPendingCallKitAccept" -> {
|
||||
result.success(PendingCallKitAcceptBridge.peekPending())
|
||||
}
|
||||
"drainPendingCallKitDecline" -> {
|
||||
result.success(PendingCallKitDeclineBridge.drain())
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -250,6 +262,30 @@ class MainActivity: FlutterActivity() {
|
||||
channel.invokeMethod("onCallKitLaunchIntent", payload)
|
||||
}
|
||||
|
||||
private fun scheduleDeliverPendingCallKitDecline() {
|
||||
PendingCallKitDeclineBridge.scheduleDeliverToFlutter(
|
||||
deliver = { deliverPendingCallKitDeclineToFlutter() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun deliverPendingCallKitDeclineToFlutter() {
|
||||
val payload = PendingCallKitDeclineBridge.peek() ?: return
|
||||
val channel = videoCallPushChannel
|
||||
if (channel == null) {
|
||||
android.util.Log.w(
|
||||
"VideoCallPush",
|
||||
"deliverPendingCallKitDeclineToFlutter: channel not ready",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
android.util.Log.i(
|
||||
"VideoCallPush",
|
||||
"deliverPendingCallKitDeclineToFlutter: id=${payload["id"]}",
|
||||
)
|
||||
channel.invokeMethod("onCallKitDecline", payload)
|
||||
}
|
||||
|
||||
private fun handleIntentForActionUrl(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
|
||||
/// Stores a decline until Flutter can POST /video-call/decline.
|
||||
///
|
||||
/// The plugin broadcasts the decline. If the Flutter engine is not attached,
|
||||
/// that broadcast never reaches the backend and the call keeps ringing on the
|
||||
/// user's other devices.
|
||||
object PendingCallKitDeclineBridge {
|
||||
private const val TAG = "PendingCallKitDecline"
|
||||
|
||||
@Volatile
|
||||
private var pendingPayload: Map<String, Any?>? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
fun store(payload: Map<String, Any?>) {
|
||||
pendingPayload = payload
|
||||
Log.i(TAG, "stored pending decline id=${payload["id"]}")
|
||||
}
|
||||
|
||||
fun peek(): Map<String, Any?>? = pendingPayload
|
||||
|
||||
fun drain(): Map<String, Any?>? {
|
||||
val payload = pendingPayload
|
||||
pendingPayload = null
|
||||
if (payload != null) {
|
||||
Log.i(TAG, "drained pending decline id=${payload["id"]}")
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
fun scheduleDeliverToFlutter(
|
||||
deliver: () -> Unit,
|
||||
delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L),
|
||||
) {
|
||||
for (delayMs in delaysMs) {
|
||||
mainHandler.postDelayed({ deliver() }, delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,7 +558,10 @@ object VideoCallPushBridge {
|
||||
if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
// A ring that already stores its own call id must not be closed by
|
||||
// another call in the same chat. Legacy rings have no call id and
|
||||
// still match the conversation.
|
||||
if (!extraCallId.isNullOrBlank()) return false
|
||||
}
|
||||
|
||||
val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false
|
||||
|
||||
@@ -19,6 +19,7 @@ import flutter_foreground_task
|
||||
private let pendingAcceptedCallTtl: TimeInterval = 120
|
||||
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
||||
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
|
||||
private var callAudioChannel: FlutterMethodChannel?
|
||||
private let appUrlScheme = "eatvio"
|
||||
/// True only if CallKit ended while the in-app call must keep audio.
|
||||
/// Incoming accept keeps CallKit up, so this stays false after answer.
|
||||
@@ -115,6 +116,7 @@ import flutter_foreground_task
|
||||
name: callAudioSessionChannelName,
|
||||
binaryMessenger: flutterController.binaryMessenger
|
||||
)
|
||||
self.callAudioChannel = callAudioChannel
|
||||
callAudioChannel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
@@ -1127,6 +1129,14 @@ import flutter_foreground_task
|
||||
// misses the session CallKit just activated; toggling after that
|
||||
// does not restart the ADM, so the incoming microphone stays silent.
|
||||
rtcSession.isAudioEnabled = true
|
||||
// setCallConnected requests a second CXAnswerCallAction. That fires
|
||||
// didActivate again after the microphone track already exists and can
|
||||
// stop AudioEngine. Dart starts recording on each activation once the
|
||||
// track is published. The first activation (accept, no track yet) is a
|
||||
// no-op on the Dart side.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.callAudioChannel?.invokeMethod("audioSessionActivated", arguments: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
|
||||
@@ -1,16 +1,70 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Stable id of this install, sent as `hw_id` on push registration and on
|
||||
/// video-call accept/decline.
|
||||
///
|
||||
/// Android `Build.ID` is the firmware id. Two phones on the same build share
|
||||
/// it, so the backend treats them as one device and a decline on one does not
|
||||
/// reach the other. A per-install id stays unique.
|
||||
abstract final class DeviceHwIdProvider {
|
||||
static Future<String?> current() async {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
static const androidInstallIdKey = 'eatvio.android_install_hw_id';
|
||||
static const androidTokenMigratedKey = 'eatvio.android_hw_id_token_migrated';
|
||||
|
||||
static Future<String?> current({SharedPreferences? preferences}) async {
|
||||
if (Platform.isIOS) {
|
||||
return (await deviceInfo.iosInfo).identifierForVendor;
|
||||
return (await DeviceInfoPlugin().iosInfo).identifierForVendor;
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
return (await deviceInfo.androidInfo).id;
|
||||
return androidInstallId(preferences: preferences);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String> androidInstallId({
|
||||
SharedPreferences? preferences,
|
||||
}) async {
|
||||
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||
final existing = prefs.getString(androidInstallIdKey)?.trim();
|
||||
if (existing != null && existing.length >= 8) return existing;
|
||||
|
||||
final created = _newInstallId();
|
||||
await prefs.setString(androidInstallIdKey, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/// The previous Android registration used `Build.ID`. Drop that token once
|
||||
/// and register again with the install id.
|
||||
static Future<bool> androidTokenNeedsReregister({
|
||||
SharedPreferences? preferences,
|
||||
}) async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||
return prefs.getBool(androidTokenMigratedKey) != true;
|
||||
}
|
||||
|
||||
static Future<void> markAndroidTokenReregistered({
|
||||
SharedPreferences? preferences,
|
||||
}) async {
|
||||
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||
await prefs.setBool(androidTokenMigratedKey, true);
|
||||
}
|
||||
|
||||
static String _newInstallId() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
final hex = bytes
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
return '${hex.substring(0, 8)}-'
|
||||
'${hex.substring(8, 12)}-'
|
||||
'${hex.substring(12, 16)}-'
|
||||
'${hex.substring(16, 20)}-'
|
||||
'${hex.substring(20)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
|
||||
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
|
||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
@@ -44,15 +45,16 @@ class FirebaseApi {
|
||||
Future<void> registerAnonymousPushToken() async {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token == null || token.isEmpty) return;
|
||||
final hwId = await DeviceHwIdProvider.current();
|
||||
if (hwId == null || hwId.length < 8) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/anonymous_token',
|
||||
data: {
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'hw_id': hwId,
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
@@ -72,23 +74,38 @@ class FirebaseApi {
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/register',
|
||||
data: {
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('registerPushToken timeout');
|
||||
},
|
||||
);
|
||||
final hwId = await DeviceHwIdProvider.current();
|
||||
final reregisterAndroid =
|
||||
await DeviceHwIdProvider.androidTokenNeedsReregister();
|
||||
if (token != null &&
|
||||
token.isNotEmpty &&
|
||||
hwId != null &&
|
||||
hwId.length >= 8) {
|
||||
if (reregisterAndroid) {
|
||||
try {
|
||||
await unregisterPushToken();
|
||||
} catch (_) {}
|
||||
}
|
||||
await sl<Dio>()
|
||||
.post(
|
||||
'/api/profile/notification/token/register',
|
||||
data: {
|
||||
'platform': credentials.platform,
|
||||
'hw_id': hwId,
|
||||
'token': token,
|
||||
},
|
||||
)
|
||||
.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('registerPushToken timeout');
|
||||
},
|
||||
);
|
||||
if (reregisterAndroid) {
|
||||
await DeviceHwIdProvider.markAndroidTokenReregistered();
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
@@ -145,12 +162,13 @@ class FirebaseApi {
|
||||
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
|
||||
if (token == null || token.isEmpty) return;
|
||||
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final hwId = await DeviceHwIdProvider.current();
|
||||
if (hwId == null || hwId.length < 8) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/voip/register',
|
||||
data: {
|
||||
'platform': 'ios',
|
||||
'hw_id': (await deviceInfo.iosInfo).identifierForVendor,
|
||||
'hw_id': hwId,
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
|
||||
@@ -50,6 +50,14 @@ class WsManager {
|
||||
|
||||
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||
|
||||
/// The callee rejected the ring. Broadcast to the caller and the callee.
|
||||
Stream<ChannelReadEvent>? get videoCallDeclinedStream =>
|
||||
_privateMessagesChannel?.bind('video-call-declined');
|
||||
|
||||
/// The call was cancelled or hung up. Same channel the web client uses.
|
||||
Stream<ChannelReadEvent>? get videoCallEndedStream =>
|
||||
_privateMessagesChannel?.bind('video-call-ended');
|
||||
|
||||
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
||||
|
||||
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_socket_signal.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -23,11 +24,15 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
StreamSubscription? newIncomingMessagesSubscription;
|
||||
StreamSubscription? newLastMessageSubscription;
|
||||
StreamSubscription<void>? reconnectedSubscription;
|
||||
StreamSubscription? videoCallDeclinedSubscription;
|
||||
StreamSubscription? videoCallEndedSubscription;
|
||||
final Set<int> _presentedVideoCallMessageIds = {};
|
||||
|
||||
/// Test seam. Production uses CallKit.
|
||||
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
|
||||
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
|
||||
Future<void> Function({String? conversationUuid, String? callId})?
|
||||
endActiveVideoCall;
|
||||
|
||||
ChatsCubit({
|
||||
required this.chatRepository,
|
||||
@@ -55,6 +60,26 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
_handleNewMessageViaSocket(event.data);
|
||||
});
|
||||
|
||||
await videoCallDeclinedSubscription?.cancel();
|
||||
videoCallDeclinedSubscription = wsManager.videoCallDeclinedStream?.listen(
|
||||
(event) {
|
||||
unawaited(
|
||||
_closeCallFromSocket(
|
||||
VideoCallSocketSignal.tryParseDeclined(event.data),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await videoCallEndedSubscription?.cancel();
|
||||
videoCallEndedSubscription = wsManager.videoCallEndedStream?.listen((
|
||||
event,
|
||||
) {
|
||||
unawaited(
|
||||
_closeCallFromSocket(VideoCallSocketSignal.tryParseEnded(event.data)),
|
||||
);
|
||||
});
|
||||
|
||||
await reconnectedSubscription?.cancel();
|
||||
reconnectedSubscription = wsManager.reconnected.listen((_) {
|
||||
unawaited(getChatRooms());
|
||||
@@ -262,6 +287,34 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _closeCallFromSocket(VideoCallSocketSignal? signal) async {
|
||||
if (signal == null) return;
|
||||
if (signal.endsActiveCall) {
|
||||
final endActive = endActiveVideoCall;
|
||||
if (endActive != null) {
|
||||
await endActive(
|
||||
conversationUuid: signal.conversationUuid,
|
||||
callId: signal.callId,
|
||||
);
|
||||
} else {
|
||||
await VideoCallEndedPushHandler.endActiveCallIfMatches(
|
||||
conversationUuid: signal.conversationUuid,
|
||||
callId: signal.callId,
|
||||
);
|
||||
}
|
||||
}
|
||||
final dismiss = dismissIncomingVideoCall;
|
||||
if (dismiss != null) {
|
||||
await dismiss(signal.conversationUuid);
|
||||
return;
|
||||
}
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: signal.conversationUuid,
|
||||
callId: signal.callId,
|
||||
reason: signal.reason,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
|
||||
try {
|
||||
final dismiss = dismissIncomingVideoCall;
|
||||
@@ -308,6 +361,8 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
@override
|
||||
Future<void> close() {
|
||||
newIncomingMessagesSubscription?.cancel();
|
||||
videoCallDeclinedSubscription?.cancel();
|
||||
videoCallEndedSubscription?.cancel();
|
||||
reconnectedSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ class VideoCallManager {
|
||||
_audioSession =
|
||||
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator(),
|
||||
_resolveHwId = resolveHwId ?? DeviceHwIdProvider.current {
|
||||
_audioSession.onIosAudioSessionActivated = _restartIosCaptureIfMicOn;
|
||||
FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData);
|
||||
if (Platform.isIOS) {
|
||||
// LiveKit rewrites the audio session whenever a remote audio track
|
||||
@@ -250,7 +251,23 @@ class VideoCallManager {
|
||||
}
|
||||
}
|
||||
|
||||
await endCall();
|
||||
final previousUuid = _snapshot.conversationUuid;
|
||||
final hasLiveRoom = _room != null && !_liveKitDisposed;
|
||||
if (shouldPreserveCallKitWhenStartingCall(
|
||||
previousConversationUuid: previousUuid,
|
||||
nextConversationUuid: conversationUuid,
|
||||
)) {
|
||||
// The accepted CallKit call owns the iOS microphone. A full endCall
|
||||
// here deactivates that session before the mic track exists.
|
||||
if (hasLiveRoom) {
|
||||
_disposeRoomEphemerals();
|
||||
await _disconnectLiveKitWithTimeout();
|
||||
_liveKitDisposed = false;
|
||||
_callStageEntered = false;
|
||||
}
|
||||
} else {
|
||||
await endCall();
|
||||
}
|
||||
_sessionMayRequestToken = true;
|
||||
|
||||
_resetSessionFlagsForNewRoom();
|
||||
@@ -445,7 +462,6 @@ class VideoCallManager {
|
||||
final shouldNotifyBackend = shouldNotifyCallEnded(
|
||||
alreadyNotified: _callEndedNotified,
|
||||
hasConversation: uuid.isNotEmpty,
|
||||
peerStillInRoom: _roomHasRemoteUser(),
|
||||
);
|
||||
if (uuid.isNotEmpty) {
|
||||
_callEndedNotified = true;
|
||||
@@ -453,10 +469,16 @@ class VideoCallManager {
|
||||
}
|
||||
_sessionMayRequestToken = false;
|
||||
_disposeRoomEphemerals();
|
||||
await _disconnectLiveKitOnly();
|
||||
await _disconnectLiveKitWithTimeout();
|
||||
if (shouldNotifyBackend) {
|
||||
try {
|
||||
await _videoCallRepository.notifyCallEnded(uuid, callId: _activeCallId);
|
||||
await _videoCallRepository.notifyCallEnded(
|
||||
uuid,
|
||||
callId: backendVideoCallId(
|
||||
callId: _activeCallId,
|
||||
conversationUuid: uuid,
|
||||
),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
await _foreground.stopOngoingCall();
|
||||
@@ -497,18 +519,19 @@ class VideoCallManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decline from incoming UI before joining the LiveKit room.
|
||||
/// Decline from the incoming UI before this device joins the room.
|
||||
///
|
||||
/// Uses `POST /decline` so the backend can stop ringing on the user's
|
||||
/// other devices. Do not call `/ended` for a ringing decline.
|
||||
/// `POST /video-call/decline` stops ringing on the callee's other devices
|
||||
/// and tells the caller the call was declined.
|
||||
Future<void> declineIncomingCall(
|
||||
String conversationUuid, {
|
||||
String? callId,
|
||||
}) async {
|
||||
final uuid = conversationUuid.trim();
|
||||
final resolvedCallId = callId?.trim().isNotEmpty == true
|
||||
? callId!.trim()
|
||||
: _activeCallId;
|
||||
final resolvedCallId = backendVideoCallId(
|
||||
callId: callId?.trim().isNotEmpty == true ? callId!.trim() : _activeCallId,
|
||||
conversationUuid: uuid,
|
||||
);
|
||||
if (uuid.isEmpty) {
|
||||
await _cleanupLocalCallAfterDecline();
|
||||
return;
|
||||
@@ -549,7 +572,7 @@ class VideoCallManager {
|
||||
_callEndedNotified = true;
|
||||
_sessionMayRequestToken = false;
|
||||
_disposeRoomEphemerals();
|
||||
await _disconnectLiveKitOnly();
|
||||
await _disconnectLiveKitWithTimeout();
|
||||
await _foreground.stopOngoingCall();
|
||||
await _stopCallAudioSession();
|
||||
await _dismissIosCallKitAfterHangup(uuid);
|
||||
@@ -591,8 +614,8 @@ class VideoCallManager {
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
if (next) {
|
||||
await _audioSession.restartCapture();
|
||||
await _bindIosCallKitAudioEngine();
|
||||
await _audioSession.restartCapture();
|
||||
}
|
||||
} catch (_) {
|
||||
return;
|
||||
@@ -791,8 +814,8 @@ class VideoCallManager {
|
||||
true,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
await _audioSession.restartCapture();
|
||||
await _bindIosCallKitAudioEngine();
|
||||
await _audioSession.restartCapture();
|
||||
} catch (_) {}
|
||||
}
|
||||
await _resumeVideoAfterForeground();
|
||||
@@ -964,16 +987,17 @@ class VideoCallManager {
|
||||
}
|
||||
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost));
|
||||
})
|
||||
..on<lk.ParticipantDisconnectedEvent>((_) {
|
||||
..on<lk.ParticipantDisconnectedEvent>((event) {
|
||||
final room = _room;
|
||||
if (room == null) return;
|
||||
final departedIdentity = event.participant.identity;
|
||||
if (!shouldForceEndCallAfterPeerLeft(
|
||||
callStageEntered: _callStageEntered,
|
||||
sessionConnected:
|
||||
_snapshot.connectPhase == VideoCallConnectPhase.connected,
|
||||
remoteIdentities: room.remoteParticipants.values.map(
|
||||
(participant) => participant.identity,
|
||||
),
|
||||
remoteIdentities: room.remoteParticipants.values
|
||||
.map((participant) => participant.identity)
|
||||
.where((identity) => identity != departedIdentity),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
@@ -1074,10 +1098,15 @@ class VideoCallManager {
|
||||
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
|
||||
_emitSnapshot(_snapshot.copyWith(connectError: null));
|
||||
try {
|
||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
||||
_snapshot.conversationUuid,
|
||||
callId: _activeCallId,
|
||||
);
|
||||
final tokenResponse = await _videoCallRepository
|
||||
.getParticipantToken(
|
||||
_snapshot.conversationUuid,
|
||||
callId: backendVideoCallId(
|
||||
callId: _activeCallId,
|
||||
conversationUuid: _snapshot.conversationUuid,
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 12));
|
||||
if (_liveKitDisposed) return;
|
||||
|
||||
rememberCallId(tokenResponse.callId);
|
||||
@@ -1123,10 +1152,15 @@ class VideoCallManager {
|
||||
var token = _preparedToken;
|
||||
if (wsUrl == null || token == null) {
|
||||
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
|
||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
||||
_snapshot.conversationUuid,
|
||||
callId: _activeCallId,
|
||||
);
|
||||
final tokenResponse = await _videoCallRepository
|
||||
.getParticipantToken(
|
||||
_snapshot.conversationUuid,
|
||||
callId: backendVideoCallId(
|
||||
callId: _activeCallId,
|
||||
conversationUuid: _snapshot.conversationUuid,
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 12));
|
||||
if (_liveKitDisposed) return;
|
||||
rememberCallId(tokenResponse.callId);
|
||||
wsUrl = tokenResponse.wsUrl;
|
||||
@@ -1135,7 +1169,7 @@ class VideoCallManager {
|
||||
_preparedToken = token;
|
||||
}
|
||||
|
||||
await room.connect(wsUrl, token);
|
||||
await room.connect(wsUrl, token).timeout(const Duration(seconds: 15));
|
||||
if (_liveKitDisposed) return;
|
||||
|
||||
await _ensureLocalParticipantNamePublished();
|
||||
@@ -1146,6 +1180,13 @@ class VideoCallManager {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _disconnectLiveKitWithTimeout() {
|
||||
return _disconnectLiveKitOnly().timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () {},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _disconnectLiveKitOnly() async {
|
||||
if (_room == null) return;
|
||||
await _stopCallAudioSession();
|
||||
@@ -1216,14 +1257,6 @@ class VideoCallManager {
|
||||
);
|
||||
}
|
||||
|
||||
bool _roomHasRemoteUser() {
|
||||
final room = _room;
|
||||
if (room == null) return false;
|
||||
return videoCallRoomStillHasRemoteUser(
|
||||
room.remoteParticipants.values.map((participant) => participant.identity),
|
||||
);
|
||||
}
|
||||
|
||||
bool get _canHandleCallAudio =>
|
||||
_callStageEntered &&
|
||||
!_liveKitDisposed &&
|
||||
@@ -1261,12 +1294,14 @@ class VideoCallManager {
|
||||
|
||||
if (micOn) {
|
||||
try {
|
||||
await lp?.setMicrophoneEnabled(
|
||||
true,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
await _audioSession.restartCapture();
|
||||
await lp
|
||||
?.setMicrophoneEnabled(
|
||||
true,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
await _bindIosCallKitAudioEngine();
|
||||
await _audioSession.restartCapture();
|
||||
} catch (_) {
|
||||
effectiveMic = false;
|
||||
}
|
||||
@@ -1309,15 +1344,24 @@ class VideoCallManager {
|
||||
|
||||
Future<void> _bindIosCallKitAudioEngine() async {
|
||||
if (!Platform.isIOS) return;
|
||||
try {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
if (calls.isEmpty) return;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
await _audioSession.startCallKitCapture();
|
||||
}
|
||||
|
||||
/// CallKit activates the session on accept and again when the call is
|
||||
/// marked connected. The second activation can stop AudioEngine after the
|
||||
/// microphone track is already published, so recording has to start again.
|
||||
Future<void> _restartIosCaptureIfMicOn() async {
|
||||
if (!_iosMicCaptureReady) return;
|
||||
// The plugin posts an audio-interruption ended notice in the same
|
||||
// didActivate. Starting immediately loses to that notice.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
if (!_iosMicCaptureReady) return;
|
||||
await _audioSession.startCallKitCapture();
|
||||
}
|
||||
|
||||
bool get _iosMicCaptureReady =>
|
||||
Platform.isIOS && _snapshot.micEnabled && _callStageEntered;
|
||||
|
||||
Future<void> _startCallAudioSession() async {
|
||||
if (!_canHandleCallAudio) return;
|
||||
try {
|
||||
@@ -1333,18 +1377,22 @@ class VideoCallManager {
|
||||
|
||||
/// Incoming iOS keeps CallKit up for the whole call so the microphone
|
||||
/// session stays. Hang-up has to close that CallKit call.
|
||||
///
|
||||
/// An empty id used to match every active call, including the one the
|
||||
/// callee just answered, and CallKit then deactivated the microphone.
|
||||
Future<void> _dismissIosCallKitAfterHangup(String? conversationUuid) async {
|
||||
if (!Platform.isIOS) return;
|
||||
final uuid = conversationUuid?.trim().toLowerCase() ?? '';
|
||||
if (uuid.isEmpty) return;
|
||||
try {
|
||||
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
||||
if (activeCalls.isEmpty) return;
|
||||
final uuid = conversationUuid?.trim().toLowerCase() ?? '';
|
||||
for (final call in activeCalls) {
|
||||
final handle = call.handle?.trim().toLowerCase();
|
||||
final extraUuid =
|
||||
call.extra?['conversationUuid']?.toString().trim().toLowerCase() ??
|
||||
call.extra?['conversation_uuid']?.toString().trim().toLowerCase();
|
||||
final matches = uuid.isEmpty || handle == uuid || extraUuid == uuid;
|
||||
final matches = handle == uuid || extraUuid == uuid;
|
||||
if (!matches) continue;
|
||||
VideoCallRemoteEndRegistry.mark(call.id);
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.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_notification_navigator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -83,10 +84,19 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
}
|
||||
if (_isCallSessionInactive(snapshot)) {
|
||||
unawaited(_systemPipCoordinator.disarm());
|
||||
if (_callScreenRouteVisible) {
|
||||
if (isTransientIdleSnapshotWhileCallScreenVisible(
|
||||
callScreenVisible: _callScreenRouteVisible,
|
||||
conversationUuid: snapshot.conversationUuid,
|
||||
needsManualReconnect: snapshot.linkIssue.needsManualReconnect,
|
||||
connectError: snapshot.connectError,
|
||||
)) {
|
||||
// [VideoCallManager.startCall] briefly emits idle while replacing the room.
|
||||
return;
|
||||
}
|
||||
if (_callScreenRouteVisible) {
|
||||
emit(_sessionStateFromSnapshot(snapshot));
|
||||
return;
|
||||
}
|
||||
_resetLobbyReadyTracking();
|
||||
emit(const VideoCallSessionState());
|
||||
unawaited(_restoreSystemUiAfterCall());
|
||||
@@ -361,7 +371,14 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
_autoEnterInFlight = true;
|
||||
try {
|
||||
await _requestMicBeforeIncomingEnter();
|
||||
await enterCallStage();
|
||||
for (var attempt = 0; attempt < 3; attempt++) {
|
||||
if (isClosed || state.callStageEntered) return;
|
||||
if (state.entryMode != VideoCallEntryMode.incomingAccepted) return;
|
||||
if (state.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
await enterCallStage();
|
||||
if (state.callStageEntered) return;
|
||||
await Future<void>.delayed(Duration(milliseconds: 350 * (attempt + 1)));
|
||||
}
|
||||
} finally {
|
||||
_autoEnterInFlight = false;
|
||||
}
|
||||
@@ -383,7 +400,14 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
|
||||
Future<bool> _requestMicPermissionForCall() async {
|
||||
try {
|
||||
final status = await Permission.microphone.request();
|
||||
final current = await Permission.microphone.status;
|
||||
if (current.isGranted) return true;
|
||||
// The incoming-call activity can keep this future pending when the app
|
||||
// was already open. The call has to leave the loading screen anyway.
|
||||
final status = await Permission.microphone.request().timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () => PermissionStatus.denied,
|
||||
);
|
||||
return status.isGranted;
|
||||
} catch (_) {
|
||||
return false;
|
||||
@@ -396,7 +420,8 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
if (_videoCallManager.callEndedNotified) return;
|
||||
final inCall =
|
||||
state.callStageEntered ||
|
||||
state.connectPhase == VideoCallConnectPhase.connected;
|
||||
state.connectPhase == VideoCallConnectPhase.connected ||
|
||||
state.connectPhase == VideoCallConnectPhase.connecting;
|
||||
if (!inCall) return;
|
||||
|
||||
_handlingRemoteCallEnd = true;
|
||||
|
||||
@@ -20,7 +20,13 @@ class VideoCallAudioSessionCoordinator {
|
||||
_isIos = isIos ?? Platform.isIOS,
|
||||
_iosCallAudioSession = iosCallAudioSession ?? _invokeIosCallAudioSession,
|
||||
_stopLocalRecording = stopLocalRecording ?? _invokeStopLocalRecording,
|
||||
_startLocalRecording = startLocalRecording ?? _invokeStartLocalRecording;
|
||||
_startLocalRecording =
|
||||
startLocalRecording ?? _invokeStartLocalRecording {
|
||||
if (iosCallAudioSession == null && _isIos) {
|
||||
_activationListener = this;
|
||||
_iosChannel.setMethodCallHandler(_onIosAudioSessionCall);
|
||||
}
|
||||
}
|
||||
|
||||
static const MethodChannel _iosChannel = MethodChannel(
|
||||
'com.eatvioapp/call_audio_session',
|
||||
@@ -34,6 +40,11 @@ class VideoCallAudioSessionCoordinator {
|
||||
final IosCallAudioSessionHandler _iosCallAudioSession;
|
||||
final IosAdmHandler _stopLocalRecording;
|
||||
final IosAdmHandler _startLocalRecording;
|
||||
static VideoCallAudioSessionCoordinator? _activationListener;
|
||||
|
||||
/// Called when CallKit activates the audio session, including the second
|
||||
/// activation from `setCallConnected`.
|
||||
Future<void> Function()? onIosAudioSessionActivated;
|
||||
|
||||
static final AudioSessionConfiguration callConfiguration =
|
||||
AudioSessionConfiguration(
|
||||
@@ -64,12 +75,34 @@ class VideoCallAudioSessionCoordinator {
|
||||
return _iosChannel.invokeMethod<void>(method);
|
||||
}
|
||||
|
||||
static Future<void> _invokeStopLocalRecording() {
|
||||
return _webrtcChannel.invokeMethod<void>('stopLocalRecording');
|
||||
static Future<void> _onIosAudioSessionCall(MethodCall call) async {
|
||||
if (call.method != 'audioSessionActivated') return;
|
||||
await _activationListener?.onIosAudioSessionActivated?.call();
|
||||
}
|
||||
|
||||
static Future<void> _invokeStartLocalRecording() {
|
||||
return _webrtcChannel.invokeMethod<void>('startLocalRecording');
|
||||
static Future<void> _ensureWebRtcInitialized() async {
|
||||
try {
|
||||
await _webrtcChannel.invokeMethod<void>(
|
||||
'initialize',
|
||||
<String, dynamic>{'options': <String, dynamic>{}},
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<void> _invokeStopLocalRecording() async {
|
||||
await _ensureWebRtcInitialized();
|
||||
await _webrtcChannel.invokeMethod<void>(
|
||||
'stopLocalRecording',
|
||||
<String, dynamic>{},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _invokeStartLocalRecording() async {
|
||||
await _ensureWebRtcInitialized();
|
||||
await _webrtcChannel.invokeMethod<void>(
|
||||
'startLocalRecording',
|
||||
<String, dynamic>{},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
@@ -84,7 +117,7 @@ class VideoCallAudioSessionCoordinator {
|
||||
await session.configure(callConfiguration);
|
||||
_configured = true;
|
||||
}
|
||||
await session.setActive(true);
|
||||
await session.setActive(true).timeout(const Duration(seconds: 5));
|
||||
}
|
||||
|
||||
Future<bool> reactivate() async {
|
||||
@@ -116,14 +149,11 @@ class VideoCallAudioSessionCoordinator {
|
||||
}
|
||||
|
||||
/// flutter-webrtc 1.2 uses AudioEngine ADM. `isAudioEnabled` does not start
|
||||
/// that engine. Incoming CallKit therefore needs `initAndStartRecording`
|
||||
/// after the microphone track exists and CallKit has activated the session.
|
||||
/// that engine. `initAndStartRecording` has to run after the microphone
|
||||
/// track exists. Do not stop first: that tears the capture graph down and
|
||||
/// the published track stays silent.
|
||||
Future<void> startCallKitCapture() async {
|
||||
if (!_isIos) return;
|
||||
try {
|
||||
await _stopLocalRecording();
|
||||
} catch (_) {}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 120));
|
||||
try {
|
||||
await _startLocalRecording();
|
||||
_iosActive = true;
|
||||
@@ -133,6 +163,9 @@ class VideoCallAudioSessionCoordinator {
|
||||
Future<void> stop() async {
|
||||
if (_isIos) {
|
||||
_iosActive = false;
|
||||
try {
|
||||
await _stopLocalRecording();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _iosCallAudioSession('deactivate');
|
||||
} catch (_) {}
|
||||
|
||||
@@ -37,6 +37,7 @@ class VideoCallCallKitCoordinator {
|
||||
final Map<String, CallKitParams> _callKitParamsById = {};
|
||||
final Set<String> _acceptNavigationCompletedIds = {};
|
||||
final Map<String, Future<void>> _acceptDeliveriesInFlight = {};
|
||||
final Map<String, Future<void>> _acceptDeliveriesByConversation = {};
|
||||
final Set<String> _suppressedEndCallKitIds = {};
|
||||
String? _activeCallKitId;
|
||||
bool _initialized = false;
|
||||
@@ -107,12 +108,19 @@ class VideoCallCallKitCoordinator {
|
||||
}
|
||||
|
||||
Future<void> _onAndroidCallPushMethodCall(MethodCall call) async {
|
||||
if (call.method == 'onCallKitLaunchIntent') {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native',
|
||||
name: 'VideoCallCallKit',
|
||||
);
|
||||
await _handleAndroidCallKitLaunchIntent(call.arguments);
|
||||
switch (call.method) {
|
||||
case 'onCallKitLaunchIntent':
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native',
|
||||
name: 'VideoCallCallKit',
|
||||
);
|
||||
await _handleAndroidCallKitLaunchIntent(call.arguments);
|
||||
case 'onCallKitDecline':
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: received onCallKitDecline from native',
|
||||
name: 'VideoCallCallKit',
|
||||
);
|
||||
await _handleAndroidCallKitDecline(call.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +234,21 @@ class VideoCallCallKitCoordinator {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleAndroidCallKitDecline(Object? arguments) async {
|
||||
final data = _stringKeyedMap(arguments);
|
||||
final extra = _stringKeyedMap(data['extra']);
|
||||
final callKitId =
|
||||
_parseString(data, const ['id', 'uuid']) ??
|
||||
_parseString(extra, const ['conversationUuid', 'conversation_uuid']);
|
||||
if (callKitId == null) return;
|
||||
|
||||
try {
|
||||
await _androidCallPushChannel.invokeMethod<Object?>('drainPendingCallKitDecline');
|
||||
} catch (_) {}
|
||||
|
||||
await _handleDecline(callKitId, eventExtra: extra);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
final observer = _androidResumeObserver;
|
||||
if (observer != null) {
|
||||
@@ -493,8 +516,9 @@ class VideoCallCallKitCoordinator {
|
||||
return;
|
||||
}
|
||||
|
||||
final callId = _parseCallId(extra);
|
||||
await _deliverIncomingAccept(
|
||||
callKitId: conversationUuid,
|
||||
callKitId: callId ?? conversationUuid,
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: _parseString(extra, const [
|
||||
'counterpartName',
|
||||
@@ -672,6 +696,13 @@ class VideoCallCallKitCoordinator {
|
||||
name: 'VideoCallCallKit',
|
||||
);
|
||||
|
||||
final conversationKey = conversationUuid.trim().toLowerCase();
|
||||
final conversationDelivery = _acceptDeliveriesByConversation[conversationKey];
|
||||
if (conversationDelivery != null) {
|
||||
await conversationDelivery;
|
||||
return;
|
||||
}
|
||||
|
||||
_rememberPendingIncomingAccept(
|
||||
callKitId: callKitId,
|
||||
conversationUuid: conversationUuid,
|
||||
@@ -698,12 +729,16 @@ class VideoCallCallKitCoordinator {
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
);
|
||||
_acceptDeliveriesInFlight[callKitId] = delivery;
|
||||
_acceptDeliveriesByConversation[conversationKey] = delivery;
|
||||
try {
|
||||
await delivery;
|
||||
} finally {
|
||||
if (identical(_acceptDeliveriesInFlight[callKitId], delivery)) {
|
||||
_acceptDeliveriesInFlight.remove(callKitId);
|
||||
}
|
||||
if (identical(_acceptDeliveriesByConversation[conversationKey], delivery)) {
|
||||
_acceptDeliveriesByConversation.remove(conversationKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,7 +763,15 @@ class VideoCallCallKitCoordinator {
|
||||
_manager.notifyIncomingCallAccepted(conversationUuid, callId: callId),
|
||||
);
|
||||
|
||||
// Android accept starts an ongoing-call service that holds the mic.
|
||||
// Joining LiveKit while that service is still up never leaves the
|
||||
// loading screen when the app was already in the foreground.
|
||||
_suppressCallEnd(callKitId);
|
||||
_suppressCallEnd(conversationUuid);
|
||||
if (callId != null) _suppressCallEnd(callId);
|
||||
|
||||
try {
|
||||
await _finishCallKitAfterAccept(callKitId);
|
||||
await _waitForAcceptNavigationReady();
|
||||
|
||||
final navigated = await _openConnectingScreenAfterAccept(
|
||||
@@ -764,8 +807,6 @@ class VideoCallCallKitCoordinator {
|
||||
);
|
||||
unawaited(_drainPendingIncomingAcceptWithRetries());
|
||||
}
|
||||
|
||||
await _finishCallKitAfterAccept(callKitId);
|
||||
} catch (error, stackTrace) {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: accept delivery failed, callKitId=$callKitId: $error',
|
||||
@@ -777,9 +818,14 @@ class VideoCallCallKitCoordinator {
|
||||
// must be ignored briefly. iOS keeps CallKit for the whole call: a
|
||||
// later ENDED is a real hang-up and must not be swallowed.
|
||||
if (Platform.isAndroid) {
|
||||
final suppressedIds = <String>{
|
||||
callKitId,
|
||||
conversationUuid,
|
||||
if (callId != null) callId,
|
||||
};
|
||||
unawaited(
|
||||
Future<void>.delayed(const Duration(seconds: 2), () {
|
||||
_suppressedEndCallKitIds.remove(callKitId);
|
||||
_suppressedEndCallKitIds.removeAll(suppressedIds);
|
||||
_acceptNavigationCompletedIds.remove(callKitId);
|
||||
}),
|
||||
);
|
||||
@@ -815,6 +861,12 @@ class VideoCallCallKitCoordinator {
|
||||
return;
|
||||
}
|
||||
|
||||
_suppressCallEnd(pending.callKitId);
|
||||
_suppressCallEnd(pending.conversationUuid);
|
||||
try {
|
||||
await _finishCallKitAfterAccept(pending.callKitId);
|
||||
} catch (_) {}
|
||||
|
||||
await _waitForAcceptNavigationReady();
|
||||
await _settleUiAfterResume();
|
||||
|
||||
@@ -837,9 +889,6 @@ class VideoCallCallKitCoordinator {
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
await _finishCallKitAfterAccept(pending.callKitId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// After answer: Android dismisses the incoming notification. iOS keeps the
|
||||
@@ -852,8 +901,32 @@ class VideoCallCallKitCoordinator {
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
_suppressedEndCallKitIds.add(callKitId);
|
||||
_suppressCallEnd(callKitId);
|
||||
await FlutterCallkitIncoming.endCall(callKitId);
|
||||
if (!Platform.isAndroid) return;
|
||||
await _waitUntilAndroidCallKitReleased();
|
||||
}
|
||||
|
||||
/// Telecom keeps the microphone while the incoming connection is active.
|
||||
/// Joining LiveKit before it is gone never leaves the loading screen when
|
||||
/// the app was already in the foreground.
|
||||
Future<void> _waitUntilAndroidCallKitReleased() async {
|
||||
final deadline = DateTime.now().add(const Duration(milliseconds: 1500));
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
try {
|
||||
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||
if (calls.isEmpty) return;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
|
||||
void _suppressCallEnd(String id) {
|
||||
final trimmed = id.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
_suppressedEndCallKitIds.add(trimmed);
|
||||
}
|
||||
|
||||
Future<void> _settleUiAfterResume() async {
|
||||
@@ -918,8 +991,11 @@ class VideoCallCallKitCoordinator {
|
||||
extra,
|
||||
handle: params?.handle ?? callKitId,
|
||||
);
|
||||
final callId =
|
||||
_parseCallId(extra) ?? (callKitId.contains('-') ? callKitId : null);
|
||||
final callId = backendVideoCallId(
|
||||
callId:
|
||||
_parseCallId(extra) ?? (callKitId.contains('-') ? callKitId : null),
|
||||
conversationUuid: conversationUuid,
|
||||
);
|
||||
final remoteEndDismissal =
|
||||
callKitDeclineIsEndedPush(
|
||||
cachedExtra: params?.extra,
|
||||
|
||||
@@ -112,9 +112,10 @@ abstract final class VideoCallEndedPushHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
// When call_id is known, never fall back to conversation matching:
|
||||
// a stale ended event must not close a newer ring in the same chat.
|
||||
return false;
|
||||
// A ring that already has its own call id must not be closed by a
|
||||
// stale ended event for another call in the same chat. A legacy ring
|
||||
// whose id is only the conversation still has to close.
|
||||
if (_callHasDistinctCallId(call)) return false;
|
||||
}
|
||||
|
||||
final uuid = conversationUuid?.trim().toLowerCase();
|
||||
@@ -122,6 +123,18 @@ abstract final class VideoCallEndedPushHandler {
|
||||
return callMatchesConversation(call, uuid);
|
||||
}
|
||||
|
||||
/// True when this ring already carries a server call id. Conversation
|
||||
/// fallback would close a newer call in the same chat.
|
||||
static bool _callHasDistinctCallId(CallKitParams call) {
|
||||
final extra = call.extra;
|
||||
if (extra == null) return false;
|
||||
for (final key in const ['call_id', 'callId']) {
|
||||
final value = extra[key];
|
||||
if (value is String && value.trim().isNotEmpty) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool callMatchesConversation(
|
||||
CallKitParams call,
|
||||
String conversationUuid,
|
||||
|
||||
@@ -18,15 +18,14 @@ bool shouldForceEndCallAfterPeerLeft({
|
||||
return !videoCallRoomStillHasRemoteUser(remoteIdentities);
|
||||
}
|
||||
|
||||
/// The participant who taps hang up must not report the end while the other
|
||||
/// person is still in the room. That report and the remaining client's report
|
||||
/// both see an empty room and each one rings the other user again.
|
||||
/// Hang up reports the end the same way the web client does after it leaves
|
||||
/// the room. The server then sends the ended chat message and push when no
|
||||
/// other person is left in the room, which drops the call on the other devices.
|
||||
bool shouldNotifyCallEnded({
|
||||
required bool alreadyNotified,
|
||||
required bool hasConversation,
|
||||
required bool peerStillInRoom,
|
||||
}) {
|
||||
if (!hasConversation || alreadyNotified || peerStillInRoom) return false;
|
||||
if (!hasConversation || alreadyNotified) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -91,3 +90,44 @@ bool shouldForceEndCallAfterRoomDisconnect({
|
||||
return reason == lk.DisconnectReason.participantRemoved ||
|
||||
reason == lk.DisconnectReason.roomDeleted;
|
||||
}
|
||||
|
||||
/// Starting an incoming call must not run [endCall]. That dismisses the
|
||||
/// CallKit call which just activated the microphone, and an empty
|
||||
/// conversation id dismisses every active CallKit call.
|
||||
bool shouldPreserveCallKitWhenStartingCall({
|
||||
required String previousConversationUuid,
|
||||
required String nextConversationUuid,
|
||||
}) {
|
||||
final previous = previousConversationUuid.trim();
|
||||
final next = nextConversationUuid.trim();
|
||||
if (previous.isEmpty) return true;
|
||||
return previous == next;
|
||||
}
|
||||
|
||||
/// `POST /decline` and `POST /ended` treat `call_id` as the session uuid.
|
||||
/// The conversation uuid is a different id; sending it makes the request 404
|
||||
/// and the other devices never hear about the hang-up.
|
||||
String? backendVideoCallId({
|
||||
required String? callId,
|
||||
required String conversationUuid,
|
||||
}) {
|
||||
final id = callId?.trim() ?? '';
|
||||
if (id.isEmpty) return null;
|
||||
if (id == conversationUuid.trim()) return null;
|
||||
return id;
|
||||
}
|
||||
|
||||
/// [startCall] emits an empty idle snapshot while it replaces the room.
|
||||
/// A real failure (lost link or a connect error) still has to reach the
|
||||
/// visible call screen, otherwise the loading screen never leaves.
|
||||
bool isTransientIdleSnapshotWhileCallScreenVisible({
|
||||
required bool callScreenVisible,
|
||||
required String conversationUuid,
|
||||
required bool needsManualReconnect,
|
||||
required String? connectError,
|
||||
}) {
|
||||
if (!callScreenVisible) return false;
|
||||
if (needsManualReconnect) return false;
|
||||
if (connectError != null && connectError.trim().isNotEmpty) return false;
|
||||
return conversationUuid.trim().isEmpty;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
|
||||
|
||||
/// `video-call-declined` / `video-call-ended` on `private-new-chat-messages`.
|
||||
///
|
||||
/// The web client closes every other device from these events. A decline does
|
||||
/// not create a chat message, so the open app has to read the socket itself.
|
||||
class VideoCallSocketSignal {
|
||||
const VideoCallSocketSignal({
|
||||
required this.endsActiveCall,
|
||||
this.conversationUuid,
|
||||
this.callId,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
final bool endsActiveCall;
|
||||
final String? conversationUuid;
|
||||
final String? callId;
|
||||
final VideoCallEndReason? reason;
|
||||
|
||||
static VideoCallSocketSignal? tryParseDeclined(Object? raw) {
|
||||
return _tryParse(raw, declined: true);
|
||||
}
|
||||
|
||||
static VideoCallSocketSignal? tryParseEnded(Object? raw) {
|
||||
return _tryParse(raw, declined: false);
|
||||
}
|
||||
|
||||
static VideoCallSocketSignal? _tryParse(
|
||||
Object? raw, {
|
||||
required bool declined,
|
||||
}) {
|
||||
final data = _asMap(raw);
|
||||
if (data == null) return null;
|
||||
|
||||
final conversationUuid = _string(data, const [
|
||||
'conversation_uuid',
|
||||
'conversationUuid',
|
||||
]);
|
||||
final callId = _string(data, const ['call_id', 'callId']);
|
||||
if ((conversationUuid == null || conversationUuid.isEmpty) &&
|
||||
(callId == null || callId.isEmpty)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final reason = declined
|
||||
? VideoCallEndReason.declined
|
||||
: VideoCallEndReason.tryParse(data['reason'] ?? data['status']);
|
||||
final endsActiveCall = declined || reason != VideoCallEndReason.accepted;
|
||||
|
||||
return VideoCallSocketSignal(
|
||||
endsActiveCall: endsActiveCall,
|
||||
conversationUuid: conversationUuid,
|
||||
callId: callId,
|
||||
reason: reason,
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(Object? raw) {
|
||||
if (raw is Map) {
|
||||
return raw.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
if (raw is! String || raw.trim().isEmpty) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map) {
|
||||
return decoded.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _string(Map<String, dynamic> data, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = data[key];
|
||||
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.64+153
|
||||
version: 1.3.65+154
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
|
||||
23
test/core/device/device_hw_id_provider_test.dart
Normal file
23
test/core/device/device_hw_id_provider_test.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('android install id stays the same for this install', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
|
||||
final first = await DeviceHwIdProvider.androidInstallId(
|
||||
preferences: preferences,
|
||||
);
|
||||
final second = await DeviceHwIdProvider.androidInstallId(
|
||||
preferences: preferences,
|
||||
);
|
||||
|
||||
expect(first.length, greaterThanOrEqualTo(8));
|
||||
expect(second, first);
|
||||
expect(first.contains('-'), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -1027,6 +1027,42 @@ void main() {
|
||||
await reconnectController.close();
|
||||
},
|
||||
);
|
||||
|
||||
test('video-call-declined closes the call on this device', () async {
|
||||
final declined = StreamController<ChannelReadEvent>.broadcast();
|
||||
when(mockWsManager.newMessagesStream).thenReturn(null);
|
||||
when(mockWsManager.reconnected).thenAnswer((_) => const Stream.empty());
|
||||
when(
|
||||
mockWsManager.videoCallDeclinedStream,
|
||||
).thenAnswer((_) => declined.stream);
|
||||
when(mockWsManager.videoCallEndedStream).thenReturn(null);
|
||||
|
||||
final dismissed = <String?>[];
|
||||
final ended = <String?>[];
|
||||
chatsCubit.dismissIncomingVideoCall = (uuid) async {
|
||||
dismissed.add(uuid);
|
||||
};
|
||||
chatsCubit.endActiveVideoCall = ({conversationUuid, callId}) async {
|
||||
ended.add(conversationUuid);
|
||||
expect(callId, 'call-1');
|
||||
};
|
||||
|
||||
await chatsCubit.connectToWebSocket();
|
||||
declined.add(
|
||||
_FakeChannelReadEvent(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'call_id': 'call-1',
|
||||
'conversation_uuid': 'conv-1',
|
||||
'status': 'declined',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(dismissed, ['conv-1']);
|
||||
expect(ended, ['conv-1']);
|
||||
await declined.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
@@ -28,7 +29,7 @@ void main() {
|
||||
expect(calls, ['activate', 'activate', 'restart', 'deactivate']);
|
||||
});
|
||||
|
||||
test('startCallKitCapture restarts the AudioEngine ADM', () async {
|
||||
test('startCallKitCapture starts the AudioEngine ADM without stopping', () async {
|
||||
final adm = <String>[];
|
||||
final coordinator = VideoCallAudioSessionCoordinator(
|
||||
isIos: true,
|
||||
@@ -40,7 +41,45 @@ void main() {
|
||||
await coordinator.startCallKitCapture();
|
||||
|
||||
expect(coordinator.isActive, isTrue);
|
||||
expect(adm, ['stop', 'start']);
|
||||
expect(adm, ['start']);
|
||||
});
|
||||
|
||||
test('stop stops the AudioEngine ADM', () async {
|
||||
final adm = <String>[];
|
||||
final coordinator = VideoCallAudioSessionCoordinator(
|
||||
isIos: true,
|
||||
iosCallAudioSession: (_) async {},
|
||||
stopLocalRecording: () async => adm.add('stop'),
|
||||
startLocalRecording: () async => adm.add('start'),
|
||||
);
|
||||
|
||||
await coordinator.startCallKitCapture();
|
||||
await coordinator.stop();
|
||||
|
||||
expect(coordinator.isActive, isFalse);
|
||||
expect(adm, ['start', 'stop']);
|
||||
});
|
||||
|
||||
test('forwards a later CallKit audio activation', () async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final coordinator = VideoCallAudioSessionCoordinator(isIos: true);
|
||||
var activations = 0;
|
||||
coordinator.onIosAudioSessionActivated = () async {
|
||||
activations++;
|
||||
};
|
||||
|
||||
const codec = StandardMethodCodec();
|
||||
final data = codec.encodeMethodCall(
|
||||
const MethodCall('audioSessionActivated'),
|
||||
);
|
||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
'com.eatvioapp/call_audio_session',
|
||||
data,
|
||||
(_) {},
|
||||
);
|
||||
|
||||
expect(activations, 1);
|
||||
});
|
||||
|
||||
test('stop clears the active session when deactivate fails', () async {
|
||||
|
||||
@@ -75,6 +75,23 @@ void main() {
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('a legacy ring without call_id still matches the conversation', () {
|
||||
const call = CallKitParams(
|
||||
id: conversation,
|
||||
handle: conversation,
|
||||
extra: <String, dynamic>{'conversationUuid': conversation},
|
||||
);
|
||||
|
||||
expect(
|
||||
VideoCallEndedPushHandler.callMatches(
|
||||
call: call,
|
||||
conversationUuid: conversation,
|
||||
callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () {
|
||||
|
||||
@@ -66,23 +66,11 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('does not report the end while the other user is still in the room', () {
|
||||
test('reports the end so the other devices drop the call', () {
|
||||
expect(
|
||||
shouldNotifyCallEnded(
|
||||
alreadyNotified: false,
|
||||
hasConversation: true,
|
||||
peerStillInRoom: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('reports the end when this client is the last participant', () {
|
||||
expect(
|
||||
shouldNotifyCallEnded(
|
||||
alreadyNotified: false,
|
||||
hasConversation: true,
|
||||
peerStillInRoom: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
@@ -90,7 +78,13 @@ void main() {
|
||||
shouldNotifyCallEnded(
|
||||
alreadyNotified: true,
|
||||
hasConversation: true,
|
||||
peerStillInRoom: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
shouldNotifyCallEnded(
|
||||
alreadyNotified: false,
|
||||
hasConversation: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
@@ -118,6 +112,78 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('an incoming start does not tear down the accepted CallKit call', () {
|
||||
expect(
|
||||
shouldPreserveCallKitWhenStartingCall(
|
||||
previousConversationUuid: '',
|
||||
nextConversationUuid: 'conv-1',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldPreserveCallKitWhenStartingCall(
|
||||
previousConversationUuid: 'conv-1',
|
||||
nextConversationUuid: 'conv-1',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldPreserveCallKitWhenStartingCall(
|
||||
previousConversationUuid: 'conv-old',
|
||||
nextConversationUuid: 'conv-1',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('the conversation uuid is not sent as the session call id', () {
|
||||
expect(
|
||||
backendVideoCallId(callId: 'conv-1', conversationUuid: 'conv-1'),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
backendVideoCallId(
|
||||
callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
conversationUuid: 'conv-1',
|
||||
),
|
||||
'0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
|
||||
);
|
||||
expect(
|
||||
backendVideoCallId(callId: ' ', conversationUuid: 'conv-1'),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('a lost connection on the visible call screen is not ignored', () {
|
||||
expect(
|
||||
isTransientIdleSnapshotWhileCallScreenVisible(
|
||||
callScreenVisible: true,
|
||||
conversationUuid: '',
|
||||
needsManualReconnect: false,
|
||||
connectError: null,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isTransientIdleSnapshotWhileCallScreenVisible(
|
||||
callScreenVisible: true,
|
||||
conversationUuid: 'conv-1',
|
||||
needsManualReconnect: true,
|
||||
connectError: 'timeout',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
isTransientIdleSnapshotWhileCallScreenVisible(
|
||||
callScreenVisible: false,
|
||||
conversationUuid: '',
|
||||
needsManualReconnect: false,
|
||||
connectError: null,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('a token is not requested again right after leaving', () {
|
||||
final leftAt = DateTime(2026, 10, 16, 12);
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_socket_signal.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('decline closes the caller call and the other ringing devices', () {
|
||||
final signal = VideoCallSocketSignal.tryParseDeclined(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'call_id': 'call-1',
|
||||
'conversation_uuid': 'conv-1',
|
||||
'status': 'declined',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(signal, isNotNull);
|
||||
expect(signal!.endsActiveCall, isTrue);
|
||||
expect(signal.conversationUuid, 'conv-1');
|
||||
expect(signal.callId, 'call-1');
|
||||
expect(signal.reason, VideoCallEndReason.declined);
|
||||
});
|
||||
|
||||
test('ended closes an active call', () {
|
||||
final signal = VideoCallSocketSignal.tryParseEnded(<String, dynamic>{
|
||||
'conversation_uuid': 'conv-1',
|
||||
'reason': 'cancelled',
|
||||
});
|
||||
|
||||
expect(signal?.endsActiveCall, isTrue);
|
||||
expect(signal?.reason, VideoCallEndReason.cancelled);
|
||||
});
|
||||
|
||||
test('an accept marker does not tear down the call this device joined', () {
|
||||
final signal = VideoCallSocketSignal.tryParseEnded(<String, dynamic>{
|
||||
'call_id': 'call-1',
|
||||
'reason': 'accepted',
|
||||
});
|
||||
|
||||
expect(signal?.endsActiveCall, isFalse);
|
||||
});
|
||||
|
||||
test('ignores a payload without a call or a conversation', () {
|
||||
expect(
|
||||
VideoCallSocketSignal.tryParseDeclined(<String, dynamic>{'status': 'declined'}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user