diff --git a/Linphone/Core/CorePreferences.swift b/Linphone/Core/CorePreferences.swift index a271d398a..0ce271eac 100644 --- a/Linphone/Core/CorePreferences.swift +++ b/Linphone/Core/CorePreferences.swift @@ -276,6 +276,15 @@ class CorePreferences: ObservableObject { } } + var onlyAllowEarpieceDuringCall: Bool { + get { + config.getBool(section: "ui", key: "only_allow_earpiece_during_call", defaultValue: false) + } + set { + config.setBool(section: "ui", key: "only_allow_earpiece_during_call", value: newValue) + } + } + var printLogsInLogcat: Bool { get { config.getBool(section: "app", key: "debug", defaultValue: true) diff --git a/Linphone/Localizable/ca.lproj/Localizable.strings b/Linphone/Localizable/ca.lproj/Localizable.strings index 8b1378917..e81926ee3 100644 --- a/Linphone/Localizable/ca.lproj/Localizable.strings +++ b/Linphone/Localizable/ca.lproj/Localizable.strings @@ -1 +1,2 @@ +"notification_earpiece_enforcement_message" = "Si us plau, utilitzeu només l'auricular. Les altres sortides d'àudio estan desactivades."; diff --git a/Linphone/Localizable/cs.lproj/Localizable.strings b/Linphone/Localizable/cs.lproj/Localizable.strings index 7f6df198e..6d97566ee 100644 --- a/Linphone/Localizable/cs.lproj/Localizable.strings +++ b/Linphone/Localizable/cs.lproj/Localizable.strings @@ -254,6 +254,7 @@ "new_conversation_title" = "Nová konverzace"; "next" = "Další"; "notification_missed_call_title" = "Zmeškaný hovor"; +"notification_earpiece_enforcement_message" = "Používejte pouze sluchátko. Ostatní zvukové výstupy jsou zakázány."; "operation_in_progress_overlay" = "Operace probíhá, prosím počkejte"; "or" = "nebo"; "settings_advanced_allow_outgoing_early_media_title" = "Přenášet zvuk při odchozím hovoru (early media)"; diff --git a/Linphone/Localizable/de.lproj/Localizable.strings b/Linphone/Localizable/de.lproj/Localizable.strings index 7ed904f6d..a45ebab3d 100644 --- a/Linphone/Localizable/de.lproj/Localizable.strings +++ b/Linphone/Localizable/de.lproj/Localizable.strings @@ -301,6 +301,7 @@ "new_conversation_title" = "Neuer Chat"; "next" = "Weiter"; "notification_missed_call_title" = "Verpasster Anruf"; +"notification_earpiece_enforcement_message" = "Bitte verwenden Sie nur den Hörer. Andere Audioausgaben sind deaktiviert."; "operation_in_progress_overlay" = "Vorgang wird ausgeführt, bitte warten"; "or" = "oder"; "password" = "Passwort"; diff --git a/Linphone/Localizable/en.lproj/Localizable.strings b/Linphone/Localizable/en.lproj/Localizable.strings index cbc327642..d5cecfb52 100644 --- a/Linphone/Localizable/en.lproj/Localizable.strings +++ b/Linphone/Localizable/en.lproj/Localizable.strings @@ -464,6 +464,7 @@ "notification_chat_message_reaction_received" = "%@ reacted by %@ to: %@"; "notification_chat_message_received_title" = "Message received"; "notification_missed_call_title" = "Missed call"; +"notification_earpiece_enforcement_message" = "Please use the earpiece only. Other audio outputs are disabled."; "operation_in_progress_overlay" = "Operation in progress, please wait"; "or" = "or"; "password" = "Password"; diff --git a/Linphone/Localizable/es.lproj/Localizable.strings b/Linphone/Localizable/es.lproj/Localizable.strings index f325be334..57ad67024 100644 --- a/Linphone/Localizable/es.lproj/Localizable.strings +++ b/Linphone/Localizable/es.lproj/Localizable.strings @@ -137,6 +137,7 @@ "message_delivery_info_error_title" = "Error"; "next" = "Siguiente"; "notification_missed_call_title" = "Llamada perdida"; +"notification_earpiece_enforcement_message" = "Utilice únicamente el auricular. Las demás salidas de audio están desactivadas."; "or" = "o"; "password" = "Clave"; "phone_number" = "Número de teléfono"; diff --git a/Linphone/Localizable/eu.lproj/Localizable.strings b/Linphone/Localizable/eu.lproj/Localizable.strings index 0d60592fc..c2aec29ca 100644 --- a/Linphone/Localizable/eu.lproj/Localizable.strings +++ b/Linphone/Localizable/eu.lproj/Localizable.strings @@ -14,3 +14,4 @@ "sip_address_domain" = "Domeinua"; "start" = "Hasi"; "username" = "Erabiltzaile izena"; +"notification_earpiece_enforcement_message" = "Mesedez, erabili entzungailua soilik. Beste audio-irteerak desgaituta daude."; diff --git a/Linphone/Localizable/fi.lproj/Localizable.strings b/Linphone/Localizable/fi.lproj/Localizable.strings index da83e8b33..a5f34de34 100644 --- a/Linphone/Localizable/fi.lproj/Localizable.strings +++ b/Linphone/Localizable/fi.lproj/Localizable.strings @@ -27,5 +27,6 @@ "8" = "8"; "9" = "9"; "account_settings_avpf_title" = "AVPF"; +"notification_earpiece_enforcement_message" = "Käytä vain kuuloketta. Muut äänilähdöt on poistettu käytöstä."; "ZRTP" = "ZRTP"; "[https://sip.linphone.org](https://sip.linphone.org)" = "[https://sip.linphone.org](https://sip.linphone.org)"; diff --git a/Linphone/Localizable/fr.lproj/Localizable.strings b/Linphone/Localizable/fr.lproj/Localizable.strings index 9a9ab3ea0..ac93a68df 100644 --- a/Linphone/Localizable/fr.lproj/Localizable.strings +++ b/Linphone/Localizable/fr.lproj/Localizable.strings @@ -463,6 +463,7 @@ "notification_chat_message_reaction_received" = "%@ a réagi par %@ à : %@"; "notification_chat_message_received_title" = "Message reçu"; "notification_missed_call_title" = "Appel manqué"; +"notification_earpiece_enforcement_message" = "Veuillez utiliser uniquement l'écouteur. Les autres sorties audio sont désactivées."; "operation_in_progress_overlay" = "Opération en cours, merci de patienter..."; "or" = "ou"; "password" = "Mot de passe"; diff --git a/Linphone/Localizable/hu.lproj/Localizable.strings b/Linphone/Localizable/hu.lproj/Localizable.strings index e86fccf01..101d36e55 100644 --- a/Linphone/Localizable/hu.lproj/Localizable.strings +++ b/Linphone/Localizable/hu.lproj/Localizable.strings @@ -60,6 +60,7 @@ "message_delivery_info_error_title" = "Hiba"; "next" = "Következő"; "notification_missed_call_title" = "Nem fogadott hívás"; +"notification_earpiece_enforcement_message" = "Kérjük, csak a fülhallgatót használja. A többi hangkimenet le van tiltva."; "or" = "vagy"; "password" = "Jelszó"; "phone_number" = "Telefonszám"; diff --git a/Linphone/Localizable/mk.lproj/Localizable.strings b/Linphone/Localizable/mk.lproj/Localizable.strings index 8b1378917..12f57d37c 100644 --- a/Linphone/Localizable/mk.lproj/Localizable.strings +++ b/Linphone/Localizable/mk.lproj/Localizable.strings @@ -1 +1,2 @@ +"notification_earpiece_enforcement_message" = "Ве молиме користете само слушалка. Другите аудио излези се оневозможени."; diff --git a/Linphone/Localizable/nl.lproj/Localizable.strings b/Linphone/Localizable/nl.lproj/Localizable.strings index 268bda440..2e6ac58c1 100644 --- a/Linphone/Localizable/nl.lproj/Localizable.strings +++ b/Linphone/Localizable/nl.lproj/Localizable.strings @@ -291,6 +291,7 @@ "new_conversation_title" = "Nieuw gesprek"; "next" = "Volgende"; "notification_missed_call_title" = "Gemiste oproep"; +"notification_earpiece_enforcement_message" = "Gebruik alleen de oortelefoon. Andere audio-uitgangen zijn uitgeschakeld."; "operation_in_progress_overlay" = "Bezig met bewerking, even geduld alstublieft"; "or" = "of"; "password" = "Wachtwoord"; diff --git a/Linphone/Localizable/pl.lproj/Localizable.strings b/Linphone/Localizable/pl.lproj/Localizable.strings index be4a69cd0..bf78e67fe 100644 --- a/Linphone/Localizable/pl.lproj/Localizable.strings +++ b/Linphone/Localizable/pl.lproj/Localizable.strings @@ -48,6 +48,7 @@ "menu_reply_to_chat_message" = "Odpowiedz"; "next" = "Dalej"; "notification_missed_call_title" = "Nieodebrane połączenie"; +"notification_earpiece_enforcement_message" = "Proszę używać tylko słuchawki. Inne wyjścia audio są wyłączone."; "or" = "lub"; "password" = "Hasło"; "phone_number" = "Numer telefonu"; diff --git a/Linphone/Localizable/pt-BR.lproj/Localizable.strings b/Linphone/Localizable/pt-BR.lproj/Localizable.strings index a3d449ccd..17a210bb3 100644 --- a/Linphone/Localizable/pt-BR.lproj/Localizable.strings +++ b/Linphone/Localizable/pt-BR.lproj/Localizable.strings @@ -310,6 +310,7 @@ "new_conversation_title" = "Nova conversa"; "next" = "Próximo"; "notification_missed_call_title" = "Chamada perdida"; +"notification_earpiece_enforcement_message" = "Use apenas o auricular. As outras saídas de áudio estão desativadas."; "operation_in_progress_overlay" = "Operação em andamento, por favor, aguarde"; "recordings_title" = "Gravações"; "settings_advanced_accept_early_media_title" = "Aceitar mídia antecipada"; diff --git a/Linphone/Localizable/pt.lproj/Localizable.strings b/Linphone/Localizable/pt.lproj/Localizable.strings index b6424ac9f..0920cb67a 100644 --- a/Linphone/Localizable/pt.lproj/Localizable.strings +++ b/Linphone/Localizable/pt.lproj/Localizable.strings @@ -6,3 +6,4 @@ "%lld" = "%lld"; "[https://sip.linphone.org](https://sip.linphone.org)" = "[https://sip.linphone.org](https://sip.linphone.org)"; ": %@" = ": %@"; +"notification_earpiece_enforcement_message" = "Use apenas o auricular. As outras saídas de áudio estão desativadas."; diff --git a/Linphone/Localizable/ru.lproj/Localizable.strings b/Linphone/Localizable/ru.lproj/Localizable.strings index bd4edb2a2..39e78c656 100644 --- a/Linphone/Localizable/ru.lproj/Localizable.strings +++ b/Linphone/Localizable/ru.lproj/Localizable.strings @@ -292,6 +292,7 @@ "new_conversation_title" = "Новая беседа"; "next" = "Следующий"; "notification_missed_call_title" = "Пропущенный звонок"; +"notification_earpiece_enforcement_message" = "Пожалуйста, используйте только динамик телефона. Другие аудиовыходы отключены."; "operation_in_progress_overlay" = "Операция выполняется, пожалуйста, подождите"; "or" = "или"; "password" = "Пароль"; diff --git a/Linphone/Localizable/sk.lproj/Localizable.strings b/Linphone/Localizable/sk.lproj/Localizable.strings index 43290e2e1..773f774e4 100644 --- a/Linphone/Localizable/sk.lproj/Localizable.strings +++ b/Linphone/Localizable/sk.lproj/Localizable.strings @@ -178,6 +178,7 @@ "menu_reply_to_chat_message" = "Odpovedať"; "next" = "Ďalej"; "notification_missed_call_title" = "Zmeškaný hovor"; +"notification_earpiece_enforcement_message" = "Používajte iba slúchadlo. Ostatné zvukové výstupy sú zakázané."; "or" = "alebo"; "password" = "Heslo"; "phone_number" = "Telefónne číslo"; diff --git a/Linphone/Localizable/uk.lproj/Localizable.strings b/Linphone/Localizable/uk.lproj/Localizable.strings index 0bfb10b18..ab9df7070 100644 --- a/Linphone/Localizable/uk.lproj/Localizable.strings +++ b/Linphone/Localizable/uk.lproj/Localizable.strings @@ -199,6 +199,7 @@ "new_conversation_title" = "Нова розмова"; "next" = "Наступний"; "notification_missed_call_title" = "Пропущений виклик"; +"notification_earpiece_enforcement_message" = "Будь ласка, використовуйте лише навушник. Інші аудіовиходи вимкнено."; "operation_in_progress_overlay" = "Операція триває, будь ласка, зачекайте"; "password" = "Пароль"; "phone_number" = "Номер телефону"; diff --git a/Linphone/Localizable/zh-Hans.lproj/Localizable.strings b/Linphone/Localizable/zh-Hans.lproj/Localizable.strings index b3b295824..b2b4fb913 100644 --- a/Linphone/Localizable/zh-Hans.lproj/Localizable.strings +++ b/Linphone/Localizable/zh-Hans.lproj/Localizable.strings @@ -287,6 +287,7 @@ "new_conversation_title" = "新聊天"; "next" = "下一个"; "notification_missed_call_title" = "未接来电"; +"notification_earpiece_enforcement_message" = "请仅使用听筒。其他音频输出已被禁用。"; "operation_in_progress_overlay" = "操作正在进行中,请稍候"; "or" = "或"; "password" = "密码"; diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index b06bad07c..9cdac0b7e 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -102,7 +102,7 @@ class TelecomManager: ObservableObject { Log.info("Can not start a call with null address!") return } - + if TelecomManager.callKitEnabled(core: core) {// && !nextCallIsTransfer != true { let uuid = UUID() let name = addr?.asStringUriOnly() ?? "Unknown" @@ -383,6 +383,13 @@ class TelecomManager: ObservableObject { } } + static func isAudioRouteAllowedForCall() -> Bool { + guard AppServices.corePreferences.onlyAllowEarpieceDuringCall else { return true } + let output = AVAudioSession.sharedInstance().currentRoute.outputs.first + Log.info("Current audio route output is \(output?.portType.rawValue ?? "Unknown")") + return output?.portType == .builtInReceiver + } + static func callKitEnabled(core: Core) -> Bool { #if !targetEnvironment(simulator) return core.callkitEnabled @@ -650,8 +657,12 @@ class TelecomManager: ObservableObject { do { try core.setVideodevice(newValue: "AV Capture: com.apple.avfoundation.avcapturedevice.built-in_video:1") } catch _ { - + } + + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ["linphone-earpiece-enforcement"]) + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["linphone-earpiece-enforcement"]) + withAnimation { self.outgoingCallStarted = false self.callInProgress = false @@ -726,6 +737,10 @@ class TelecomManager: ObservableObject { } case .Released: TelecomManager.setAppData(sCall: call, appData: nil) + if core.callsNb == 0 { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ["linphone-earpiece-enforcement"]) + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["linphone-earpiece-enforcement"]) + } case .Referred: referedFromCall = call.callLog?.callId default: diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 67a9c91d3..673453b67 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -2004,38 +2004,46 @@ struct CallView: View { .background(callViewModel.micMutted ? Color.redDanger500 : Color.gray500) .cornerRadius(40) - Button { - if AVAudioSession.sharedInstance().availableInputs != nil - && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - audioRouteSheet = true - } - } else { - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty ? .speaker : .none) - } catch _ { - - } - } - - } label: { - HStack { - Image(imageAudioRoute) - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - .onAppear(perform: getAudioRouteImage) - .onReceive(pub) { _ in - self.getAudioRouteImage() + if !callViewModel.hasAudioRouteRestriction { + Button { + if AVAudioSession.sharedInstance().availableInputs != nil + && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + audioRouteSheet = true } + } else { + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty ? .speaker : .none) + } catch _ { + + } + } + + } label: { + HStack { + Image(imageAudioRoute) + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } } + .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) + .frame(width: buttonSize, height: buttonSize) + .background(Color.gray500) + .cornerRadius(40) } - .buttonStyle(PressedButtonStyle(buttonSize: buttonSize)) - .frame(width: buttonSize, height: buttonSize) - .background(Color.gray500) - .cornerRadius(40) + Color.clear + .frame(width: 0, height: 0) + .onAppear { + getAudioRouteImage() + callViewModel.enforceEarpieceIfNeeded() + } + .onReceive(pub) { _ in + self.getAudioRouteImage() + callViewModel.enforceEarpieceIfNeeded() + } } .frame(height: geo.size.height * 0.15) .padding(.horizontal, 20) diff --git a/Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift b/Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift index c6a536dbd..c57e991f2 100644 --- a/Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift +++ b/Linphone/UI/Call/Fragments/AudioRouteBottomSheet.swift @@ -70,72 +70,74 @@ struct AudioRouteBottomSheet: View { }) .frame(maxHeight: .infinity) - Button(action: { - optionsAudioRoute = 2 - - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) - } catch _ { - - } - }, label: { - HStack { - Image(optionsAudioRoute == 2 ? "radio-button-fill" : "radio-button") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - - Text("call_audio_device_type_speaker") - .default_text_style_white(styleSize: 15) - - Spacer() - - Image("speaker-high") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - } - }) - .frame(maxHeight: .infinity) - - Button(action: { - optionsAudioRoute = 3 - - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) - try AVAudioSession.sharedInstance().setPreferredInput( - AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Bluetooth") }).first) - } catch _ { - - } - }, label: { - HStack { - Image(optionsAudioRoute == 3 ? "radio-button-fill" : "radio-button") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - - Text(String(format: String(localized: "call_audio_device_type_bluetooth"), - AVAudioSession.sharedInstance().currentRoute.outputs.first?.portName ?? "")) - .default_text_style_white(styleSize: 15) - - Spacer() - - Image("bluetooth") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - } - }) - .frame(maxHeight: .infinity) + if !AppServices.corePreferences.onlyAllowEarpieceDuringCall { + Button(action: { + optionsAudioRoute = 2 + + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) + } catch _ { + + } + }, label: { + HStack { + Image(optionsAudioRoute == 2 ? "radio-button-fill" : "radio-button") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + + Text("call_audio_device_type_speaker") + .default_text_style_white(styleSize: 15) + + Spacer() + + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + } + }) + .frame(maxHeight: .infinity) + + Button(action: { + optionsAudioRoute = 3 + + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) + try AVAudioSession.sharedInstance().setPreferredInput( + AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Bluetooth") }).first) + } catch _ { + + } + }, label: { + HStack { + Image(optionsAudioRoute == 3 ? "radio-button-fill" : "radio-button") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + + Text(String(format: String(localized: "call_audio_device_type_bluetooth"), + AVAudioSession.sharedInstance().currentRoute.outputs.first?.portName ?? "")) + .default_text_style_white(styleSize: 15) + + Spacer() + + Image("bluetooth") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + } + }) + .frame(maxHeight: .infinity) + } } .padding(.horizontal, 20) .background(Color.gray600) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index a5722f1cf..68006130e 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -22,6 +22,7 @@ import linphonesw import AVFAudio import Combine import SwiftUI +import UserNotifications // swiftlint:disable line_length // swiftlint:disable type_body_length @@ -89,25 +90,142 @@ class CallViewModel: ObservableObject { @Published var letters4: String = "DD" @Published var operationInProgress: Bool = false - + @Published var hasAudioRouteRestriction: Bool = false + @Published var audioMutedByEarpieceEnforcement: Bool = false + private var mCoreDelegate: CoreDelegate? private var chatRoomDelegate: ChatRoomDelegate? - + + private static let earpieceNotificationIdentifier = "linphone-earpiece-enforcement" + private var isEnforcingEarpiece: Bool = false + private var routeChangeObserver: Any? + init() { - // Not needed since call audio configuration (AVAudioSession) is handled by the SDK - /* - do { - try configureAudio(.call) - } catch { - print("Audio session error: \(error)") - } - */ + hasAudioRouteRestriction = AppServices.corePreferences.onlyAllowEarpieceDuringCall + NotificationCenter.default.addObserver(forName: Notification.Name("CallViewModelReset"), object: nil, queue: nil) { notification in self.resetCallView() } + + if hasAudioRouteRestriction { + routeChangeObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.enforceEarpieceIfNeeded() + } + } + } + + deinit { + if let observer = routeChangeObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + func isRouteAllowed() -> Bool { + guard AppServices.corePreferences.onlyAllowEarpieceDuringCall else { return true } + let output = AVAudioSession.sharedInstance().currentRoute.outputs.first + return output?.portType == .builtInReceiver + } + + func enforceEarpieceIfNeeded() { + guard hasAudioRouteRestriction else { return } + + if isRouteAllowed() { + if audioMutedByEarpieceEnforcement { + coreContext.doOnCoreQueue { core in + if let call = self.currentCall { + call.microphoneMuted = false + core.micEnabled = true + let micMuttedTmp = call.microphoneMuted || !core.micEnabled + DispatchQueue.main.async { + self.micMutted = micMuttedTmp + self.audioMutedByEarpieceEnforcement = false + } + Log.info("\(CallViewModel.TAG) Earpiece restored, unmuting audio") + } + } + cancelEarpieceNotification() + } + } else { + guard !isEnforcingEarpiece else { return } + isEnforcingEarpiece = true + + Log.info("\(CallViewModel.TAG) Disallowed audio route detected, muting and forcing earpiece") + + DispatchQueue.main.async { + self.micMutted = true + self.audioMutedByEarpieceEnforcement = true + } + + self.forceEarpiece() + self.postEarpieceEnforcementNotification() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + if !self.isRouteAllowed() { + self.forceEarpiece() + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.isEnforcingEarpiece = false + } + } + } + + private func forceEarpiece() { + coreContext.doOnCoreQueue { core in + if let call = self.currentCall { + call.microphoneMuted = true + AudioRouteUtils.routeAudioToEarpiece(core: core, call: call) + } + } + + do { + let session = AVAudioSession.sharedInstance() + try session.overrideOutputAudioPort(.none) + let receiver = session.availableInputs?.first(where: { $0.portType.rawValue.contains("Receiver") }) + ?? session.availableInputs?.first + try session.setPreferredInput(receiver) + } catch { + Log.error("\(CallViewModel.TAG) Failed to override audio port to earpiece: \(error)") + } + } + + private func postEarpieceEnforcementNotification() { + let content = UNMutableNotificationContent() + content.title = "Linphone" + content.body = String(localized: "notification_earpiece_enforcement_message") + content.sound = .default + + let request = UNNotificationRequest( + identifier: CallViewModel.earpieceNotificationIdentifier, + content: content, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + Log.error("\(CallViewModel.TAG) Failed to post earpiece notification: \(error)") + } + } + } + + private func cancelEarpieceNotification() { + UNUserNotificationCenter.current().removeDeliveredNotifications( + withIdentifiers: [CallViewModel.earpieceNotificationIdentifier] + ) + UNUserNotificationCenter.current().removePendingNotificationRequests( + withIdentifiers: [CallViewModel.earpieceNotificationIdentifier] + ) } func resetCallView() { + cancelEarpieceNotification() + audioMutedByEarpieceEnforcement = false + DispatchQueue.main.async { self.displayName = "" self.avatarModel = nil