From 1901248539bdccf1d2d22e8531ce8001c3db1de6 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 15 Oct 2026 14:06:37 +0300 Subject: [PATCH] feat(video_calls): ios mic fix, android call fix. feat(dev_tools): base url caching --- android/app/build.gradle | 1 + .../com/eatvioapp/app/CallKitIntentBridge.kt | 4 + .../app/IncomingCallRingtonePolicy.kt | 15 ++ .../com/eatvioapp/app/VideoCallPushBridge.kt | 194 ++++++++++++++---- .../app/IncomingCallRingtonePolicyTest.kt | 45 ++++ ios/Runner/AppDelegate.swift | 34 ++- lib/core/app_config.dart | 50 ++++- lib/core/cached_base_url.dart | 16 ++ lib/core/locator_service.dart | 7 +- .../push_navigation_handler.dart | 6 +- lib/core/prefs_keys.dart | 9 +- .../bloc/chats_cubit/chats_cubit.dart | 3 + .../managers/video_call_manager.dart | 60 +++++- .../managers/video_call_manager_snapshot.dart | 4 + .../video_call_session_cubit.dart | 31 +++ .../video_call_audio_session_coordinator.dart | 11 +- .../video_call_ended_push_handler.dart | 19 +- .../video_call_ended_push_payload.dart | 18 +- .../services/video_call_fcm_handler.dart | 4 +- .../video_call_notification_navigator.dart | 11 + .../services/video_call_peer_departure.dart | 29 +++ pubspec.yaml | 2 +- test/core/app_config_test.dart | 81 +++++++- test/core/enums/prefs_keys_test.dart | 67 +++--- test/core/prefs_keys_test.dart | 88 ++++---- .../video_call_session_cubit_test.dart | 30 +++ ...o_call_audio_session_coordinator_test.dart | 5 +- .../video_call_peer_departure_test.dart | 79 +++++++ .../video_call_push_payload_test.dart | 1 + 29 files changed, 777 insertions(+), 147 deletions(-) create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/IncomingCallRingtonePolicy.kt create mode 100644 android/app/src/test/kotlin/com/eatvioapp/app/IncomingCallRingtonePolicyTest.kt create mode 100644 lib/core/cached_base_url.dart create mode 100644 lib/features/video_calls/services/video_call_peer_departure.dart create mode 100644 test/features/video_calls/services/video_call_peer_departure_test.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index ff3634f9..11428239 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -69,6 +69,7 @@ android { } dependencies { + testImplementation 'junit:junit:4.13.2' implementation platform('com.google.firebase:firebase-bom:34.4.0') implementation 'com.google.firebase:firebase-messaging' implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300' diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt index e433b4fc..070462bc 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt @@ -8,6 +8,10 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver object CallKitIntentBridge { fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) { + if (IncomingCallRingtonePolicy.actionStopsRingtone(action)) { + VideoCallPushBridge.silenceIncomingCall(context) + } + if (data != null) { CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload -> PendingCallKitAcceptBridge.store(payload) diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/IncomingCallRingtonePolicy.kt b/android/app/src/main/kotlin/com/eatvioapp/app/IncomingCallRingtonePolicy.kt new file mode 100644 index 00000000..10e330a7 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/IncomingCallRingtonePolicy.kt @@ -0,0 +1,15 @@ +package com.eatvioapp.app + +/** + * Call actions that must stop the incoming ringtone. + * Accept is included: answering the call is not the same as ending it, + * but the ringing player is a separate object from the in-call audio. + */ +internal object IncomingCallRingtonePolicy { + fun actionStopsRingtone(action: String): Boolean { + return action.contains("ACTION_CALL_ACCEPT") || + action.contains("ACTION_CALL_DECLINE") || + action.contains("ACTION_CALL_ENDED") || + action.contains("ACTION_CALL_TIMEOUT") + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index 251ec22f..563c245a 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -8,6 +8,7 @@ import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.CallkitEventCallback import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager import com.hiennv.flutter_callkit_incoming.CallkitNotificationService import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager @@ -17,14 +18,36 @@ import com.hiennv.flutter_callkit_incoming.addCall import com.hiennv.flutter_callkit_incoming.getDataActiveCalls import com.hiennv.flutter_callkit_incoming.removeAllCalls import java.util.HashMap +import java.util.concurrent.atomic.AtomicInteger object VideoCallPushBridge { private const val TAG = "VideoCallPushBridge" private const val ANDROID_ACCENT_COLOR = "#2787F5" private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L + private val mainHandler = Handler(Looper.getMainLooper()) + private val playbackLock = Any() + private val playbackGeneration = AtomicInteger(0) + private var activeSoundManager: CallkitSoundPlayerManager? = null private var activeNotificationManager: CallkitNotificationManager? = null + private var activeCallBundle: Bundle? = null + private var storedAppContext: Context? = null + private var ringtoneTimeout: Runnable? = null + private var callbackRegistered = false + + /** + * The plugin stops only the sound player owned by [FlutterCallkitIncomingPlugin]. + * Incoming pushes start a different player, so accept/decline never reached it + * and the ringtone kept looping until the process died. + */ + private val incomingCallEventCallback = object : CallkitEventCallback { + override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) { + val context = storedAppContext ?: return + Log.i(TAG, "incoming call event=$event, stopping ringtone") + silenceIncomingCall(context) + } + } fun isIncomingVideoCall(data: Map): Boolean { val rawType = data["type"] @@ -89,18 +112,114 @@ object VideoCallPushBridge { fun showIncomingCall(context: Context, data: Map) { val appContext = context.applicationContext + storedAppContext = appContext + ensureIncomingCallCallbackRegistered() + val callData = buildCallDataBundle(data) if (callData == null) { Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data") return } + val generation = playbackGeneration.incrementAndGet() Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}") + // Ringtone.play() and stop() must run on the same thread. FCM delivers + // pushes on a background thread, while Answer runs on the main thread. + mainHandler.post { + synchronized(playbackLock) { + if (generation != playbackGeneration.get()) { + Log.i(TAG, "showIncomingCall: skipped stale playback generation=$generation") + return@post + } + startIncomingCallLocked(appContext, callData, generation) + } + } + } + + /** + * Stops the ringtone that [showIncomingCall] started and removes the incoming + * notification. Safe to call more than once. + */ + fun silenceIncomingCall(context: Context) { + val appContext = context.applicationContext + storedAppContext = appContext + Log.i(TAG, "silenceIncomingCall") + playbackGeneration.incrementAndGet() + val stop = Runnable { + synchronized(playbackLock) { + stopPlaybackLocked(appContext) + } + } + if (Looper.myLooper() == Looper.getMainLooper()) { + stop.run() + } else { + mainHandler.post(stop) + } + } + + fun dismissAllIncomingCalls(context: Context) { + val appContext = context.applicationContext + val calls = getDataActiveCalls(appContext) + Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}") + playbackGeneration.incrementAndGet() + + val dismiss = Runnable { + synchronized(playbackLock) { + val notificationManager = resolveNotificationManager(appContext) + val bundles = calls.map { it.toBundle() } + stopPlaybackLocked(appContext) + + for (bundle in bundles) { + try { + dismissIncomingCallNotification(appContext, notificationManager, bundle) + } catch (error: Exception) { + Log.w( + TAG, + "dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}", + error, + ) + } + } + + try { + CallkitNotificationService.stopService(appContext) + } catch (error: Exception) { + Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error) + } + + try { + removeAllCalls(appContext) + } catch (error: Exception) { + Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error) + } + + stopIncomingRingtone() + scheduleMissedCallCleanup(notificationManager, bundles) + } + } + + if (Looper.myLooper() == Looper.getMainLooper()) { + dismiss.run() + } else { + mainHandler.post(dismiss) + } + } + + private fun ensureIncomingCallCallbackRegistered() { + if (callbackRegistered) return + FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback) + callbackRegistered = true + } + + private fun startIncomingCallLocked(appContext: Context, callData: Bundle, generation: Int) { + stopPlaybackLocked(appContext) + val soundManager = CallkitSoundPlayerManager(appContext) val notificationManager = CallkitNotificationManager(appContext, soundManager) activeSoundManager = soundManager activeNotificationManager = notificationManager + activeCallBundle = callData notificationManager.createNotificationChanel(callData) notificationManager.showIncomingNotification(callData) @@ -109,46 +228,41 @@ object VideoCallPushBridge { } catch (error: Exception) { Log.w(TAG, "showIncomingCall: failed to persist active call", error) } + + val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L) + if (durationMs > 0L) { + val timeout = Runnable { + synchronized(playbackLock) { + if (generation != playbackGeneration.get()) return@Runnable + Log.i(TAG, "incoming ringtone timed out") + stopPlaybackLocked(appContext) + } + } + ringtoneTimeout = timeout + mainHandler.postDelayed(timeout, durationMs) + } } - fun dismissAllIncomingCalls(context: Context) { - val appContext = context.applicationContext - val calls = getDataActiveCalls(appContext) - Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}") - - stopIncomingRingtone(appContext) - - val notificationManager = resolveNotificationManager(appContext) - val bundles = calls.map { it.toBundle() } - - for (bundle in bundles) { + private fun stopPlaybackLocked(appContext: Context) { + cancelRingtoneTimeoutLocked() + val notificationManager = activeNotificationManager + val bundle = activeCallBundle + stopIncomingRingtone() + if (notificationManager != null && bundle != null) { try { dismissIncomingCallNotification(appContext, notificationManager, bundle) } catch (error: Exception) { - Log.w( - TAG, - "dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}", - error, - ) + Log.w(TAG, "stopPlayback: failed to dismiss incoming notification", error) } } - - try { - CallkitNotificationService.stopService(appContext) - } catch (error: Exception) { - Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error) - } - - try { - removeAllCalls(appContext) - } catch (error: Exception) { - Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error) - } - - stopIncomingRingtone(appContext) - scheduleMissedCallCleanup(notificationManager, bundles) activeSoundManager = null activeNotificationManager = null + activeCallBundle = null + } + + private fun cancelRingtoneTimeoutLocked() { + ringtoneTimeout?.let { mainHandler.removeCallbacks(it) } + ringtoneTimeout = null } private fun resolveNotificationManager(context: Context): CallkitNotificationManager { @@ -161,11 +275,19 @@ object VideoCallPushBridge { return CallkitNotificationManager(context, soundManager) } - private fun stopIncomingRingtone(context: Context) { - activeSoundManager?.stop() - FlutterCallkitIncomingPlugin.getInstance() - ?.getCallkitSoundPlayerManager() - ?.stop() + private fun stopIncomingRingtone() { + try { + activeSoundManager?.stop() + } catch (error: Exception) { + Log.w(TAG, "stopIncomingRingtone: active player", error) + } + try { + FlutterCallkitIncomingPlugin.getInstance() + ?.getCallkitSoundPlayerManager() + ?.stop() + } catch (error: Exception) { + Log.w(TAG, "stopIncomingRingtone: plugin player", error) + } } /** diff --git a/android/app/src/test/kotlin/com/eatvioapp/app/IncomingCallRingtonePolicyTest.kt b/android/app/src/test/kotlin/com/eatvioapp/app/IncomingCallRingtonePolicyTest.kt new file mode 100644 index 00000000..16f066da --- /dev/null +++ b/android/app/src/test/kotlin/com/eatvioapp/app/IncomingCallRingtonePolicyTest.kt @@ -0,0 +1,45 @@ +package com.eatvioapp.app + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class IncomingCallRingtonePolicyTest { + @Test + fun answerDeclineEndAndTimeoutStopTheRingtone() { + assertTrue( + IncomingCallRingtonePolicy.actionStopsRingtone( + "com.hiennv.flutter_callkit_incoming.ACTION_CALL_ACCEPT", + ), + ) + assertTrue( + IncomingCallRingtonePolicy.actionStopsRingtone( + "com.hiennv.flutter_callkit_incoming.ACTION_CALL_DECLINE", + ), + ) + assertTrue( + IncomingCallRingtonePolicy.actionStopsRingtone( + "com.hiennv.flutter_callkit_incoming.ACTION_CALL_ENDED", + ), + ) + assertTrue( + IncomingCallRingtonePolicy.actionStopsRingtone( + "com.hiennv.flutter_callkit_incoming.ACTION_CALL_TIMEOUT", + ), + ) + } + + @Test + fun incomingAndStartActionsKeepTheRingtonePlaying() { + assertFalse( + IncomingCallRingtonePolicy.actionStopsRingtone( + "com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING", + ), + ) + assertFalse( + IncomingCallRingtonePolicy.actionStopsRingtone( + "com.hiennv.flutter_callkit_incoming.ACTION_CALL_START", + ), + ) + } +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 1863355a..93039753 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -42,6 +42,10 @@ import flutter_foreground_task let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions) + // WebRTC must not activate the session itself. CallKit and the in-app + // call both own AVAudioSession, and a second activation drops the mic. + RTCAudioSession.sharedInstance().useManualAudio = true + if let controller = window?.rootViewController as? FlutterViewController { let channel = FlutterMethodChannel( name: voipDiagnosticsChannelName, @@ -76,6 +80,9 @@ import flutter_foreground_task case "activate": self.activateInAppCallAudioSession() result(nil) + case "restart": + self.restartInAppCallAudioUnit() + result(nil) case "deactivate": self.deactivateInAppCallAudioSession() result(nil) @@ -574,6 +581,7 @@ import flutter_foreground_task func didActivateAudioSession(_ audioSession: AVAudioSession) { let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.useManualAudio = true rtcSession.audioSessionDidActivate(audioSession) rtcSession.isAudioEnabled = true } @@ -581,10 +589,13 @@ import flutter_foreground_task func didDeactivateAudioSession(_ audioSession: AVAudioSession) { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.audioSessionDidDeactivate(audioSession) + // The audio unit is stopped, but isAudioEnabled stays true. A later + // assignment of true is then a no-op and the microphone never restarts. + rtcSession.isAudioEnabled = false guard resumeAudioAfterCallKitEnds else { return } DispatchQueue.main.async { [weak self] in guard let self, self.resumeAudioAfterCallKitEnds else { return } - self.activateInAppCallAudioSession() + self.restartInAppCallAudioUnit() } } @@ -606,14 +617,26 @@ import flutter_foreground_task private func activateInAppCallAudioSession() { let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.useManualAudio = true rtcSession.lockForConfiguration() - defer { rtcSession.unlockForConfiguration() } do { try rtcSession.setConfiguration(callAudioConfiguration(), active: true) - rtcSession.isAudioEnabled = true } catch { NSLog("[EatvioVoIP] activate call audio session failed: \(error)") } + rtcSession.unlockForConfiguration() + // Starting the audio unit while the configuration lock is held deadlocks + // the capture thread, so the mic stays silent. + rtcSession.isAudioEnabled = true + } + + /// Cycles the audio unit after the microphone track exists. Enabling audio + /// before the track is created leaves capture attached to nothing. + private func restartInAppCallAudioUnit() { + activateInAppCallAudioSession() + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.isAudioEnabled = false + rtcSession.isAudioEnabled = true } private func deactivateInAppCallAudioSession() { @@ -636,7 +659,10 @@ import flutter_foreground_task .allowBluetooth, .defaultToSpeaker, ] - configuration.mode = AVAudioSession.Mode.voiceChat.rawValue + // videoChat is the speakerphone mode for a video call. voiceChat keeps + // the input on the receiver, so the mic is silent once audio goes to + // the loudspeaker. + configuration.mode = AVAudioSession.Mode.videoChat.rawValue return configuration } } diff --git a/lib/core/app_config.dart b/lib/core/app_config.dart index d410565f..289d3d7b 100644 --- a/lib/core/app_config.dart +++ b/lib/core/app_config.dart @@ -1,8 +1,12 @@ +import 'dart:async'; + import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart'; import 'package:eatvio_app/core/env_config.dart'; import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/prefs.dart'; +import 'package:eatvio_app/core/prefs_keys.dart'; import 'package:eatvio_app/core/views/cubit/views_cubit.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart'; import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; @@ -31,20 +35,50 @@ class AppConfig { void setBaseUrl(String baseUrls) { _baseUrl = baseUrls; + _persistBaseUrl(baseUrls); + } + + void _persistBaseUrl(String baseUrl) { + if (!sl.isRegistered()) { + return; + } + final normalized = baseUrl.trim(); + if (normalized.isEmpty) { + unawaited(sl().remove(PrefsKeys.customBaseUrl)); + return; + } + unawaited(sl().setString(PrefsKeys.customBaseUrl, normalized)); } /// Список глобальных BlocProvider'ов /// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения List get globalBlocProviders => [ - BlocProvider.value(value: sl()), - BlocProvider(lazy: true, create: (context) => sl()), - BlocProvider(lazy: true, create: (context) => sl()), + BlocProvider.value( + value: sl(), + ), + BlocProvider( + lazy: true, + create: (context) => sl(), + ), + BlocProvider( + lazy: true, + create: (context) => sl(), + ), BlocProvider(lazy: true, create: (context) => sl()), - BlocProvider(lazy: true, create: (context) => sl()), + BlocProvider( + lazy: true, + create: (context) => sl(), + ), BlocProvider(lazy: true, create: (context) => sl()), BlocProvider(lazy: true, create: (context) => sl()), - BlocProvider(lazy: false, create: (context) => sl()), - BlocProvider(lazy: true, create: (context) => sl()), + BlocProvider( + lazy: false, + create: (context) => sl(), + ), + BlocProvider( + lazy: true, + create: (context) => sl(), + ), ]; /// Делегаты локализации @@ -56,5 +90,7 @@ class AppConfig { ]; /// Поддерживаемые локали - List get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')]; + List get supportedLocales => [ + const Locale.fromSubtags(languageCode: 'ru'), + ]; } diff --git a/lib/core/cached_base_url.dart b/lib/core/cached_base_url.dart new file mode 100644 index 00000000..d6e82e73 --- /dev/null +++ b/lib/core/cached_base_url.dart @@ -0,0 +1,16 @@ +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/prefs.dart'; +import 'package:eatvio_app/core/prefs_keys.dart'; + +/// Подставляет сохранённый base URL до создания HTTP-клиента. +void applyCachedBaseUrl() { + if (!sl.isRegistered() || !sl.isRegistered()) { + return; + } + final cached = sl().getString(PrefsKeys.customBaseUrl)?.trim(); + if (cached == null || cached.isEmpty) { + return; + } + sl().setBaseUrl(cached); +} diff --git a/lib/core/locator_service.dart b/lib/core/locator_service.dart index 6f06c668..cb2e63cc 100644 --- a/lib/core/locator_service.dart +++ b/lib/core/locator_service.dart @@ -1,4 +1,5 @@ import 'package:eatvio_app/core/app_initializer.dart'; +import 'package:eatvio_app/core/cached_base_url.dart'; import 'package:eatvio_app/core/di/app_config_registration.dart'; import 'package:eatvio_app/core/di/cubit_registrations.dart'; import 'package:eatvio_app/core/di/external_registrations.dart'; @@ -21,6 +22,8 @@ Future init({required AppBuildType buildType}) async { // Ожидание инициализации асинхронных зависимостей await sl.getAsync(); + // До создания Dio и любых запросов к бэкенду. + applyCachedBaseUrl(); // Регистрация сервисов ServiceRegistrations.registerAll(); @@ -32,5 +35,7 @@ Future init({required AppBuildType buildType}) async { CubitRegistrations.registerAll(); // Регистрация WebSocket менеджера - sl.registerLazySingleton(() => WsManager(wsService: sl(), errorReporter: sl())); + sl.registerLazySingleton( + () => WsManager(wsService: sl(), errorReporter: sl()), + ); } diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index 22191b85..b6481d6d 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -65,7 +65,11 @@ class PushNavigationHandler { 'PushNavigationHandler: dismiss incoming call UI, source=$source', name: 'PushNavigationHandler', ); - unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi()); + unawaited( + VideoCallEndedPushHandler.dismissIncomingCallUi( + conversationUuid: endedPayload.conversationUuid, + ), + ); return; } diff --git a/lib/core/prefs_keys.dart b/lib/core/prefs_keys.dart index 2d64cae6..df74fa84 100644 --- a/lib/core/prefs_keys.dart +++ b/lib/core/prefs_keys.dart @@ -1 +1,8 @@ -enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped } +enum PrefsKeys { + hasAnonymousTokenBeenSent, + welcomeScreenWasShowed, + registrationDate, + marketingBottomSheetHasBeenShown, + isEmailVerificationSkipped, + customBaseUrl, +} diff --git a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart index 50414998..4cd6c9ce 100644 --- a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart +++ b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart @@ -231,6 +231,9 @@ class ChatsCubit extends Cubit { final kind = VideoCallChatSignal.kindOf(message); if (kind == VideoCallChatSignalKind.ended) { unawaited(_dismissIncomingVideoCallUi()); + unawaited( + VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId), + ); return; } if (isOwn || kind != VideoCallChatSignalKind.started) return; diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 11920783..5f10d7af 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/device/device_hw_id_provider.dart'; @@ -8,6 +9,7 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snap import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_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_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_task_handler.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; @@ -37,6 +39,12 @@ class VideoCallManager { _audioSession = audioSessionCoordinator ?? VideoCallAudioSessionCoordinator() { FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData); + if (Platform.isIOS) { + // LiveKit rewrites the audio session whenever a remote audio track + // appears. That second configuration stops the microphone and does + // not start it again. + lk.Hardware.instance.setAutomaticConfigurationEnabled(enable: false); + } } final VideoCallRepository _videoCallRepository; @@ -46,7 +54,10 @@ class VideoCallManager { final VideoCallAudioSessionCoordinator _audioSession; static const lk.AudioCaptureOptions _micCaptureOptions = - lk.AudioCaptureOptions(stopAudioCaptureOnMute: false); + lk.AudioCaptureOptions( + stopAudioCaptureOnMute: false, + voiceIsolation: false, + ); static const Duration _mutedMicPollInterval = Duration(milliseconds: 320); static const Duration _mutedMicOverlayMinInterval = Duration( milliseconds: 2600, @@ -65,6 +76,7 @@ class VideoCallManager { num? _lastMutedMicTotalEnergy; bool _liveKitDisposed = true; bool _callEndedNotified = true; + bool _forceEndRequested = false; final Set _callAcceptNotifiedConversationUuids = {}; bool _callStageEntered = false; bool _joinInProgress = false; @@ -86,6 +98,8 @@ class VideoCallManager { String? get counterpartName => _snapshot.counterpartName; String? get localParticipantName => _snapshot.localParticipantName; + bool get callEndedNotified => _callEndedNotified; + bool get isActiveCall => _snapshot.connectPhase == VideoCallConnectPhase.connected && _callStageEntered && @@ -207,6 +221,7 @@ class VideoCallManager { _resetSessionFlagsForNewRoom(); _callEndedNotified = false; + _forceEndRequested = false; _liveKitDisposed = false; _callStageEntered = false; _room = lk.Room( @@ -391,10 +406,13 @@ class VideoCallManager { Future endCall() async { final uuid = _snapshot.conversationUuid; + final shouldNotifyBackend = !_callEndedNotified && uuid.isNotEmpty; + if (uuid.isNotEmpty) { + _callEndedNotified = true; + } _disposeRoomEphemerals(); await _disconnectLiveKitOnly(); - if (!_callEndedNotified && uuid.isNotEmpty) { - _callEndedNotified = true; + if (shouldNotifyBackend) { try { await _videoCallRepository.notifyCallEnded(uuid); } catch (_) {} @@ -477,6 +495,9 @@ class VideoCallManager { next, audioCaptureOptions: _micCaptureOptions, ); + if (next) { + await _audioSession.restartCapture(); + } } catch (_) { return; } @@ -674,6 +695,7 @@ class VideoCallManager { true, audioCaptureOptions: _micCaptureOptions, ); + await _audioSession.restartCapture(); } catch (_) {} } await _resumeVideoAfterForeground(); @@ -814,6 +836,12 @@ class VideoCallManager { } } + void _requestForceEnd() { + if (_forceEndRequested || _liveKitDisposed) return; + _forceEndRequested = true; + _emitSnapshot(_snapshot.copyWith(forceEndCall: true)); + } + void _attachRoomEventListeners() { final room = _room; if (room == null) return; @@ -825,11 +853,34 @@ class VideoCallManager { if (_snapshot.linkIssue.needsManualReconnect) return; _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.none)); }) - ..on((_) { + ..on((event) { if (_liveKitDisposed) return; if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; + if (shouldForceEndCallAfterRoomDisconnect( + callStageEntered: _callStageEntered, + sessionConnected: true, + reason: event.reason, + )) { + _requestForceEnd(); + return; + } _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost)); }) + ..on((_) { + final room = _room; + if (room == null) return; + if (!shouldForceEndCallAfterPeerLeft( + callStageEntered: _callStageEntered, + sessionConnected: + _snapshot.connectPhase == VideoCallConnectPhase.connected, + remoteIdentities: room.remoteParticipants.values.map( + (participant) => participant.identity, + ), + )) { + return; + } + _requestForceEnd(); + }) ..on((e) { if (e.participant != room.localParticipant) return; _syncPoorConnectionOverlay(e.connectionQuality); @@ -1089,6 +1140,7 @@ class VideoCallManager { true, audioCaptureOptions: _micCaptureOptions, ); + await _audioSession.restartCapture(); } catch (_) { effectiveMic = false; } diff --git a/lib/features/video_calls/managers/video_call_manager_snapshot.dart b/lib/features/video_calls/managers/video_call_manager_snapshot.dart index 86a16070..4c8a33bd 100644 --- a/lib/features/video_calls/managers/video_call_manager_snapshot.dart +++ b/lib/features/video_calls/managers/video_call_manager_snapshot.dart @@ -11,6 +11,7 @@ class VideoCallManagerSnapshot { required this.connectPhase, required this.linkIssue, this.counterpartPresentInLobby = false, + this.forceEndCall = false, }); final String conversationUuid; @@ -22,6 +23,7 @@ class VideoCallManagerSnapshot { final VideoCallConnectPhase connectPhase; final VideoCallLinkIssue linkIssue; final bool counterpartPresentInLobby; + final bool forceEndCall; static const initial = VideoCallManagerSnapshot( conversationUuid: '', @@ -41,6 +43,7 @@ class VideoCallManagerSnapshot { VideoCallConnectPhase? connectPhase, VideoCallLinkIssue? linkIssue, bool? counterpartPresentInLobby, + bool? forceEndCall, bool clearConnectError = false, }) { return VideoCallManagerSnapshot( @@ -54,6 +57,7 @@ class VideoCallManagerSnapshot { linkIssue: linkIssue ?? this.linkIssue, counterpartPresentInLobby: counterpartPresentInLobby ?? this.counterpartPresentInLobby, + forceEndCall: forceEndCall ?? this.forceEndCall, ); } } diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 28cc47b0..5387e500 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/core/cubit/base_cubit.dart'; 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_screen_wake_lock_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart'; import 'package:flutter/material.dart'; @@ -30,6 +31,8 @@ class VideoCallSessionCubit extends BaseCubit { final VideoCallSystemPipCoordinator _systemPipCoordinator; final VideoCallScreenWakeLockCoordinator _screenWakeLock; bool _callScreenRouteVisible = false; + bool _handlingRemoteCallEnd = false; + bool _suppressDisposeSideEffects = false; bool _autoEnterInFlight = false; bool _counterpartPresentWhenLobbyReady = false; bool _sawEmptyLobbyWhenConnected = false; @@ -74,6 +77,10 @@ class VideoCallSessionCubit extends BaseCubit { void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) { if (isClosed) return; + if (snapshot.forceEndCall) { + unawaited(handleRemoteCallEnded()); + return; + } if (_isCallSessionInactive(snapshot)) { unawaited(_systemPipCoordinator.disarm()); if (_callScreenRouteVisible) { @@ -377,8 +384,32 @@ class VideoCallSessionCubit extends BaseCubit { } } + /// The other participant hung up or was removed from the room. + Future handleRemoteCallEnded() async { + if (_handlingRemoteCallEnd || isClosed) return; + if (_videoCallManager.callEndedNotified) return; + final inCall = + state.callStageEntered || + state.connectPhase == VideoCallConnectPhase.connected; + if (!inCall) return; + + _handlingRemoteCallEnd = true; + _suppressDisposeSideEffects = true; + try { + await endCall(); + if (isClosed) return; + emit(const VideoCallSessionState()); + await _restoreSystemUiAfterCall(); + VideoCallNotificationNavigator.closeCallRouteIfOpen(); + } finally { + _suppressDisposeSideEffects = false; + _handlingRemoteCallEnd = false; + } + } + void onCallScreenDisposed() { _callScreenRouteVisible = false; + if (_suppressDisposeSideEffects) return; if (state.uiMode != VideoCallUiMode.fullscreen) { if (state.uiMode == VideoCallUiMode.pip && state.callStageEntered) { // [isCallScreenRouteVisible] is not in bloc state; refresh the global diff --git a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart index 9297f696..e08bb8e8 100644 --- a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart +++ b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart @@ -31,7 +31,7 @@ class VideoCallAudioSessionCoordinator { avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.allowBluetooth | AVAudioSessionCategoryOptions.defaultToSpeaker, - avAudioSessionMode: AVAudioSessionMode.voiceChat, + avAudioSessionMode: AVAudioSessionMode.videoChat, avAudioSessionRouteSharingPolicy: AVAudioSessionRouteSharingPolicy.defaultPolicy, avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, @@ -88,6 +88,15 @@ class VideoCallAudioSessionCoordinator { } } + /// Restarts capture after the microphone track has been created. + Future restartCapture() async { + if (!_isIos) return; + try { + await _iosCallAudioSession('restart'); + _iosActive = true; + } catch (_) {} + } + Future stop() async { if (_isIos) { _iosActive = false; diff --git a/lib/features/video_calls/services/video_call_ended_push_handler.dart b/lib/features/video_calls/services/video_call_ended_push_handler.dart index 5ff7ec6e..f7ed9f98 100644 --- a/lib/features/video_calls/services/video_call_ended_push_handler.dart +++ b/lib/features/video_calls/services/video_call_ended_push_handler.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:flutter/services.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; @@ -9,7 +11,10 @@ abstract final class VideoCallEndedPushHandler { 'com.eatvioapp/video_call_push', ); - static Future dismissIncomingCallUi() async { + static Future dismissIncomingCallUi({ + String? conversationUuid, + }) async { + await endActiveCallIfMatches(conversationUuid); if (Platform.isAndroid) { try { await _androidPushChannel.invokeMethod('dismissIncomingCalls'); @@ -20,6 +25,18 @@ abstract final class VideoCallEndedPushHandler { await _dismissViaCallKit(); } + static Future endActiveCallIfMatches(String? conversationUuid) async { + final uuid = conversationUuid?.trim(); + if (uuid == null || uuid.isEmpty) return; + if (!sl.isRegistered()) return; + + final cubit = sl(); + if (cubit.state.conversationUuid != uuid) return; + try { + await cubit.handleRemoteCallEnded(); + } catch (_) {} + } + static Future _dismissViaCallKit() async { try { final activeCalls = await FlutterCallkitIncoming.activeCalls(); diff --git a/lib/features/video_calls/services/video_call_ended_push_payload.dart b/lib/features/video_calls/services/video_call_ended_push_payload.dart index 4defe8fe..4d65a705 100644 --- a/lib/features/video_calls/services/video_call_ended_push_payload.dart +++ b/lib/features/video_calls/services/video_call_ended_push_payload.dart @@ -1,11 +1,23 @@ import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; -/// Marker for a data-only `video_call_ended` push (no conversation uuid required). +/// Marker for a data-only `video_call_ended` push. class VideoCallEndedPushPayload { - const VideoCallEndedPushPayload._(); + const VideoCallEndedPushPayload({this.conversationUuid}); + + final String? conversationUuid; static VideoCallEndedPushPayload? tryParse(Map data) { if (!VideoCallPushPayload.isEndedVideoCall(data)) return null; - return const VideoCallEndedPushPayload._(); + final rawUuid = + data['conversationUuid'] ?? + data['conversation_uuid'] ?? + data['uuid']; + final conversationUuid = rawUuid?.toString().trim(); + return VideoCallEndedPushPayload( + conversationUuid: + conversationUuid == null || conversationUuid.isEmpty + ? null + : conversationUuid, + ); } } diff --git a/lib/features/video_calls/services/video_call_fcm_handler.dart b/lib/features/video_calls/services/video_call_fcm_handler.dart index 941da2f9..1b11f693 100644 --- a/lib/features/video_calls/services/video_call_fcm_handler.dart +++ b/lib/features/video_calls/services/video_call_fcm_handler.dart @@ -62,7 +62,9 @@ abstract final class VideoCallFcmHandler { name: 'VideoCallFcm', ); try { - await VideoCallEndedPushHandler.dismissIncomingCallUi(); + await VideoCallEndedPushHandler.dismissIncomingCallUi( + conversationUuid: endedPayload.conversationUuid, + ); } catch (e, st) { log( 'VideoCallFcmHandler: failed to dismiss incoming call UI: $e', diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 8462baf0..2ec1ce0b 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -74,6 +74,17 @@ class VideoCallNotificationNavigator { return isOnCallRoute(); } + static void closeCallRouteIfOpen() { + try { + if (!isOnCallRoute()) return; + final context = AppRouter.navigatorKey.currentContext; + if (context == null || !context.mounted) return; + if (Navigator.canPop(context)) { + Navigator.pop(context); + } + } catch (_) {} + } + static bool isOnCallRoute() { final matches = AppRouter.appRouter.routerDelegate.currentConfiguration.matches; diff --git a/lib/features/video_calls/services/video_call_peer_departure.dart b/lib/features/video_calls/services/video_call_peer_departure.dart new file mode 100644 index 00000000..102d9043 --- /dev/null +++ b/lib/features/video_calls/services/video_call_peer_departure.dart @@ -0,0 +1,29 @@ +import 'package:livekit_client/livekit_client.dart' as lk; + +/// Real call participants use the `user-{id}` identity. Egress and other +/// bots must not keep the call alive after the other person leaves. +bool videoCallRoomStillHasRemoteUser(Iterable remoteIdentities) { + for (final identity in remoteIdentities) { + if (identity.startsWith('user-')) return true; + } + return false; +} + +bool shouldForceEndCallAfterPeerLeft({ + required bool callStageEntered, + required bool sessionConnected, + required Iterable remoteIdentities, +}) { + if (!callStageEntered || !sessionConnected) return false; + return !videoCallRoomStillHasRemoteUser(remoteIdentities); +} + +bool shouldForceEndCallAfterRoomDisconnect({ + required bool callStageEntered, + required bool sessionConnected, + required lk.DisconnectReason? reason, +}) { + if (!callStageEntered || !sessionConnected) return false; + return reason == lk.DisconnectReason.participantRemoved || + reason == lk.DisconnectReason.roomDeleted; +} diff --git a/pubspec.yaml b/pubspec.yaml index c3102218..7d4e564d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.50+139 +version: 1.3.51+140 publish_to: 'none' environment: diff --git a/test/core/app_config_test.dart b/test/core/app_config_test.dart index 0bcfc864..9f72563d 100644 --- a/test/core/app_config_test.dart +++ b/test/core/app_config_test.dart @@ -1,19 +1,27 @@ +import 'package:dio/dio.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; +import 'package:eatvio_app/core/cached_base_url.dart'; import 'package:eatvio_app/core/env_config.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/prefs.dart'; +import 'package:eatvio_app/core/prefs_keys.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -class _MockVideoCallSessionCubit extends Mock implements VideoCallSessionCubit {} +class _MockVideoCallSessionCubit extends Mock + implements VideoCallSessionCubit {} void main() { setUpAll(() async { // Initialize dotenv for tests - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { @@ -90,6 +98,75 @@ void main() { expect(devConfig.baseUrl, testConfig.baseUrl); }); + test('setBaseUrl saves override to prefs', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = Prefs(await SharedPreferences.getInstance()); + sl.registerSingleton(prefs); + final config = AppConfig(buildType: AppBuildType.prod); + + config.setBaseUrl('https://custom.example'); + await Future.delayed(Duration.zero); + + expect( + prefs.getString(PrefsKeys.customBaseUrl), + 'https://custom.example', + ); + }); + + test('setBaseUrl removes cached override when url is blank', () async { + SharedPreferences.setMockInitialValues({ + 'customBaseUrl': 'https://custom.example', + }); + final prefs = Prefs(await SharedPreferences.getInstance()); + sl.registerSingleton(prefs); + final config = AppConfig(buildType: AppBuildType.prod); + + config.setBaseUrl(' '); + await Future.delayed(Duration.zero); + + expect(prefs.getString(PrefsKeys.customBaseUrl), isNull); + expect(config.baseUrl, ' '); + }); + + test( + 'applyCachedBaseUrl replaces env base url before dio is created', + () async { + SharedPreferences.setMockInitialValues({ + 'customBaseUrl': 'https://cached.example', + }); + sl.registerLazySingleton( + () => AppConfig(buildType: AppBuildType.prod), + ); + sl.registerSingleton( + Prefs(await SharedPreferences.getInstance()), + ); + sl.registerLazySingleton( + () => Dio(BaseOptions(baseUrl: sl().baseUrl)), + ); + + applyCachedBaseUrl(); + + expect(sl().baseUrl, 'https://cached.example'); + expect(sl().options.baseUrl, 'https://cached.example'); + }, + ); + + test('applyCachedBaseUrl keeps env base url when cache is empty', () async { + SharedPreferences.setMockInitialValues({}); + sl.registerLazySingleton( + () => AppConfig(buildType: AppBuildType.prod), + ); + sl.registerSingleton(Prefs(await SharedPreferences.getInstance())); + sl.registerLazySingleton( + () => Dio(BaseOptions(baseUrl: sl().baseUrl)), + ); + + applyCachedBaseUrl(); + + expect(sl().baseUrl, EnvConfig.baseUrl); + expect(sl().options.baseUrl, EnvConfig.baseUrl); + }); + test('setBaseUrl can be called multiple times', () { final config = AppConfig(buildType: AppBuildType.prod); const url1 = 'https://url1.com'; diff --git a/test/core/enums/prefs_keys_test.dart b/test/core/enums/prefs_keys_test.dart index 8e4afe40..fe2f15cc 100644 --- a/test/core/enums/prefs_keys_test.dart +++ b/test/core/enums/prefs_keys_test.dart @@ -4,57 +4,46 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('PrefsKeys', () { test('has all expected values', () { - expect(PrefsKeys.values.length, 5); + expect(PrefsKeys.values.length, 6); expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent)); expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed)); expect(PrefsKeys.values, contains(PrefsKeys.registrationDate)); - expect(PrefsKeys.values, contains(PrefsKeys.marketingBottomSheetHasBeenShown)); + expect( + PrefsKeys.values, + contains(PrefsKeys.marketingBottomSheetHasBeenShown), + ); expect(PrefsKeys.values, contains(PrefsKeys.isEmailVerificationSkipped)); + expect(PrefsKeys.values, contains(PrefsKeys.customBaseUrl)); }); test('can be compared', () { - expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.hasAnonymousTokenBeenSent, true); - expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed, false); + expect( + PrefsKeys.hasAnonymousTokenBeenSent == + PrefsKeys.hasAnonymousTokenBeenSent, + true, + ); + expect( + PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed, + false, + ); }); test('has correct name property', () { - expect(PrefsKeys.hasAnonymousTokenBeenSent.name, 'hasAnonymousTokenBeenSent'); + expect( + PrefsKeys.hasAnonymousTokenBeenSent.name, + 'hasAnonymousTokenBeenSent', + ); expect(PrefsKeys.welcomeScreenWasShowed.name, 'welcomeScreenWasShowed'); expect(PrefsKeys.registrationDate.name, 'registrationDate'); - expect(PrefsKeys.marketingBottomSheetHasBeenShown.name, 'marketingBottomSheetHasBeenShown'); - expect(PrefsKeys.isEmailVerificationSkipped.name, 'isEmailVerificationSkipped'); + expect( + PrefsKeys.marketingBottomSheetHasBeenShown.name, + 'marketingBottomSheetHasBeenShown', + ); + expect( + PrefsKeys.isEmailVerificationSkipped.name, + 'isEmailVerificationSkipped', + ); + expect(PrefsKeys.customBaseUrl.name, 'customBaseUrl'); }); }); } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/core/prefs_keys_test.dart b/test/core/prefs_keys_test.dart index c56ee2c0..98785895 100644 --- a/test/core/prefs_keys_test.dart +++ b/test/core/prefs_keys_test.dart @@ -4,25 +4,46 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('PrefsKeys', () { test('все значения enum определены', () { - expect(PrefsKeys.values.length, 5); + expect(PrefsKeys.values.length, 6); expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent)); expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed)); expect(PrefsKeys.values, contains(PrefsKeys.registrationDate)); - expect(PrefsKeys.values, contains(PrefsKeys.marketingBottomSheetHasBeenShown)); + expect( + PrefsKeys.values, + contains(PrefsKeys.marketingBottomSheetHasBeenShown), + ); expect(PrefsKeys.values, contains(PrefsKeys.isEmailVerificationSkipped)); + expect(PrefsKeys.values, contains(PrefsKeys.customBaseUrl)); }); test('имена ключей корректны', () { - expect(PrefsKeys.hasAnonymousTokenBeenSent.name, 'hasAnonymousTokenBeenSent'); + expect( + PrefsKeys.hasAnonymousTokenBeenSent.name, + 'hasAnonymousTokenBeenSent', + ); expect(PrefsKeys.welcomeScreenWasShowed.name, 'welcomeScreenWasShowed'); expect(PrefsKeys.registrationDate.name, 'registrationDate'); - expect(PrefsKeys.marketingBottomSheetHasBeenShown.name, 'marketingBottomSheetHasBeenShown'); - expect(PrefsKeys.isEmailVerificationSkipped.name, 'isEmailVerificationSkipped'); + expect( + PrefsKeys.marketingBottomSheetHasBeenShown.name, + 'marketingBottomSheetHasBeenShown', + ); + expect( + PrefsKeys.isEmailVerificationSkipped.name, + 'isEmailVerificationSkipped', + ); + expect(PrefsKeys.customBaseUrl.name, 'customBaseUrl'); }); test('can be compared', () { - expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.hasAnonymousTokenBeenSent, true); - expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed, false); + expect( + PrefsKeys.hasAnonymousTokenBeenSent == + PrefsKeys.hasAnonymousTokenBeenSent, + true, + ); + expect( + PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed, + false, + ); }); test('has correct index', () { @@ -33,43 +54,22 @@ void main() { }); test('can access by name', () { - expect(PrefsKeys.values.byName('hasAnonymousTokenBeenSent'), PrefsKeys.hasAnonymousTokenBeenSent); - expect(PrefsKeys.values.byName('welcomeScreenWasShowed'), PrefsKeys.welcomeScreenWasShowed); - expect(PrefsKeys.values.byName('registrationDate'), PrefsKeys.registrationDate); - expect(PrefsKeys.values.byName('marketingBottomSheetHasBeenShown'), PrefsKeys.marketingBottomSheetHasBeenShown); + expect( + PrefsKeys.values.byName('hasAnonymousTokenBeenSent'), + PrefsKeys.hasAnonymousTokenBeenSent, + ); + expect( + PrefsKeys.values.byName('welcomeScreenWasShowed'), + PrefsKeys.welcomeScreenWasShowed, + ); + expect( + PrefsKeys.values.byName('registrationDate'), + PrefsKeys.registrationDate, + ); + expect( + PrefsKeys.values.byName('marketingBottomSheetHasBeenShown'), + PrefsKeys.marketingBottomSheetHasBeenShown, + ); }); }); } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index 2f9e9c3b..05f074b7 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -124,6 +124,36 @@ void main() { }, ); + test('force end from the other participant closes the call', () async { + when(mockManager.callEndedNotified).thenReturn(false); + when(mockManager.endCall()).thenAnswer((_) async {}); + cubit.emit( + const VideoCallSessionState( + conversationUuid: 'uuid', + connectPhase: VideoCallConnectPhase.connected, + callStageEntered: true, + uiMode: VideoCallUiMode.fullscreen, + ), + ); + + managerListener( + const VideoCallManagerSnapshot( + conversationUuid: 'uuid', + micEnabled: true, + videoEnabled: true, + connectPhase: VideoCallConnectPhase.connected, + linkIssue: VideoCallLinkIssue.none, + forceEndCall: true, + ), + ); + await Future.delayed(Duration.zero); + + verify(mockManager.endCall()).called(1); + expect(cubit.state.connectPhase, VideoCallConnectPhase.idle); + expect(cubit.state.callStageEntered, isFalse); + expect(cubit.state.uiMode, VideoCallUiMode.hidden); + }); + test('isMicActiveForUi mirrors micEnabled', () { const state = VideoCallSessionState(micEnabled: true); expect(state.isMicActiveForUi, isTrue); diff --git a/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart b/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart index 45098fcf..71b266f6 100644 --- a/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart +++ b/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart @@ -20,9 +20,12 @@ void main() { final resumed = await coordinator.reactivate(); expect(resumed, isTrue); + await coordinator.restartCapture(); + expect(coordinator.isActive, isTrue); + await coordinator.stop(); expect(coordinator.isActive, isFalse); - expect(calls, ['activate', 'activate', 'deactivate']); + expect(calls, ['activate', 'activate', 'restart', 'deactivate']); }); test('stop clears the active session when deactivate fails', () async { diff --git a/test/features/video_calls/services/video_call_peer_departure_test.dart b/test/features/video_calls/services/video_call_peer_departure_test.dart new file mode 100644 index 00000000..7ecb1799 --- /dev/null +++ b/test/features/video_calls/services/video_call_peer_departure_test.dart @@ -0,0 +1,79 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; + +void main() { + test('ends the call when the other user leaves the room', () { + expect( + shouldForceEndCallAfterPeerLeft( + callStageEntered: true, + sessionConnected: true, + remoteIdentities: const [], + ), + isTrue, + ); + }); + + test('keeps the call when another user is still in the room', () { + expect( + shouldForceEndCallAfterPeerLeft( + callStageEntered: true, + sessionConnected: true, + remoteIdentities: const ['user-7', 'EG_recorder'], + ), + isFalse, + ); + }); + + test('ignores an egress bot after the other user has left', () { + expect( + shouldForceEndCallAfterPeerLeft( + callStageEntered: true, + sessionConnected: true, + remoteIdentities: const ['EG_recorder'], + ), + isTrue, + ); + }); + + test('does not end a call that has not entered the stage', () { + expect( + shouldForceEndCallAfterPeerLeft( + callStageEntered: false, + sessionConnected: true, + remoteIdentities: const [], + ), + isFalse, + ); + }); + + test('ends the call when the server removes this participant', () { + expect( + shouldForceEndCallAfterRoomDisconnect( + callStageEntered: true, + sessionConnected: true, + reason: lk.DisconnectReason.participantRemoved, + ), + isTrue, + ); + expect( + shouldForceEndCallAfterRoomDisconnect( + callStageEntered: true, + sessionConnected: true, + reason: lk.DisconnectReason.roomDeleted, + ), + isTrue, + ); + }); + + test('keeps a connection-loss screen when the link drops', () { + expect( + shouldForceEndCallAfterRoomDisconnect( + callStageEntered: true, + sessionConnected: true, + reason: lk.DisconnectReason.disconnected, + ), + isFalse, + ); + }); +} diff --git a/test/features/video_calls/services/video_call_push_payload_test.dart b/test/features/video_calls/services/video_call_push_payload_test.dart index 9e716cba..07ad8574 100644 --- a/test/features/video_calls/services/video_call_push_payload_test.dart +++ b/test/features/video_calls/services/video_call_push_payload_test.dart @@ -57,6 +57,7 @@ void main() { }); expect(payload, isNotNull); + expect(payload!.conversationUuid, 'abc-123'); }); test('parses message id for a stable CallKit uuid', () {