Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app

This commit is contained in:
2026-10-21 17:55:55 +03:00
5 changed files with 70 additions and 27 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
@@ -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()

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.61+150
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);