Merge pull request 'feat(video_calls): ios calle mic fix' (#1093) from misha/eatvio_app:issue-1089 into main

Reviewed-on: eatvio/eatvio_app#1093
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
This commit is contained in:
2026-10-21 14:53:27 +00:00
5 changed files with 45 additions and 36 deletions

View File

@@ -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
}

View File

@@ -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);

View File

@@ -246,7 +246,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
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<VideoCallSessionState> {
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: false,
micEnabled: isIncomingAccepted,
videoEnabled: false,
);
if (!started) {
@@ -371,10 +371,11 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
Future<void> _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

View File

@@ -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:

View File

@@ -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);