Option ui/only_allow_earpiece_during_call to enforce the use or earpiecee only during call

This commit is contained in:
Christophe Deschamps 2026-03-18 15:47:46 +01:00
parent b82156d2f2
commit fed6394cd5
23 changed files with 277 additions and 107 deletions

View file

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

View file

@ -1 +1,2 @@
"notification_earpiece_enforcement_message" = "Si us plau, utilitzeu només l'auricular. Les altres sortides d'àudio estan desactivades.";

View file

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

View file

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

View file

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

View file

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

View file

@ -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.";

View file

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

View file

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

View file

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

View file

@ -1 +1,2 @@
"notification_earpiece_enforcement_message" = "Ве молиме користете само слушалка. Другите аудио излези се оневозможени.";

View file

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

View file

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

View file

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

View file

@ -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.";

View file

@ -292,6 +292,7 @@
"new_conversation_title" = "Новая беседа";
"next" = "Следующий";
"notification_missed_call_title" = "Пропущенный звонок";
"notification_earpiece_enforcement_message" = "Пожалуйста, используйте только динамик телефона. Другие аудиовыходы отключены.";
"operation_in_progress_overlay" = "Операция выполняется, пожалуйста, подождите";
"or" = "или";
"password" = "Пароль";

View file

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

View file

@ -199,6 +199,7 @@
"new_conversation_title" = "Нова розмова";
"next" = "Наступний";
"notification_missed_call_title" = "Пропущений виклик";
"notification_earpiece_enforcement_message" = "Будь ласка, використовуйте лише навушник. Інші аудіовиходи вимкнено.";
"operation_in_progress_overlay" = "Операція триває, будь ласка, зачекайте";
"password" = "Пароль";
"phone_number" = "Номер телефону";

View file

@ -287,6 +287,7 @@
"new_conversation_title" = "新聊天";
"next" = "下一个";
"notification_missed_call_title" = "未接来电";
"notification_earpiece_enforcement_message" = "请仅使用听筒。其他音频输出已被禁用。";
"operation_in_progress_overlay" = "操作正在进行中,请稍候";
"or" = "或";
"password" = "密码";

View file

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

View file

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

View file

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

View file

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