From 81448d80065eeb77fe2efd1f90e385a51782330f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 28 Dec 2023 16:53:47 +0100 Subject: [PATCH] Init audio route --- Linphone/Core/CoreContext.swift | 2 - Linphone/Localizable.xcstrings | 9 + Linphone/TelecomManager/TelecomManager.swift | 9 + Linphone/UI/Call/CallView.swift | 340 +++++++++++++----- .../UI/Call/ViewModel/CallViewModel.swift | 127 +++++++ Linphone/UI/Main/ContentView.swift | 59 ++- .../History/Fragments/StartCallFragment.swift | 4 + 7 files changed, 456 insertions(+), 94 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 404098fed..09270fc31 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -155,8 +155,6 @@ final class CoreContext: ObservableObject { } self.mCore.publisher?.onLogCollectionUploadStateChanged?.postOnMainQueue { (cbValue: (_: Core, _: Core.LogCollectionUploadState, info: String)) in - print("publisherpublisher onLogCollectionUploadStateChanged") - if cbValue.info.starts(with: "https") { UIPasteboard.general.setValue( cbValue.info, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index d6a578052..b5524e746 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -187,6 +187,9 @@ }, "Block the number" : { + }, + "Bluetooth" : { + }, "Call history" : { @@ -280,6 +283,9 @@ }, "Don’t save modifications?" : { + }, + "Earpiece" : { + }, "Edit" : { @@ -504,6 +510,9 @@ }, "Skip" : { + }, + "Speaker" : { + }, "Start" : { diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index ce319cde3..0fb2979d4 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -399,10 +399,13 @@ class TelecomManager: ObservableObject { } } + /* if speakerBeforePause { speakerBeforePause = false AudioRouteUtils.routeAudioToSpeaker(core: core) } + */ + actionToFulFill?.fulfill() actionToFulFill = nil case .Paused: @@ -518,6 +521,11 @@ class TelecomManager: ObservableObject { break } + //AudioRouteUtils.isBluetoothAvailable(core: core) + //AudioRouteUtils.isHeadsetAudioRouteAvailable(core: core) + //AudioRouteUtils.isBluetoothAudioRouteAvailable(core: core) + + /* let readyForRoutechange = callkitAudioSessionActivated == nil || (callkitAudioSessionActivated == true) if readyForRoutechange && (cstate == .IncomingReceived || cstate == .OutgoingInit || cstate == .Connected || cstate == .StreamsRunning) { if (call.currentParams?.videoEnabled ?? false) && AudioRouteUtils.isReceiverEnabled(core: core) && call.conference == nil { @@ -527,6 +535,7 @@ class TelecomManager: ObservableObject { AudioRouteUtils.routeAudioToBluetooth(core: core, call: call) } } + */ } // post Notification kLinphoneCallUpdate NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self, userInfo: [ diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 821a66cbb..6b2f1e48f 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -17,33 +17,43 @@ * along with this program. If not, see . */ +// swiftlint:disable type_body_length import SwiftUI import CallKit +import AVFAudio struct CallView: View { @ObservedObject private var coreContext = CoreContext.shared @ObservedObject private var telecomManager = TelecomManager.shared @ObservedObject private var contactsManager = ContactsManager.shared - - @ObservedObject var callViewModel: CallViewModel + + @ObservedObject var callViewModel: CallViewModel + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + @State private var orientation = UIDevice.current.orientation + + let pub = NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) @State var startDate = Date.now - @State var timeElapsed: Int = 0 - @State var micMutted: Bool = false - - let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + @State var audioRouteIsSpeaker: Bool = false + @State var audioRouteSheet: Bool = false + @State var hideButtonsSheet: Bool = false + @State var options: Int = 1 + + @State var imageAudioRoute: String = "" var body: some View { GeometryReader { geo in if #available(iOS 16.4, *) { - innerView() - .sheet(isPresented: $telecomManager.callStarted) { + innerView(geoHeight: geo.size.height) + .sheet(isPresented: .constant(telecomManager.callStarted && !hideButtonsSheet && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height))) { GeometryReader { _ in VStack(spacing: 0) { HStack(spacing: 12) { Button { - terminateCall() + callViewModel.terminateCall() } label: { Image("phone-disconnect") .renderingMode(.template) @@ -72,26 +82,55 @@ struct CallView: View { .cornerRadius(40) Button { - muteCall() + callViewModel.muteCall() } label: { - Image(micMutted ? "microphone-slash" : "microphone") + Image(callViewModel.micMutted ? "microphone-slash" : "microphone") .renderingMode(.template) .resizable() - .foregroundStyle(micMutted ? .black : .white) + .foregroundStyle(callViewModel.micMutted ? .black : .white) .frame(width: 32, height: 32) } .frame(width: 60, height: 60) - .background(micMutted ? .white : Color.gray500) + .background(callViewModel.micMutted ? .white : Color.gray500) .cornerRadius(40) Button { + options = callViewModel.getAudioRoute() + print("audioRouteIsSpeakeraudioRouteIsSpeaker output \(AVAudioSession.sharedInstance().currentRoute.outputs)") + + print("audioRouteIsSpeakeraudioRouteIsSpeaker inputs \(AVAudioSession.sharedInstance().availableInputs?.count)") + + + if AVAudioSession.sharedInstance().availableInputs != nil + && !AVAudioSession.sharedInstance().availableInputs!.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + + hideButtonsSheet = true + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + audioRouteSheet = true + } + + } else { + audioRouteIsSpeaker = !audioRouteIsSpeaker + + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) + } catch _ { + + } + } + } label: { - Image("speaker-high") + Image(imageAudioRoute) .renderingMode(.template) .resizable() .foregroundStyle(.white) .frame(width: 32, height: 32) + .onAppear(perform: getAudioRouteImage) + .onReceive(pub) { (output) in + self.getAudioRouteImage() + } } .frame(width: 60, height: 60) @@ -263,18 +302,129 @@ struct CallView: View { Spacer() } .frame(maxHeight: .infinity, alignment: .top) - .background(.black) + .presentationBackground(.black) .presentationDetents([.fraction(0.1), .medium]) .interactiveDismissDisabled() .presentationBackgroundInteraction(.enabled) } } + .sheet(isPresented: $audioRouteSheet, onDismiss: { + audioRouteSheet = false + hideButtonsSheet = false + }) { + + VStack(spacing: 0) { + Button(action: { + options = 1 + + audioRouteIsSpeaker = false + + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .defaultToSpeaker) + try AVAudioSession.sharedInstance().setActive(true) + } catch _ { + + } + }, label: { + HStack { + Image(options == 1 ? "radio-button-fill" : "radio-button") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + + Text("Earpiece") + .default_text_style_white(styleSize: 15) + + Spacer() + + Image("ear") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + }) + .frame(maxHeight: .infinity) + + Button(action: { + options = 2 + + audioRouteIsSpeaker = true + + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) + } catch _ { + + } + }, label: { + HStack { + Image(options == 2 ? "radio-button-fill" : "radio-button") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + + Text("Speaker") + .default_text_style_white(styleSize: 15) + + Spacer() + + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + }) + .frame(maxHeight: .infinity) + + Button(action: { + options = 3 + + audioRouteIsSpeaker = false + + do { + try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) + try AVAudioSession.sharedInstance().setActive(true) + } catch _ { + + } + }, label: { + HStack { + Image(options == 3 ? "radio-button-fill" : "radio-button") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + + Text("Bluetooth") + .default_text_style_white(styleSize: 15) + + Spacer() + + Image("bluetooth") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 25, height: 25, alignment: .leading) + } + }) + .frame(maxHeight: .infinity) + } + .padding(.horizontal, 20) + .presentationBackground(Color.gray600) + .presentationDetents([.fraction(0.3)]) + .frame(maxHeight: .infinity) + } } } } @ViewBuilder - func innerView() -> some View { + func innerView(geoHeight: CGFloat) -> some View { VStack { Rectangle() .foregroundColor(Color.orangeMain500) @@ -369,9 +519,9 @@ struct CallView: View { .frame(width: 20, height: 20) .padding(.top, 100) - Text(counterToMinutes()) - .onReceive(timer) { firedDate in - timeElapsed = Int(firedDate.timeIntervalSince(startDate)) + Text(callViewModel.counterToMinutes()) + .onReceive(callViewModel.timer) { firedDate in + callViewModel.timeElapsed = Int(firedDate.timeIntervalSince(startDate)) } .padding(.top) @@ -388,20 +538,84 @@ struct CallView: View { .padding(.horizontal, 4) if telecomManager.callStarted { - HStack(spacing: 12) { - HStack { - } - .frame(height: 60) - } - .padding(.horizontal, 25) - .padding(.top, 20) + if telecomManager.callStarted && idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { + HStack(spacing: 12) { + HStack { + + } + .frame(height: 60) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } else { + HStack(spacing: 12) { + Button { + callViewModel.terminateCall() + } label: { + Image("phone-disconnect") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.redDanger500) + .cornerRadius(40) + + Spacer() + + Button { + } label: { + Image("video-camera") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Button { + callViewModel.muteCall() + } label: { + Image(callViewModel.micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(callViewModel.micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(callViewModel.micMutted ? .white : Color.gray500) + .cornerRadius(40) + + Button { + } label: { + Image("speaker-high") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + } + .frame(height: geoHeight * 0.15) + .padding(.horizontal, 20) + } } else { HStack(spacing: 12) { HStack { Spacer() Button { - terminateCall() + callViewModel.terminateCall() } label: { Image("phone-disconnect") .renderingMode(.template) @@ -415,7 +629,7 @@ struct CallView: View { .cornerRadius(40) Button { - acceptCall() + callViewModel.acceptCall() } label: { Image("phone") .renderingMode(.template) @@ -439,60 +653,24 @@ struct CallView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray900) } - - func terminateCall() { - withAnimation { - telecomManager.callInProgress = false - telecomManager.callStarted = false - } - - coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - telecomManager.terminateCall(call: core.currentCall!) - } - } - - timer.upstream.connect().cancel() - } - - func acceptCall() { - withAnimation { - telecomManager.callInProgress = true - telecomManager.callStarted = true - } - - coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - telecomManager.acceptCall(core: core, call: core.currentCall!, hasVideo: false) - } - } - - timer.upstream.connect().cancel() - } - - func muteCall() { - coreContext.doOnCoreQueue { core in - if core.currentCall != nil { - micMutted = !micMutted - core.currentCall!.microphoneMuted = micMutted - } - } - } - - func counterToMinutes() -> String { - let currentTime = timeElapsed - let seconds = currentTime % 60 - let minutes = String(format: "%02d", Int(currentTime / 60)) - let hours = String(format: "%02d", Int(currentTime / 3600)) - - if Int(currentTime / 3600) > 0 { - return "\(hours):\(minutes):\(seconds < 10 ? "0" : "")\(seconds)" - } else { - return "\(minutes):\(seconds < 10 ? "0" : "")\(seconds)" - } - } + + func getAudioRouteImage() { + print("getAudioRouteImagegetAudioRouteImage") + imageAudioRoute = AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty + ? ( + AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty + ? ( + AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Receiver" }).isEmpty + ? "headset" + : "speaker-slash" + ) + : "bluetooth" + ) + : "speaker-high" + } } #Preview { - CallView(callViewModel: CallViewModel()) + CallView(callViewModel: CallViewModel()) } +// swiftlint:enable type_body_length diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index efb3ab588..c7ad9cca7 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -17,7 +17,9 @@ * along with this program. If not, see . */ +import SwiftUI import linphonesw +import AVFAudio class CallViewModel: ObservableObject { @@ -29,8 +31,14 @@ class CallViewModel: ObservableObject { @Published var remoteAddressString: String = "example.linphone@sip.linphone.org" @Published var remoteAddress: Address? @Published var avatarModel: ContactAvatarModel? + @Published var audioSessionImage: String = "" + @State var micMutted: Bool = false + @State var timeElapsed: Int = 0 + + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() init() { + setupNotifications() coreContext.doOnCoreQueue { core in if core.currentCall != nil && core.currentCall!.remoteAddress != nil { DispatchQueue.main.async { @@ -52,4 +60,123 @@ class CallViewModel: ObservableObject { } } } + + func terminateCall() { + withAnimation { + telecomManager.callInProgress = false + telecomManager.callStarted = false + } + + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + self.telecomManager.terminateCall(call: core.currentCall!) + } + } + + timer.upstream.connect().cancel() + } + + func acceptCall() { + withAnimation { + telecomManager.callInProgress = true + telecomManager.callStarted = true + } + + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + self.telecomManager.acceptCall(core: core, call: core.currentCall!, hasVideo: false) + } + } + + timer.upstream.connect().cancel() + } + + func muteCall() { + coreContext.doOnCoreQueue { core in + if core.currentCall != nil { + self.micMutted = !self.micMutted + core.currentCall!.microphoneMuted = self.micMutted + } + } + } + + func counterToMinutes() -> String { + let currentTime = timeElapsed + let seconds = currentTime % 60 + let minutes = String(format: "%02d", Int(currentTime / 60)) + let hours = String(format: "%02d", Int(currentTime / 3600)) + + if Int(currentTime / 3600) > 0 { + return "\(hours):\(minutes):\(seconds < 10 ? "0" : "")\(seconds)" + } else { + return "\(minutes):\(seconds < 10 ? "0" : "")\(seconds)" + } + } + + func setupNotifications() { + /* + notifCenter.addObserver(self, + selector: #selector(handleRouteChange), + name: AVAudioSession.routeChangeNotification, + object: nil) + */ + + //NotificationCenter.default.addObserver(self, selector: Selector(("handleRouteChange")), name: UITextView.textDidChangeNotification, object: nil) + } + + + func handleRouteChange(notification: Notification) { + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { + return + } + + // Switch over the route change reason. + switch reason { + + + case .newDeviceAvailable, .oldDeviceUnavailable: // New device found. + print("handleRouteChangehandleRouteChange handleRouteChange") + + AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty + ? ( + AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty + ? ( + AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Receiver" }).isEmpty + ? "headset" + : "speaker-slash" + ) + : "bluetooth" + ) + : "speaker-high" + + /* + case .oldDeviceUnavailable: // Old device removed. + if let previousRoute = + userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { + + } + */ + default: () + } + } + + + func hasHeadphones(in routeDescription: AVAudioSessionRouteDescription) -> Bool { + // Filter the outputs to only those with a port type of headphones. + return !routeDescription.outputs.filter({$0.portType == .headphones}).isEmpty + } + + func getAudioRoute() -> Int { + if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty { + if AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue.contains("Bluetooth") }).isEmpty { + return 1 + } else { + return 3 + } + } else { + return 2 + } + } } diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index cdd0ca2a4..4c81f0c7c 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -511,19 +511,56 @@ struct ContentView: View { } if isShowStartCallFragment { - StartCallFragment( - startCallViewModel: startCallViewModel, - isShowStartCallFragment: $isShowStartCallFragment, - showingDialer: $showingDialer - ) - .zIndex(3) - .transition(.move(edge: .bottom)) - .halfSheet(showSheet: $showingDialer) { - DialerBottomSheet( + + if #available(iOS 16.4, *) { + if idiom != .pad { + StartCallFragment( + startCallViewModel: startCallViewModel, + isShowStartCallFragment: $isShowStartCallFragment, + showingDialer: $showingDialer + ) + .zIndex(3) + .transition(.move(edge: .bottom)) + .sheet(isPresented: $showingDialer) { + DialerBottomSheet( + startCallViewModel: startCallViewModel, + showingDialer: $showingDialer + ) + .presentationDetents([.medium]) + //.interactiveDismissDisabled() + .presentationBackgroundInteraction(.enabled(upThrough: .medium)) + } + } else { + StartCallFragment( + startCallViewModel: startCallViewModel, + isShowStartCallFragment: $isShowStartCallFragment, + showingDialer: $showingDialer + ) + .zIndex(3) + .transition(.move(edge: .bottom)) + .halfSheet(showSheet: $showingDialer) { + DialerBottomSheet( + startCallViewModel: startCallViewModel, + showingDialer: $showingDialer + ) + } onDismiss: {} + } + + } else { + StartCallFragment( startCallViewModel: startCallViewModel, + isShowStartCallFragment: $isShowStartCallFragment, showingDialer: $showingDialer ) - } onDismiss: {} + .zIndex(3) + .transition(.move(edge: .bottom)) + .halfSheet(showSheet: $showingDialer) { + DialerBottomSheet( + startCallViewModel: startCallViewModel, + showingDialer: $showingDialer + ) + } onDismiss: {} + } } if isShowDeleteContactPopup { @@ -624,7 +661,7 @@ struct ContentView: View { } if telecomManager.callInProgress { - CallView(callViewModel: CallViewModel()) + CallView(callViewModel: CallViewModel()) .zIndex(3) .transition(.scale.combined(with: .move(edge: .top))) } diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index 08864ab57..60ff5196d 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -156,6 +156,8 @@ struct StartCallFragment: View { } ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: { addr in + showingDialer = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -236,6 +238,8 @@ struct StartCallFragment: View { } } .onTapGesture { + showingDialer = false + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue)