diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c82fc940..2b4e868f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -20,9 +20,8 @@ import flutter_foreground_task private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session" private let appUrlScheme = "eatvio" - /// If CallKit ends while the in-app call must continue, take the audio - /// session back after iOS releases it. Incoming accept keeps CallKit up, - /// so this is only for an unexpected teardown. + /// True only if CallKit ended while the in-app call must keep audio. + /// Incoming accept keeps CallKit up, so this stays false after answer. private var resumeAudioAfterCallKitEnds = false /// The push handler and the Flutter channel touch this map on different /// queues. The push writes the id before CallKit has the call, so the @@ -1077,11 +1076,13 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { - resumeAudioAfterCallKitEnds = true - // Dart may call activate before didActivate. Claim the session now so - // that path does not setActive or start the audio unit without a track. + // CallKit stays up for the whole incoming call. Do not take the session + // back after a later deactivate — that path is hang-up. + resumeAudioAfterCallKitEnds = false callKitOwnsAudioSession = true - prepareCallAudioCategory() + // Category only. RTCAudioSession.setConfiguration here races CallKit's + // activation and can start I/O before didActivate. + prepareAvAudioSessionCategory() storePendingAcceptedCall(call) recordVoipDiagnostic( "callkit_accept_native", @@ -1122,11 +1123,10 @@ import flutter_foreground_task let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true rtcSession.audioSessionDidActivate(audioSession) - applyCallAudioConfiguration(active: false) - // Incoming accept has no LiveKit microphone track yet. Starting the - // unit here attaches capture to nothing; a later isAudioEnabled=true - // is a no-op and the callee microphone stays silent. Dart starts the - // unit via restart after setMicrophoneEnabled. + // WebRTC must start I/O inside this CallKit callback. Enabling later + // misses the session CallKit just activated; toggling after that + // does not restart the ADM, so the incoming microphone stays silent. + rtcSession.isAudioEnabled = true } func didDeactivateAudioSession(_ audioSession: AVAudioSession) { @@ -1154,7 +1154,7 @@ import flutter_foreground_task RTCAudioSessionConfiguration.setWebRTC(configuration) } - private func prepareCallAudioCategory() { + private func prepareAvAudioSessionCategory() { let session = AVAudioSession.sharedInstance() do { try session.setCategory( @@ -1165,36 +1165,37 @@ import flutter_foreground_task } catch { NSLog("[EatvioVoIP] prepare call audio category failed: \(error)") } + } + + private func prepareCallAudioCategory() { + prepareAvAudioSessionCategory() applyCallAudioConfiguration(active: false) } private func activateInAppCallAudioSession() { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true - // CallKit already activated the session on answer. A second setActive - // fails or replaces that session, and the incoming microphone stays dead. - applyCallAudioConfiguration(active: !callKitOwnsAudioSession) if callKitOwnsAudioSession { - // The unit starts in restartInAppCallAudioUnit after the microphone - // track exists. Enabling it here is the incoming-only silent-mic bug. + rtcSession.isAudioEnabled = true return } + applyCallAudioConfiguration(active: true) rtcSession.isAudioEnabled = true } - /// Starts capture after the microphone track exists. Incoming CallKit - /// must not have started the unit earlier, or this assignment is a no-op. + /// Outgoing (no CallKit) may need a teardown to attach capture. Incoming + /// already started the unit in didActivate — toggling it here kills ADM. private func restartInAppCallAudioUnit() { - audioRestartGeneration += 1 - let generation = audioRestartGeneration let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true - let needsTeardown = rtcSession.isAudioEnabled - if needsTeardown { - rtcSession.isAudioEnabled = false + if callKitOwnsAudioSession { + rtcSession.isAudioEnabled = true + return } - let delay: TimeInterval = needsTeardown ? 0.25 : 0 - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + audioRestartGeneration += 1 + let generation = audioRestartGeneration + rtcSession.isAudioEnabled = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in guard let self, generation == self.audioRestartGeneration else { return } self.enableInAppCallAudioUnit() } @@ -1203,7 +1204,9 @@ import flutter_foreground_task private func enableInAppCallAudioUnit() { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true - applyCallAudioConfiguration(active: !callKitOwnsAudioSession) + if !callKitOwnsAudioSession { + applyCallAudioConfiguration(active: true) + } rtcSession.isAudioEnabled = true } diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index d0be4c59..91aed3a1 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1187,6 +1187,11 @@ class VideoCallManager { if (status.isGranted) return true; status = await Permission.microphone.request(); if (status.isGranted) return true; + if (Platform.isIOS) { + // CallKit already opened a recording session. A stale permission + // status must not block publishing the incoming microphone. + return true; + } final ctx2 = _navigatorContext; if (ctx2 == null || !ctx2.mounted) return false; final s = S.of(ctx2); 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 8c575bee..088c9177 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 @@ -246,7 +246,7 @@ class VideoCallSessionCubit extends BaseCubit { counterpartName: counterpartName, localParticipantName: localParticipantName, counterpartIsConsultant: counterpartIsConsultant ?? false, - micEnabled: false, + micEnabled: isIncomingAccepted, videoEnabled: false, callStageEntered: false, connectError: null, @@ -259,7 +259,7 @@ class VideoCallSessionCubit extends BaseCubit { conversationUuid: conversationUuid, counterpartName: counterpartName, localParticipantName: localParticipantName, - micEnabled: false, + micEnabled: isIncomingAccepted, videoEnabled: false, ); if (!started) { @@ -371,10 +371,11 @@ class VideoCallSessionCubit extends BaseCubit { Future _requestMicBeforeIncomingEnter() async { if (state.entryMode != VideoCallEntryMode.incomingAccepted) return; - final granted = await _requestMicPermissionForCall(); - if (!granted || isClosed) return; + await _requestMicPermissionForCall(); + if (isClosed) return; + if (state.isMicActiveForUi) return; - await _videoCallManager.toggleMic(state.isMicActiveForUi); + await _videoCallManager.toggleMic(false); } @visibleForTesting diff --git a/pubspec.yaml b/pubspec.yaml index 856e55fb..84090d9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.62+151 +version: 1.3.63+152 publish_to: 'none' environment: 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 a69cf9cd..bd3a1e39 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 @@ -257,7 +257,7 @@ void main() { conversationUuid: 'uuid', counterpartName: anyNamed('counterpartName'), localParticipantName: anyNamed('localParticipantName'), - micEnabled: false, + micEnabled: true, videoEnabled: false, ), ).called(1); @@ -357,7 +357,7 @@ void main() { conversationUuid: 'uuid', counterpartName: anyNamed('counterpartName'), localParticipantName: anyNamed('localParticipantName'), - micEnabled: false, + micEnabled: true, videoEnabled: false, ), ).called(1);