diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 5d78ff99..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 @@ -1036,6 +1035,9 @@ import flutter_foreground_task finish() } + if !endAfterReport { + prepareCallAudioCategory() + } if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance { plugin.showCallkitIncoming(data, fromPushKit: true, completion: afterReport) return @@ -1053,6 +1055,9 @@ import flutter_foreground_task finish() return } + if !endAfterReport { + prepareCallAudioCategory() + } voipCallReporter.report( uuid: uuid, callerName: data.nameCaller, @@ -1071,8 +1076,13 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { - resumeAudioAfterCallKitEnds = true - prepareCallAudioCategory() + // 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 + // Category only. RTCAudioSession.setConfiguration here races CallKit's + // activation and can start I/O before didActivate. + prepareAvAudioSessionCategory() storePendingAcceptedCall(call) recordVoipDiagnostic( "callkit_accept_native", @@ -1113,10 +1123,10 @@ import flutter_foreground_task let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true rtcSession.audioSessionDidActivate(audioSession) - applyCallAudioConfiguration(active: false) - // Do not start the audio unit here. Incoming accept has no LiveKit - // microphone track yet; enabling the unit attaches capture to nothing, - // and a later `isAudioEnabled = true` is then a no-op. + // 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) { @@ -1144,35 +1154,62 @@ import flutter_foreground_task RTCAudioSessionConfiguration.setWebRTC(configuration) } + private func prepareAvAudioSessionCategory() { + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory( + .playAndRecord, + mode: .videoChat, + options: [.allowBluetooth, .defaultToSpeaker] + ) + } 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) - // Starting the audio unit while the configuration lock is held deadlocks - // the capture thread, so the mic stays silent. + if callKitOwnsAudioSession { + rtcSession.isAudioEnabled = true + return + } + applyCallAudioConfiguration(active: true) rtcSession.isAudioEnabled = true } - /// Restarts capture after the previous audio graph has disconnected. - /// Toggling the unit in the same turn makes voice processing throw. + /// 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 + if callKitOwnsAudioSession { + rtcSession.isAudioEnabled = true + return + } + 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.activateInAppCallAudioSession() + self.enableInAppCallAudioUnit() } } + private func enableInAppCallAudioUnit() { + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.useManualAudio = true + if !callKitOwnsAudioSession { + applyCallAudioConfiguration(active: true) + } + rtcSession.isAudioEnabled = true + } + private func applyCallAudioConfiguration(active: Bool) { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.lockForConfiguration() 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 353276e3..84090d9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.61+150 +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);