From 3bb0d06787a5fc6baf0b5e89fa0bdb3e84e96060 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 12 Dec 2023 11:42:56 +0100 Subject: [PATCH 01/10] Add ProviderDelegate and TelecomManager for Callkit integration --- Linphone.xcodeproj/project.pbxproj | 16 + Linphone/Core/CoreContext.swift | 5 + Linphone/Info.plist | 4 + .../TelecomManager/ProviderDelegate.swift | 386 +++++++++++++++ Linphone/TelecomManager/TelecomManager.swift | 456 ++++++++++++++++++ .../ViewModel/HistoryListViewModel.swift | 3 +- 6 files changed, 869 insertions(+), 1 deletion(-) create mode 100644 Linphone/TelecomManager/ProviderDelegate.swift create mode 100644 Linphone/TelecomManager/TelecomManager.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index e7976994a..901406bbe 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69D82B25DE18007118BF /* TelecomManager.swift */; }; + 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */; }; 66C491F92B24D25B00CEA16D /* ConfigExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */; }; 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FA2B24D32600CEA16D /* CoreExtension.swift */; }; 66C491FD2B24D36500CEA16D /* AudioRouteUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FC2B24D36500CEA16D /* AudioRouteUtils.swift */; }; @@ -91,6 +93,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 662B69D82B25DE18007118BF /* TelecomManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelecomManager.swift; sourceTree = ""; }; + 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderDelegate.swift; sourceTree = ""; }; 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigExtension.swift; sourceTree = ""; }; 66C491FA2B24D32600CEA16D /* CoreExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreExtension.swift; sourceTree = ""; }; 66C491FC2B24D36500CEA16D /* AudioRouteUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteUtils.swift; sourceTree = ""; }; @@ -188,6 +192,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 662B69D72B25DDF6007118BF /* TelecomManager */ = { + isa = PBXGroup; + children = ( + 662B69D82B25DE18007118BF /* TelecomManager.swift */, + 662B69DA2B25DE25007118BF /* ProviderDelegate.swift */, + ); + path = TelecomManager; + sourceTree = ""; + }; 66C491F72B24D25A00CEA16D /* Extensions */ = { isa = PBXGroup; children = ( @@ -247,6 +260,7 @@ D719ABB62ABC67BF00B41C10 /* LinphoneApp.swift */, D777DBB12AE12C4000565A99 /* Contacts */, D719ABC72ABC6FB200B41C10 /* Core */, + 662B69D72B25DDF6007118BF /* TelecomManager */, D719ABC52ABC6EE800B41C10 /* UI */, D717071C2AC591EF0037746F /* Utils */, D719ABBA2ABC67BF00B41C10 /* Assets.xcassets */, @@ -599,6 +613,7 @@ D7C3650C2AF0084000FE6142 /* EditContactViewModel.swift in Sources */, D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */, + 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */, D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */, D732A9132B04C7A300DB42BA /* HistoryListFragment.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, @@ -641,6 +656,7 @@ D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */, D7C48DF62AFCDF4700D938CB /* ContactInnerActionsFragment.swift in Sources */, D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */, + 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */, D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */, D726E43D2B19E4FE0083C415 /* StartCallFragment.swift in Sources */, diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index ce607a4da..f2592ae60 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -149,6 +149,11 @@ final class CoreContext: ObservableObject { cbVal.core.clearAccounts() cbVal.core.clearAllAuthInfo() } + TelecomManager.shared.onAccountRegistrationStateChanged(core: cbVal.core, account: cbVal.account, state: cbVal.state, message: cbVal.message) + } + + self.mCore.publisher?.onCallStateChanged?.postOnCoreQueue { (cbVal: (core: Core, call: Call, state: Call.State, message: String)) in + TelecomManager.shared.onCallStateChanged(core: cbVal.core, call: cbVal.call, state: cbVal.state, message: cbVal.message) } self.mIteratePublisher = Timer.publish(every: 0.02, on: .main, in: .common) diff --git a/Linphone/Info.plist b/Linphone/Info.plist index 5aaaf7fac..340c36eb2 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -2,6 +2,10 @@ + NSCameraUsageDescription + Camera usage is required for video VOIP calls + NSMicrophoneUsageDescription + Microphone usage is required for VOIP calls UIAppFonts NotoSans-Light.ttf diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift new file mode 100644 index 000000000..348512a04 --- /dev/null +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -0,0 +1,386 @@ +/* +* Copyright (c) 2010-2020 Belledonne Communications SARL. +* +* This file is part of linphone-iphone +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see . +*/ +// swiftlint:disable line_length + +import Foundation +import CallKit +import UIKit +import linphonesw +import AVFoundation +import os + +class CallInfo { + var callId: String = "" + var toAddr: Address? + var isOutgoing = false + var sasEnabled = false + var connected = false + var reason: Reason = Reason.None + var displayName: String? + var videoEnabled = false + var isConference = false + + static func newIncomingCallInfo(callId: String) -> CallInfo { + let callInfo = CallInfo() + callInfo.callId = callId + return callInfo + } + + static func newOutgoingCallInfo(addr: Address, isSas: Bool, displayName: String, isVideo: Bool, isConference: Bool) -> CallInfo { + let callInfo = CallInfo() + callInfo.isOutgoing = true + callInfo.sasEnabled = isSas + callInfo.toAddr = addr + callInfo.displayName = displayName + callInfo.videoEnabled = isVideo + callInfo.isConference = isConference + return callInfo + } +} + +/* +* A delegate to support callkit. +*/ +class ProviderDelegate: NSObject { + let provider: CXProvider + var uuids: [String: UUID] = [:] + var callInfos: [UUID: CallInfo] = [:] + + override init() { + provider = CXProvider(configuration: ProviderDelegate.providerConfiguration) + super.init() + provider.setDelegate(self, queue: nil) + } + + static var providerConfiguration: CXProviderConfiguration { + get { + let providerConfiguration = CXProviderConfiguration() + // providerConfiguration.ringtoneSound = ConfigManager.instance().lpConfigBoolForKey(key: "use_device_ringtone") ? nil : "notes_of_the_optimistic.caf" + providerConfiguration.supportsVideo = true + providerConfiguration.iconTemplateImageData = UIImage(named: "callkit_logo")?.pngData() + providerConfiguration.supportedHandleTypes = [.generic, .phoneNumber, .emailAddress] + + providerConfiguration.maximumCallsPerCallGroup = 10 + providerConfiguration.maximumCallGroups = 10 + + // not show app's calls in tel's history + // providerConfiguration.includesCallsInRecents = YES; + + return providerConfiguration + } + } + + func reportIncomingCall(call: Call?, uuid: UUID, handle: String, hasVideo: Bool, displayName: String) { + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: handle) + update.hasVideo = hasVideo + update.localizedCallerName = displayName + + let callInfo = callInfos[uuid] + let callId = callInfo?.callId ?? "" + + /* + if (ConfigManager.instance().config?.hasEntry(section: "app", key: "max_calls") == 1) { // moved from misc to app section intentionally upon app start or remote configuration + if let maxCalls = ConfigManager.instance().config?.getInt(section: "app",key: "max_calls",defaultValue: 10), Core.get().callsNb > maxCalls { + Log.directLog(BCTBX_LOG_MESSAGE, text: "CallKit: declining call, as max calls (\(maxCalls)) reached call-id: [\(String(describing: callId))] and UUID: [\(uuid.description)]") + decline(uuid: uuid) + + CoreContext.shared.doOnCoreQueue(synchronous: true) { core in + try? call?.decline(reason: .Busy) + } + return + } + } + */ + + Log.info("CallKit: report new incoming call with call-id: [\(callId)] and UUID: [\(uuid.description)]") + // TelecomManager.instance().setHeldOtherCalls(exceptCallid: callId ?? "") // ALREADY COMMENTED ON LINPHONE-IPHONE 5.2 + provider.reportNewIncomingCall(with: uuid, update: update) { error in + if error == nil { + if TelecomManager.shared.endCallkit { + CoreContext.shared.doOnCoreQueue(synchronous: true) { core in + let call = core.getCallByCallid(callId: callId) + if call?.state == .PushIncomingReceived { + try? call?.terminate() + } + } + } + } else { + Log.error("CallKit: cannot complete incoming call with call-id: [\(callId)] and UUID: [\(uuid.description)] from [\(handle)] caused by [\(error!.localizedDescription)]") + let code = (error as NSError?)?.code + switch code { + case CXErrorCodeIncomingCallError.filteredByDoNotDisturb.rawValue: + callInfo?.reason = Reason.Busy // This answer is only for this device. Using Reason.DoNotDisturb will make all other end point stop ringing. + case CXErrorCodeIncomingCallError.filteredByBlockList.rawValue: + callInfo?.reason = Reason.DoNotDisturb + default: + callInfo?.reason = Reason.Unknown + } + self.callInfos.updateValue(callInfo!, forKey: uuid) + CoreContext.shared.doOnCoreQueue(synchronous: true) { _ in + try? call?.decline(reason: callInfo!.reason) + } + } + } + } + + func updateCall(uuid: UUID, handle: String, hasVideo: Bool = false, displayName: String) { + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: handle) + update.localizedCallerName = displayName + update.hasVideo = hasVideo + provider.reportCall(with: uuid, updated: update) + } + + func reportOutgoingCallStartedConnecting(uuid: UUID) { + provider.reportOutgoingCall(with: uuid, startedConnectingAt: nil) + } + + func reportOutgoingCallConnected(uuid: UUID) { + provider.reportOutgoingCall(with: uuid, connectedAt: nil) + } + + func endCall(uuid: UUID) { + provider.reportCall(with: uuid, endedAt: .init(), reason: .failed) + } + + func decline(uuid: UUID) { + provider.reportCall(with: uuid, endedAt: .init(), reason: .unanswered) + } + + func endCallNotExist(uuid: UUID, timeout: DispatchTime) { + DispatchQueue.main.asyncAfter(deadline: timeout) { + CoreContext.shared.doOnCoreQueue(synchronous: true) { core in + let callId = TelecomManager.shared.providerDelegate.callInfos[uuid]?.callId + if callId == nil { + // callkit already ended + return + } + if core.getCallByCallid(callId: callId ?? "") == nil { + Log.info("CallKit: terminate call with call-id: \(String(describing: callId)) and UUID: \(uuid) which does not exist.") + self.endCall(uuid: uuid) + } + } + } + } +} + +// MARK: - CXProviderDelegate +extension ProviderDelegate: CXProviderDelegate { + func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + + let uuid = action.callUUID + let callId = callInfos[uuid]?.callId + + // remove call infos first, otherwise CXEndCallAction will be called more than onece + if callId != nil { + uuids.removeValue(forKey: callId!) + } + callInfos.removeValue(forKey: uuid) + + CoreContext.shared.doOnCoreQueue { core in + if let call = core.getCallByCallid(callId: callId ?? "") { + TelecomManager.shared.terminateCall(call: call) + Log.info("CallKit: Call ended with call-id: \(String(describing: callId)) an UUID: \(uuid.description).") + } + action.fulfill() + } + } + + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + let uuid = action.callUUID + let callInfo = callInfos[uuid] + let callId = callInfo?.callId ?? "" + CoreContext.shared.doOnCoreQueue { core in + Log.info("CallKit: answer call with call-id: \(String(describing: callId)) and UUID: \(uuid.description).") + + let call = core.getCallByCallid(callId: callId) + + if UIApplication.shared.applicationState != .active { + TelecomManager.shared.backgroundContextCall = call + TelecomManager.shared.backgroundContextCameraIsEnabled = call?.params?.videoEnabled == true || call?.callLog?.wasConference() == true + if #available(iOS 16.0, *) { + if call?.cameraEnabled == true { + call?.cameraEnabled = AVCaptureSession().isMultitaskingCameraAccessSupported + } + } else { + call?.cameraEnabled = false // Disable camera while app is not on foreground + } + } + TelecomManager.shared.callkitAudioSessionActivated = false + core.configureAudioSession() + TelecomManager.shared.acceptCall(core: core, call: call!, hasVideo: call!.params?.videoEnabled ?? false) + action.fulfill() + } + } + + func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) { + let uuid = action.callUUID + let callId = callInfos[uuid]?.callId ?? "" + + CoreContext.shared.doOnCoreQueue { core in + let call = core.getCallByCallid(callId: callId) + + if call == nil { + Log.error("CXSetHeldCallAction: no call !") + action.fail() + return + } + + do { + if call?.conference != nil && action.isOnHold { + _ = call?.conference?.leave() + Log.info("CallKit: call-id: [\(callId)] leaving conference") + NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self) + action.fulfill() + } else { + let state = action.isOnHold ? "Paused" : "Resumed" + Log.info("CallKit: Call with call-id: [\(callId)] and UUID: [\(uuid)] paused status changed to: [\(state)]") + if action.isOnHold { + TelecomManager.shared.speakerBeforePause = AudioRouteUtils.isSpeakerAudioRouteCurrentlyUsed(core: core, call: call) + try call!.pause() + // fullfill() the action now to indicate to Callkit that this call is no longer active, even if the + // SIP transaction is not completed yet. At this stage, the media streams are off. + // If callkit is not aware that the pause action is completed, it will terminate this call if we + // attempt to resume another one. + action.fulfill() + } else { + if call?.conference != nil && core.callsNb ?? 0 > 1 {/* + try TelecomManager.shared.lc?.enterConference() + action.fulfill() + NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self) + */} else { + try call!.resume() + // We'll notify callkit that the action is fulfilled when receiving the 200Ok, which is the point + // where we actually start the media streams. + TelecomManager.shared.actionToFulFill = action + // HORRIBLE HACK HERE - PLEASE APPLE FIX THIS !! + // When resuming a SIP call after a native call has ended remotely, didActivate: audioSession + // is never called. + // It looks like in this case, it is implicit. + // As a result we have to notify the Core that the AudioSession is active. + // The SpeakerBox demo application written by Apple exhibits this behavior. + // https://developer.apple.com/documentation/callkit/making_and_receiving_voip_calls_with_callkit + // We can clearly see there that startAudio() is called immediately in the CXSetHeldCallAction + // handler, while it is called from didActivate: audioSession otherwise. + // Callkit's design is not consistent, or its documentation imcomplete, wich is somewhat disapointing. + // + Log.info("Assuming AudioSession is active when executing a CXSetHeldCallAction with isOnHold=false.") + core.activateAudioSession(actived: true) + TelecomManager.shared.callkitAudioSessionActivated = true + } + } + } + } catch { + Log.error("CallKit: Call set held (paused or resumed) \(uuid) failed because \(error)") + action.fail() + } + } + } + + func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + let uuid = action.callUUID + let callInfo = callInfos[uuid] + let update = CXCallUpdate() + update.remoteHandle = action.handle + update.localizedCallerName = callInfo?.displayName + self.provider.reportCall(with: action.callUUID, updated: update) + + let addr = callInfo?.toAddr + if addr == nil { + Log.info("CallKit: can not call a null address!") + action.fail() + } else { + CoreContext.shared.doOnCoreQueue { core in + do { + core.configureAudioSession() + try TelecomManager.shared.doCall(core: core, addr: addr!, isSas: callInfo?.sasEnabled ?? false, isVideo: callInfo?.videoEnabled ?? false, isConference: callInfo?.isConference ?? false) + action.fulfill() + } catch { + Log.info("CallKit: Call started failed because \(error)") + action.fail() + } + } + } + } + + func provider(_ provider: CXProvider, perform action: CXSetGroupCallAction) { + CoreContext.shared.doOnCoreQueue { core in + Log.info("CallKit: Call grouped callUUid : \(action.callUUID) with callUUID: \(String(describing: action.callUUIDToGroupWith)).") + TelecomManager.shared.addAllToLocalConference(core: core) + action.fulfill() + } + } + + func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { + let uuid = action.callUUID + let callId = callInfos[uuid]?.callId + CoreContext.shared.doOnCoreQueue { core in + Log.info( "CallKit: Call muted with call-id: \(String(describing: callId)) an UUID: \(uuid.description).") + core.micEnabled = !core.micEnabled + action.fulfill() + } + } + + func provider(_ provider: CXProvider, perform action: CXPlayDTMFCallAction) { + let uuid = action.callUUID + let callId = callInfos[uuid]?.callId ?? "" + + CoreContext.shared.doOnCoreQueue { core in + Log.info("CallKit: Call send dtmf with call-id: \(callId) an UUID: \(uuid.description).") + if let call = core.getCallByCallid(callId: callId) { + let digit = (action.digits.cString(using: String.Encoding.utf8)?[0])! + do { + try call.sendDtmf(dtmf: digit) + } catch { + Log.error("CallKit: Call send dtmf \(uuid) failed because \(error)") + } + } + action.fulfill() + } + } + + func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) { + let uuid = action.uuid + let callId = callInfos[uuid]?.callId + Log.error("CallKit: Call time out with call-id: \(String(describing: callId)) an UUID: \(uuid.description).") + action.fulfill() + } + + func providerDidReset(_ provider: CXProvider) { + Log.info("CallKit: did reset.") + } + + func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { + CoreContext.shared.doOnCoreQueue { core in + Log.info("CallKit: audio session activated.") + core.activateAudioSession(actived: true) + TelecomManager.shared.callkitAudioSessionActivated = true + } + } + + func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { + CoreContext.shared.doOnCoreQueue { core in + Log.info("CallKit: audio session deactivated.") + core.activateAudioSession(actived: false) + TelecomManager.shared.callkitAudioSessionActivated = nil + } + } +} +// swiftlint:enable line_length diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift new file mode 100644 index 000000000..b92ae1eca --- /dev/null +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -0,0 +1,456 @@ +/* + * Copyright (c) 2010-2020 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +// swiftlint:disable cyclomatic_complexity + +import Foundation +import linphonesw +import UserNotifications +import os +import CallKit +import AVFoundation + +class CallAppData: NSObject { + var batteryWarningShown = false + var videoRequested = false /*set when user has requested for video*/ + var isConference = false + +} + +class TelecomManager { + static let shared = TelecomManager() + static var uuidReplacedCall: String? + + let providerDelegate: ProviderDelegate // to support callkit + let callController: CXCallController // to support callkit + + var actionToFulFill: CXCallAction? + var callkitAudioSessionActivated: Bool? + var nextCallIsTransfer: Bool = false + var speakerBeforePause: Bool = false + var endCallkit: Bool = false + var endCallKitReplacedCall: Bool = true + + var backgroundContextCall: Call? + var backgroundContextCameraIsEnabled: Bool = false + + var referedFromCall: String? + var referedToCall: String? + var actionsToPerformOnceWhenCoreIsOn: [(() -> Void)] = [] + + private init() { + providerDelegate = ProviderDelegate() + callController = CXCallController() + } + + func addAllToLocalConference(core: Core) { + // TODO + } + + static func getAppData(sCall: Call) -> CallAppData? { + if sCall.userData == nil { + return nil + } + return Unmanaged.fromOpaque(sCall.userData!).takeUnretainedValue() + } + static func setAppData(sCall: Call, appData: CallAppData?) { + if sCall.userData != nil { + Unmanaged.fromOpaque(sCall.userData!).release() + } + if appData == nil { + sCall.userData = nil + } else { + sCall.userData = UnsafeMutableRawPointer(Unmanaged.passRetained(appData!).toOpaque()) + } + } + + func doCall(core: Core, addr: Address, isSas: Bool, isVideo: Bool, isConference: Bool = false) throws { + // let displayName = FastAddressBook.displayName(for: addr.getCobject) + + let lcallParams = try core.createCallParams(call: nil) + /* + if ConfigManager.instance().lpConfigBoolForKey(key: "edge_opt_preference") && AppManager.network() == .network_2g { + Log.directLog(BCTBX_LOG_MESSAGE, text: "Enabling low bandwidth mode") + lcallParams.lowBandwidthEnabled = true + } + + if (displayName != nil) { + try addr.setDisplayname(newValue: displayName!) + } + + if(ConfigManager.instance().lpConfigBoolForKey(key: "override_domain_with_default_one")) { + try addr.setDomain(newValue: ConfigManager.instance().lpConfigStringForKey(key: "domain", section: "assistant")) + } + */ + + if nextCallIsTransfer { + let call = core.currentCall + try call?.transferTo(referTo: addr) + nextCallIsTransfer = false + } else { + // We set the record file name here because we can't do it after the call is started. + // let writablePath = AppManager.recordingFilePathFromCall(address: addr.username! ) + // Log.directLog(BCTBX_LOG_DEBUG, text: "record file path: \(writablePath)") + // lcallParams.recordFile = writablePath + if isSas { + lcallParams.mediaEncryption = .ZRTP + } + if isConference { + /* if (ConferenceWaitingRoomViewModel.sharedModel.joinLayout.value! != .AudioOnly) { + lcallParams.videoEnabled = true + lcallParams.videoDirection = ConferenceWaitingRoomViewModel.sharedModel.isVideoEnabled.value == true ? .SendRecv : .RecvOnly + lcallParams.conferenceVideoLayout = ConferenceWaitingRoomViewModel.sharedModel.joinLayout.value! == .Grid ? .Grid : .ActiveSpeaker + } else { + lcallParams.videoEnabled = false + }*/ + } else { + lcallParams.videoEnabled = isVideo + } + + if let call = core.inviteAddressWithParams(addr: addr, params: lcallParams) { + // The LinphoneCallAppData object should be set on call creation with callback + // - (void)onCall:StateChanged:withMessage:. If not, we are in big trouble and expect it to crash + // We are NOT responsible for creating the AppData. + if let data = TelecomManager.getAppData(sCall: call) { + data.isConference = isConference + data.videoRequested = lcallParams.videoEnabled + TelecomManager.setAppData(sCall: call, appData: data) + } else { + Log.error("New call instanciated but app data was not set. Expect it to crash.") + /* will be used later to notify user if video was not activated because of the linphone core*/ + } + } + } + } + + func acceptCall(core: Core, call: Call, hasVideo: Bool) { + do { + let callParams = try core.createCallParams(call: call) + callParams.videoEnabled = hasVideo + /*if (ConfigManager.instance().lpConfigBoolForKey(key: "edge_opt_preference")) { + let low_bandwidth = (AppManager.network() == .network_2g) + if (low_bandwidth) { + Log.directLog(BCTBX_LOG_MESSAGE, text: "Low bandwidth mode") + } + callParams.lowBandwidthEnabled = low_bandwidth + }*/ + + // We set the record file name here because we can't do it after the call is started. + // let address = call.callLog?.fromAddress + // let writablePath = AppManager.recordingFilePathFromCall(address: address?.username ?? "") + // Log.directLog(BCTBX_LOG_MESSAGE, text: "Record file path: \(String(describing: writablePath))") + // callParams.recordFile = writablePath + + /* + if let chatView : ChatConversationView = PhoneMainView.instance().VIEW(ChatConversationView.compositeViewDescription()), chatView.isVoiceRecording { + Log.directLog(BCTBX_LOG_MESSAGE, text: "Voice recording in progress, stopping it befoce accepting the call.") + chatView.stopVoiceRecording() + }*/ + + if call.callLog?.wasConference() == true { + // Prevent incoming group call to start in audio only layout + // Do the same as the conference waiting room + callParams.videoEnabled = true + callParams.videoDirection = core.videoActivationPolicy?.automaticallyInitiate == true ? .SendRecv : .RecvOnly + Log.info("[Context] Enabling video on call params to prevent audio-only layout when answering") + } + + try call.acceptWithParams(params: callParams) + } catch { + Log.error("accept call failed \(error)") + } + } + + func terminateCall(call: Call) { + do { + try call.terminate() + Log.info("Call terminated") + } catch { + Log.error("Failed to terminate call failed because \(error)") + } + } + + func displayIncomingCall(call: Call?, handle: String, hasVideo: Bool, callId: String, displayName: String) { + let uuid = UUID() + let callInfo = CallInfo.newIncomingCallInfo(callId: callId) + + providerDelegate.callInfos.updateValue(callInfo, forKey: uuid) + providerDelegate.uuids.updateValue(uuid, forKey: callId) + providerDelegate.reportIncomingCall(call: call, uuid: uuid, handle: handle, hasVideo: hasVideo, displayName: displayName) + } + + func incomingDisplayName(call: Call) -> String { + // TODO + return "IncomingDisplayName" + } + + static func callKitEnabled(core: Core) -> Bool { +#if !targetEnvironment(simulator) + return core.callkitEnabled +#endif + return false + } + + func requestTransaction(_ transaction: CXTransaction, action: String) { + callController.request(transaction) { error in + if let error = error { + Log.error("CallKit: Requested transaction \(action) failed because: \(error)") + } else { + Log.info("CallKit: Requested transaction \(action) successfully") + } + } + } + + func onAccountRegistrationStateChanged(core: Core, account: Account, state: RegistrationState, message: String) { + if core.accountList.count == 1 && (state == .Failed || state == .Cleared) { + // terminate callkit immediately when registration failed or cleared, supporting single account configuration + for call in providerDelegate.uuids { + let callId = providerDelegate.callInfos[call.value]?.callId + if callId != nil { + let call = core.getCallByCallid(callId: callId!) + if call != nil && call?.state != .PushIncomingReceived { + // sometimes (for example) due to network, registration failed, in this case, keep the call + continue + } + } + providerDelegate.endCall(uuid: call.value) + } + endCallkit = true + } else { + endCallkit = false + } + } + + func onCallStateChanged(core: Core, call: Call, state cstate: Call.State, message: String) { + let callLog = call.callLog + let callId = callLog?.callId ?? "" + if cstate == .PushIncomingReceived { + displayIncomingCall(call: call, handle: "Calling", hasVideo: false, callId: callId, displayName: "Calling") + } else { + let video = (core.videoActivationPolicy?.automaticallyAccept ?? false) && (call.remoteParams?.videoEnabled ?? false) + + if call.userData == nil { + let appData = CallAppData() + TelecomManager.setAppData(sCall: call, appData: appData) + } + /* + if let conference = call.conference, ConferenceViewModel.shared.conference.value == nil { + Log.info("[Call] Found conference attached to call and no conference in dedicated view model, init & configure it") + ConferenceViewModel.shared.initConference(conference) + ConferenceViewModel.shared.configureConference(conference) + } + */ + switch cstate { + case .IncomingReceived: + let addr = call.remoteAddress + let displayName = incomingDisplayName(call: call) + + if call.replacedCall != nil { + endCallKitReplacedCall = false + + let uuid = providerDelegate.uuids["\(TelecomManager.uuidReplacedCall ?? "")"] + let callInfo = providerDelegate.callInfos[uuid!] + callInfo!.callId = referedToCall ?? "" + if callInfo != nil && uuid != nil && addr != nil { + providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!) + providerDelegate.uuids.removeValue(forKey: callId) + providerDelegate.uuids.updateValue(uuid!, forKey: callInfo!.callId) + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + } + } else if TelecomManager.callKitEnabled(core: core) { + /* + let isConference = isConferenceCall(call: call) + let isEarlyConference = isConference && CallsViewModel.shared.currentCallData.value??.isConferenceCall.value != true // Conference info not be received yet. + if (isEarlyConference) { + CallsViewModel.shared.currentCallData.readCurrentAndObserve { _ in + let uuid = providerDelegate.uuids["\(callId)"] + if (uuid != nil) { + displayName = "\(VoipTexts.conference_incoming_title): \(CallsViewModel.shared.currentCallData.value??.remoteConferenceSubject.value ?? "") (\(CallsViewModel.shared.currentCallData.value??.conferenceParticipantsCountLabel.value ?? ""))" + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + } + } + } + */ + let uuid = providerDelegate.uuids["\(callId)"] + if call.replacedCall == nil { + TelecomManager.uuidReplacedCall = callId + } + + if uuid != nil { + // Tha app is now registered, updated the call already existed. + providerDelegate.updateCall(uuid: uuid!, handle: addr!.asStringUriOnly(), hasVideo: video, displayName: displayName) + } else { + displayIncomingCall(call: call, handle: addr!.asStringUriOnly(), hasVideo: video, callId: callId, displayName: displayName) + } + } /* else if UIApplication.shared.applicationState != .active { + // not support callkit , use notif + let content = UNMutableNotificationContent() + content.title = NSLocalizedString("Incoming call", comment: "") + content.body = displayName + content.sound = UNNotificationSound.init(named: UNNotificationSoundName.init("notes_of_the_optimistic.caf")) + content.categoryIdentifier = "call_cat" + content.userInfo = ["CallId": callId] + let req = UNNotificationRequest.init(identifier: "call_request", content: content, trigger: nil) + UNUserNotificationCenter.current().add(req, withCompletionHandler: nil) + } */ + case .StreamsRunning: + if TelecomManager.callKitEnabled(core: core) { + let uuid = providerDelegate.uuids["\(callId)"] + if uuid != nil { + let callInfo = providerDelegate.callInfos[uuid!] + if callInfo != nil && callInfo!.isOutgoing && !callInfo!.connected { + Log.info("CallKit: outgoing call connected with uuid \(uuid!) and callId \(callId)") + providerDelegate.reportOutgoingCallConnected(uuid: uuid!) + callInfo!.connected = true + providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!) + } + } + } + + if speakerBeforePause { + speakerBeforePause = false + AudioRouteUtils.routeAudioToSpeaker(core: core) + } + actionToFulFill?.fulfill() + actionToFulFill = nil + case .Paused: + actionToFulFill?.fulfill() + actionToFulFill = nil + case .OutgoingInit, + .OutgoingProgress, + .OutgoingRinging, + .OutgoingEarlyMedia: + if TelecomManager.callKitEnabled(core: core) { + let uuid = providerDelegate.uuids[""] + if uuid != nil && callId.isEmpty { + let callInfo = providerDelegate.callInfos[uuid!] + callInfo!.callId = callId + providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!) + providerDelegate.uuids.removeValue(forKey: "") + providerDelegate.uuids.updateValue(uuid!, forKey: callId) + + Log.info("CallKit: outgoing call started connecting with uuid \(uuid!) and callId \(callId)") + providerDelegate.reportOutgoingCallStartedConnecting(uuid: uuid!) + } else { + if false { /* isConferenceCall(call: call) { + let uuid = UUID() + let callInfo = CallInfo.newOutgoingCallInfo(addr: call.remoteAddress!, isSas: call.params?.mediaEncryption == .ZRTP, displayName: VoipTexts.conference_default_title, isVideo: call.params?.videoEnabled == true, isConference:true) + providerDelegate.callInfos.updateValue(callInfo, forKey: uuid) + providerDelegate.uuids.updateValue(uuid, forKey: "") + providerDelegate.reportOutgoingCallStartedConnecting(uuid: uuid) + Core.get().activateAudioSession(actived: true) */ + } else { + referedToCall = callId + } + } + } + case .End, + .Error: + var displayName = "Unknown" + if call.dir == .Incoming { + displayName = incomingDisplayName(call: call) + } else { // if let addr = call.remoteAddress, let contactName = FastAddressBook.displayName(for: addr.getCobject) { + displayName = "TODOContactName" + } + + UIDevice.current.isProximityMonitoringEnabled = false + if core.callsNb == 0 { + core.outputAudioDevice = core.defaultOutputAudioDevice + // disable this because I don't find anygood reason for it: _bluetoothAvailable = FALSE; + // furthermore it introduces a bug when calling multiple times since route may not be + // reconfigured between cause leading to bluetooth being disabled while it should not + // bluetoothEnabled = false + } + + if UIApplication.shared.applicationState != .active && (callLog == nil || callLog?.status == .Missed || callLog?.status == .Aborted || callLog?.status == .EarlyAborted) { + // Configure the notification's payload. + let content = UNMutableNotificationContent() + content.title = NSString.localizedUserNotificationString(forKey: NSLocalizedString("Missed call", comment: ""), arguments: nil) + content.body = NSString.localizedUserNotificationString(forKey: displayName, arguments: nil) + + // Deliver the notification. + let request = UNNotificationRequest(identifier: "call_request", content: content, trigger: nil) // Schedule the notification. + let center = UNUserNotificationCenter.current() + center.add(request) { (error: Error?) in + if error != nil { + Log.info("Error while adding notification request : \(error!.localizedDescription)") + } + } + } + + if TelecomManager.callKitEnabled(core: core) { + var uuid = providerDelegate.uuids["\(callId)"] + if callId == referedToCall { + // refered call ended before connecting + Log.info("Callkit: end refered to call: \(String(describing: referedToCall))") + referedFromCall = nil + referedToCall = nil + } + if uuid == nil { + // the call not yet connected + uuid = providerDelegate.uuids[""] + } + if uuid != nil { + if callId == referedFromCall { + Log.info("Callkit: end refered from call: \(String(describing: referedFromCall))") + referedFromCall = nil + let callInfo = providerDelegate.callInfos[uuid!] + callInfo!.callId = referedToCall ?? "" + providerDelegate.callInfos.updateValue(callInfo!, forKey: uuid!) + providerDelegate.uuids.removeValue(forKey: callId) + providerDelegate.uuids.updateValue(uuid!, forKey: callInfo!.callId) + referedToCall = nil + break + } + if endCallKitReplacedCall { + let transaction = CXTransaction(action: CXEndCallAction(call: uuid!)) + requestTransaction(transaction, action: "endCall") + } else { + endCallKitReplacedCall = true + } + + } + } + case .Released: + TelecomManager.setAppData(sCall: call, appData: nil) + case .Referred: + referedFromCall = call.callLog?.callId + default: + break + } + + 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 { + AudioRouteUtils.routeAudioToSpeaker(core: core, call: call) + } else if AudioRouteUtils.isBluetoothAvailable(core: core) { + // Use bluetooth device by default if one is available + AudioRouteUtils.routeAudioToBluetooth(core: core, call: call) + } + } + } + // post Notification kLinphoneCallUpdate + NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self, userInfo: [ + AnyHashable("call"): NSValue.init(pointer: UnsafeRawPointer(call.getCobject)), + AnyHashable("state"): NSNumber(value: cstate.rawValue), + AnyHashable("message"): message + ]) + } +} + +// swiftlint:enable cyclomatic_complexity diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index 19e7c9668..a16ea380e 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -49,7 +49,7 @@ class HistoryListViewModel: ObservableObject { self.callLogsTmp.append(log) } } - + /* DispatchQueue.main.async { self.coreDelegate = CoreDelegateStub( onCallLogUpdated: { (_: Core, _: CallLog) -> Void in @@ -71,6 +71,7 @@ class HistoryListViewModel: ObservableObject { core.addDelegate(delegate: self.coreDelegate!) } } + */ } } From 035149bd47074869e75edd5636fbf286f16c6b72 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 11 Dec 2023 17:00:01 +0100 Subject: [PATCH 02/10] Fix callbacks --- Linphone/Core/CoreContext.swift | 15 ++++++ Linphone/Localizable.xcstrings | 6 +++ .../Contacts/Model/ContactAvatarModel.swift | 19 ++++++++ Linphone/UI/Main/Fragments/SideMenu.swift | 33 ------------- .../ViewModel/HistoryListViewModel.swift | 46 +++++-------------- 5 files changed, 52 insertions(+), 67 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index f2592ae60..10e723c1e 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -20,6 +20,7 @@ // swiftlint:disable large_tuple import linphonesw import Combine +import UniformTypeIdentifiers final class CoreContext: ObservableObject { @@ -156,6 +157,20 @@ final class CoreContext: ObservableObject { TelecomManager.shared.onCallStateChanged(core: cbVal.core, call: cbVal.call, state: cbVal.state, message: cbVal.message) } + 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, + forPasteboardType: UTType.plainText.identifier + ) + + ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" + ToastViewModel.shared.displayToast.toggle() + } + } + self.mIteratePublisher = Timer.publish(every: 0.02, on: .main, in: .common) .autoconnect() .receive(on: coreQueue) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 569f38f71..b3982d479 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -217,6 +217,9 @@ }, "Contacts" : { + }, + "Content" : { + }, "Continue" : { @@ -486,6 +489,9 @@ }, "This contact will be deleted definitively." : { + }, + "Title" : { + }, "TLS" : { diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 777b12e8f..3c6bc2d26 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -64,6 +64,24 @@ class ContactAvatarModel: ObservableObject { } func addDelegate() { + + /* + self.friend?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (Friend)) in + print("publisherpublisher onLogCollectionUploadStateChanged \(cbValue.address?.asStringUriOnly())") + + self.presenceStatus = cbValue.consolidatedPresence + if cbValue.consolidatedPresence == .Online || cbValue.consolidatedPresence == .Busy { + if cbValue.consolidatedPresence == .Online || cbValue.presenceModel!.latestActivityTimestamp != -1 { + self.lastPresenceInfo = cbValue.consolidatedPresence == .Online ? "Online" : self.getCallTime(startDate: cbValue.presenceModel!.latestActivityTimestamp) + } else { + self.lastPresenceInfo = "Away" + } + } else { + self.lastPresenceInfo = "" + } + } + */ + let newFriendDelegate = FriendDelegateStub( onPresenceReceived: { (linphoneFriend: Friend) -> Void in DispatchQueue.main.sync { @@ -80,6 +98,7 @@ class ContactAvatarModel: ObservableObject { } } ) + friendDelegate = newFriendDelegate if friendDelegate != nil { diff --git a/Linphone/UI/Main/Fragments/SideMenu.swift b/Linphone/UI/Main/Fragments/SideMenu.swift index 60f3c9606..3c65be7d5 100644 --- a/Linphone/UI/Main/Fragments/SideMenu.swift +++ b/Linphone/UI/Main/Fragments/SideMenu.swift @@ -25,8 +25,6 @@ struct SideMenu: View { @ObservedObject private var coreContext = CoreContext.shared - @State private var coreDelegate: CoreDelegate? - let width: CGFloat let isOpen: Bool let menuClose: () -> Void @@ -75,37 +73,6 @@ struct SideMenu: View { func sendLogs() { coreContext.doOnCoreQueue { core in core.uploadLogCollection() - - let newCoreDelegate = CoreDelegateStub( - onLogCollectionUploadStateChanged: { core, logCollectionUploadState, logString in - - if logString.starts(with: "https") { - UIPasteboard.general.setValue( - logString, - forPasteboardType: UTType.plainText.identifier - ) - - removeAllDelegate() - - ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" - ToastViewModel.shared.displayToast.toggle() - } - } - ) - - coreDelegate = newCoreDelegate - if coreDelegate != nil { - core.addDelegate(delegate: coreDelegate!) - } - } - } - - func removeAllDelegate() { - coreContext.doOnCoreQueue { core in - if coreDelegate != nil { - core.removeDelegate(delegate: coreDelegate!) - coreDelegate = nil - } } } } diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index a16ea380e..974710f9a 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -26,12 +26,9 @@ class HistoryListViewModel: ObservableObject { @Published var callLogs: [CallLog] = [] var callLogsTmp: [CallLog] = [] - @Published private var coreDelegate: CoreDelegate? - var callLogsAddressToDelete = "" init() { - removeAllDelegate() computeCallLogsList() } @@ -49,29 +46,20 @@ class HistoryListViewModel: ObservableObject { self.callLogsTmp.append(log) } } - /* - DispatchQueue.main.async { - self.coreDelegate = CoreDelegateStub( - onCallLogUpdated: { (_: Core, _: CallLog) -> Void in - DispatchQueue.main.sync { - let account = core.defaultAccount - let logs = account != nil ? account!.callLogs : core.callLogs - - self.callLogs.removeAll() - self.callLogsTmp.removeAll() - - logs.forEach { log in - self.callLogs.append(log) - self.callLogsTmp.append(log) - } - } - } - ) - if self.coreDelegate != nil { - core.addDelegate(delegate: self.coreDelegate!) + + core.publisher?.onCallLogUpdated?.postOnMainQueue { (_: (_: Core, _: CallLog)) in + print("publisherpublisher onCallLogUpdated") + let account = core.defaultAccount + let logs = account != nil ? account!.callLogs : core.callLogs + + self.callLogs.removeAll() + self.callLogsTmp.removeAll() + + logs.forEach { log in + self.callLogs.append(log) + self.callLogsTmp.append(log) } } - */ } } @@ -210,14 +198,4 @@ class HistoryListViewModel: ObservableObject { let indexTmp = self.callLogsTmp.firstIndex(where: {$0.callId == callLog.callId}) self.callLogsTmp.remove(at: indexTmp!) } - - func removeAllDelegate() { - coreContext.doOnCoreQueue { core in - if self.coreDelegate != nil { - core.removeDelegate(delegate: self.coreDelegate!) - self.coreDelegate = nil - } - } - } - } From f7f9ee32b6caa9ea7816158cb48e9b1c11067d66 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 12 Dec 2023 16:26:52 +0100 Subject: [PATCH 03/10] Init outgoing call --- Linphone.xcodeproj/project.pbxproj | 28 ++ Linphone/Localizable.xcstrings | 9 + .../TelecomManager/ProviderDelegate.swift | 13 +- Linphone/TelecomManager/TelecomManager.swift | 49 +++- Linphone/UI/Call/CallView.swift | 276 ++++++++++++++++++ .../UI/Call/ViewModel/CallViewModel.swift | 55 ++++ .../ContactInnerActionsFragment.swift | 72 ++--- .../Fragments/ContactInnerFragment.swift | 7 +- .../Fragments/ContactsInnerFragment.swift | 2 +- .../Fragments/ContactsListFragment.swift | 35 ++- .../FavoriteContactsListFragment.swift | 28 +- Linphone/UI/Main/ContentView.swift | 7 + Linphone/UI/Main/Fragments/SideMenu.swift | 2 +- .../History/Fragments/DialerBottomSheet.swift | 28 +- .../Fragments/HistoryContactFragment.swift | 17 +- .../Fragments/HistoryListFragment.swift | 46 +-- .../History/Fragments/StartCallFragment.swift | 48 ++- .../ViewModel/HistoryListViewModel.swift | 16 +- .../ViewModel/StartCallViewModel.swift | 8 +- Linphone/Utils/ActivityIndicator.swift | 33 +++ Linphone/Utils/Avatar.swift | 9 +- 21 files changed, 634 insertions(+), 154 deletions(-) create mode 100644 Linphone/UI/Call/CallView.swift create mode 100644 Linphone/UI/Call/ViewModel/CallViewModel.swift create mode 100644 Linphone/Utils/ActivityIndicator.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 901406bbe..ecccf8226 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -68,6 +68,9 @@ D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */; }; D7ADF6002AFE356400212231 /* Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADF5FF2AFE356400212231 /* Avatar.swift */; }; D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */; }; + D7B5678E2B28888F00DE63EB /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B5678D2B28888F00DE63EB /* CallView.swift */; }; + D7B99E992B29B39000BE7BF2 /* CallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */; }; + D7B99E9B2B29F7C300BE7BF2 /* ActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B99E9A2B29F7C200BE7BF2 /* ActivityIndicator.swift */; }; D7C365082AEFAB7F00FE6142 /* ContactListBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */; }; D7C3650A2AF001C300FE6142 /* EditContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C365092AF001C300FE6142 /* EditContactFragment.swift */; }; D7C3650C2AF0084000FE6142 /* EditContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C3650B2AF0084000FE6142 /* EditContactViewModel.swift */; }; @@ -157,6 +160,9 @@ D7A2EDDA2AC19EEC005D90FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; D7ADF5FF2AFE356400212231 /* Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Avatar.swift; sourceTree = ""; }; D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactInnerFragment.swift; sourceTree = ""; }; + D7B5678D2B28888F00DE63EB /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = ""; }; + D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallViewModel.swift; sourceTree = ""; }; + D7B99E9A2B29F7C200BE7BF2 /* ActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicator.swift; sourceTree = ""; }; D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListBottomSheet.swift; sourceTree = ""; }; D7C365092AF001C300FE6142 /* EditContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditContactFragment.swift; sourceTree = ""; }; D7C3650B2AF0084000FE6142 /* EditContactViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditContactViewModel.swift; sourceTree = ""; }; @@ -232,6 +238,7 @@ D74C9D002ACB098C0021626A /* PermissionManager.swift */, D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, D732A9082AFD235500DB42BA /* ShareSheetController.swift */, + D7B99E9A2B29F7C200BE7BF2 /* ActivityIndicator.swift */, ); path = Utils; sourceTree = ""; @@ -286,6 +293,7 @@ isa = PBXGroup; children = ( D719ABCA2ABC761800B41C10 /* Assistant */, + D7B5678C2B28883700DE63EB /* Call */, D719ABC62ABC6F0200B41C10 /* Main */, D7702EF02AC7200600557C00 /* Welcome */, ); @@ -473,6 +481,23 @@ path = Ressources; sourceTree = ""; }; + D7B5678C2B28883700DE63EB /* Call */ = { + isa = PBXGroup; + children = ( + D7B99E972B29B37F00BE7BF2 /* ViewModel */, + D7B5678D2B28888F00DE63EB /* CallView.swift */, + ); + path = Call; + sourceTree = ""; + }; + D7B99E972B29B37F00BE7BF2 /* ViewModel */ = { + isa = PBXGroup; + children = ( + D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; D7D24D0C2AC1B4C700C6F35B /* Fonts */ = { isa = PBXGroup; children = ( @@ -611,6 +636,7 @@ D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, D71FCA832AE14D6E00D2E43E /* ContactFragment.swift in Sources */, D7C3650C2AF0084000FE6142 /* EditContactViewModel.swift in Sources */, + D7B5678E2B28888F00DE63EB /* CallView.swift in Sources */, D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */, 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */, @@ -633,6 +659,7 @@ D7C48DF42AFA66F900D938CB /* EditContactController.swift in Sources */, 66C491FF2B24D4AC00CEA16D /* FileUtils.swift in Sources */, D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, + D7B99E992B29B39000BE7BF2 /* CallViewModel.swift in Sources */, D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */, D732A9152B04C7FE00DB42BA /* HistoryListViewModel.swift in Sources */, D71FCA7F2AE1397200D2E43E /* ContactsListViewModel.swift in Sources */, @@ -660,6 +687,7 @@ D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */, D726E43D2B19E4FE0083C415 /* StartCallFragment.swift in Sources */, + D7B99E9B2B29F7C300BE7BF2 /* ActivityIndicator.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */, D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index b3982d479..aadf76990 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -313,6 +313,9 @@ }, "I understand" : { + }, + "Incoming call" : { + }, "Incoming Call" : { @@ -352,6 +355,9 @@ }, "Message" : { + }, + "Missed call" : { + }, "My Profile" : { @@ -385,6 +391,9 @@ }, "Other actions" : { + }, + "Outgoing call" : { + }, "Outgoing Call" : { diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 348512a04..61443c185 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -24,6 +24,7 @@ import UIKit import linphonesw import AVFoundation import os +import SwiftUI class CallInfo { var callId: String = "" @@ -207,6 +208,12 @@ extension ProviderDelegate: CXProviderDelegate { let uuid = action.callUUID let callInfo = callInfos[uuid] let callId = callInfo?.callId ?? "" + + DispatchQueue.main.async { + withAnimation { + TelecomManager.shared.callInProgress = true + } + } CoreContext.shared.doOnCoreQueue { core in Log.info("CallKit: answer call with call-id: \(String(describing: callId)) and UUID: \(uuid.description).") @@ -225,7 +232,11 @@ extension ProviderDelegate: CXProviderDelegate { } TelecomManager.shared.callkitAudioSessionActivated = false core.configureAudioSession() - TelecomManager.shared.acceptCall(core: core, call: call!, hasVideo: call!.params?.videoEnabled ?? false) + + if call != nil { + TelecomManager.shared.acceptCall(core: core, call: call!, hasVideo: call!.params?.videoEnabled ?? false) + } + action.fulfill() } } diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index b92ae1eca..db1a80851 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -24,6 +24,7 @@ import UserNotifications import os import CallKit import AVFoundation +import SwiftUI class CallAppData: NSObject { var batteryWarningShown = false @@ -32,13 +33,16 @@ class CallAppData: NSObject { } -class TelecomManager { +class TelecomManager: ObservableObject { static let shared = TelecomManager() static var uuidReplacedCall: String? let providerDelegate: ProviderDelegate // to support callkit let callController: CXCallController // to support callkit + @Published var callInProgress: Bool = false + @Published var callStarted: Bool = false + var actionToFulFill: CXCallAction? var callkitAudioSessionActivated: Bool? var nextCallIsTransfer: Bool = false @@ -78,7 +82,17 @@ class TelecomManager { sCall.userData = UnsafeMutableRawPointer(Unmanaged.passRetained(appData!).toOpaque()) } } - + + func doCallWithCore(addr: Address) { + CoreContext.shared.doOnCoreQueue { core in + do { + try self.doCall(core: core, addr: addr, isSas: false, isVideo: false) + } catch { + + } + } + } + func doCall(core: Core, addr: Address, isSas: Bool, isVideo: Bool, isConference: Bool = false) throws { // let displayName = FastAddressBook.displayName(for: addr.getCobject) @@ -135,6 +149,13 @@ class TelecomManager { /* will be used later to notify user if video was not activated because of the linphone core*/ } } + + DispatchQueue.main.async { + self.callStarted = true + withAnimation { + self.callInProgress = true + } + } } } @@ -171,6 +192,10 @@ class TelecomManager { } try call.acceptWithParams(params: callParams) + + DispatchQueue.main.async { + self.callStarted = true + } } catch { Log.error("accept call failed \(error)") } @@ -195,7 +220,18 @@ class TelecomManager { } func incomingDisplayName(call: Call) -> String { - // TODO + if call.remoteAddress != nil { + let friend = ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!) + if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { + return friend!.address!.displayName! + } else { + if call.remoteAddress!.displayName != nil { + return call.remoteAddress!.displayName! + } else if call.remoteAddress!.username != nil { + return call.remoteAddress!.username! + } + } + } return "IncomingDisplayName" } @@ -361,6 +397,13 @@ class TelecomManager { } case .End, .Error: + + DispatchQueue.main.async { + withAnimation { + self.callInProgress = false + self.callStarted = false + } + } var displayName = "Unknown" if call.dir == .Incoming { displayName = incomingDisplayName(call: call) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift new file mode 100644 index 000000000..bc3baf8fa --- /dev/null +++ b/Linphone/UI/Call/CallView.swift @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2010-2020 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +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 + + @State var startDate = Date.now + @State var timeElapsed: Int = 0 + + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + VStack { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + + HStack { + if callViewModel.direction == .Outgoing { + Image("outgoing-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Outgoing call") + .foregroundStyle(.white) + } else { + Image("incoming-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Incoming call") + .foregroundStyle(.white) + } + + Spacer() + } + .frame(height: 40) + + ZStack { + VStack { + Spacer() + + if callViewModel.remoteAddress != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) + } + } else { + if callViewModel.remoteAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.displayName!, + lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.username ?? "Username Error", + lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + Text(callViewModel.displayName) + .padding(.top) + .foregroundStyle(.white) + + Text(callViewModel.remoteAddressString) + .foregroundStyle(.white) + + Spacer() + } + + if !telecomManager.callStarted { + VStack { + ActivityIndicator() + .frame(width: 20, height: 20) + .padding(.top, 100) + + Text(counterToMinutes()) + .onReceive(timer) { firedDate in + timeElapsed = Int(firedDate.timeIntervalSince(startDate)) + + } + .padding(.top) + .foregroundStyle(.white) + + Spacer() + } + .background(.clear) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray600) + .cornerRadius(20) + .padding(.horizontal, 4) + + if telecomManager.callStarted { + HStack(spacing: 12) { + Button { + 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 { + } label: { + Image("microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(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) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } else { + HStack(spacing: 12) { + + Spacer() + + Button { + 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) + + Button { + //telecomManager.callStarted.toggle() + } label: { + Image("phone") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.greenSuccess500) + .cornerRadius(40) + + Spacer() + } + .padding(.horizontal, 25) + .padding(.top, 20) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray900) + } + + func terminateCall() { + coreContext.doOnCoreQueue { core in + do { + // Terminates the call, whether it is ringing or running + try core.currentCall?.terminate() + } catch { NSLog(error.localizedDescription) } + } + timer.upstream.connect().cancel() + } + + 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)" + } + } +} + +#Preview { + CallView(callViewModel: CallViewModel()) +} diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift new file mode 100644 index 000000000..efb3ab588 --- /dev/null +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2010-2020 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import linphonesw + +class CallViewModel: ObservableObject { + + var coreContext = CoreContext.shared + var telecomManager = TelecomManager.shared + + @Published var displayName: String = "Example Linphone" + @Published var direction: Call.Dir = .Outgoing + @Published var remoteAddressString: String = "example.linphone@sip.linphone.org" + @Published var remoteAddress: Address? + @Published var avatarModel: ContactAvatarModel? + + init() { + coreContext.doOnCoreQueue { core in + if core.currentCall != nil && core.currentCall!.remoteAddress != nil { + DispatchQueue.main.async { + self.direction = .Incoming + self.remoteAddressString = String(core.currentCall!.remoteAddress!.asStringUriOnly().dropFirst(4)) + self.remoteAddress = core.currentCall!.remoteAddress! + + let friend = ContactsManager.shared.getFriendWithAddress(address: core.currentCall!.remoteAddress!) + if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { + self.displayName = friend!.address!.displayName! + } else { + if core.currentCall!.remoteAddress!.displayName != nil { + self.displayName = core.currentCall!.remoteAddress!.displayName! + } else if core.currentCall!.remoteAddress!.username != nil { + self.displayName = core.currentCall!.remoteAddress!.username! + } + } + } + } + } + } +} diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift index 99333cd53..0e256c39e 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -22,6 +22,7 @@ import SwiftUI struct ContactInnerActionsFragment: View { @ObservedObject var contactsManager = ContactsManager.shared + @ObservedObject private var telecomManager = TelecomManager.shared @ObservedObject var contactViewModel: ContactViewModel @ObservedObject var editContactViewModel: EditContactViewModel @@ -62,8 +63,7 @@ struct ContactInnerActionsFragment: View { VStack(spacing: 0) { if contactViewModel.indexDisplayedFriend != nil && contactsManager.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { ForEach(0.. Void + var body: some View { ForEach(0.. Date: Tue, 19 Dec 2023 17:34:59 +0100 Subject: [PATCH 04/10] Add startCall utilities to TelecomManager --- Linphone/TelecomManager/TelecomManager.swift | 39 ++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index db1a80851..76c6d0c2b 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -82,13 +82,46 @@ class TelecomManager: ObservableObject { sCall.userData = UnsafeMutableRawPointer(Unmanaged.passRetained(appData!).toOpaque()) } } - + + func startCall(core: Core, addr: Address?, isSas: Bool, isVideo: Bool, isConference: Bool = false) throws { + if addr == nil { + Log.info("Can not start a call with null address!") + return + } + + if TelecomManager.callKitEnabled(core: core) && !nextCallIsTransfer != true { + let uuid = UUID() + let name = "outgoingTODO" // FastAddressBook.displayName(for: addr) ?? "unknow" + let handle = CXHandle(type: .generic, value: addr?.asStringUriOnly() ?? "") + let startCallAction = CXStartCallAction(call: uuid, handle: handle) + let transaction = CXTransaction(action: startCallAction) + + let callInfo = CallInfo.newOutgoingCallInfo(addr: addr!, isSas: isSas, displayName: name, isVideo: isVideo, isConference: isConference) + providerDelegate.callInfos.updateValue(callInfo, forKey: uuid) + providerDelegate.uuids.updateValue(uuid, forKey: "") + + // setHeldOtherCalls(core: core, exceptCallid: "") + requestTransaction(transaction, action: "startCall") + } else { + try doCall(core: core, addr: addr!, isSas: isSas, isVideo: isVideo, isConference: isConference) + } + } + + func startCall(core: Core, addr: String, isSas: Bool = false, isVideo: Bool, isConference: Bool = false) { + do { + let address = try Factory.Instance.createAddress(addr: addr) + try startCall(core: core, addr: address, isSas: isSas, isVideo: isVideo, isConference: isConference) + } catch { + Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr) \(error) ") + } + } + func doCallWithCore(addr: Address) { CoreContext.shared.doOnCoreQueue { core in do { - try self.doCall(core: core, addr: addr, isSas: false, isVideo: false) + try self.startCall(core: core, addr: addr, isSas: false, isVideo: false) } catch { - + Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr.asStringUriOnly()) \(error) ") } } } From cc6d599ec5f2c6d73c8ce4f03f20e1fd8374660d Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 21 Dec 2023 14:06:52 +0100 Subject: [PATCH 05/10] Fixes --- Linphone/Contacts/ContactsManager.swift | 2 +- Linphone/Core/CoreContext.swift | 3 - Linphone/TelecomManager/TelecomManager.swift | 24 +- Linphone/UI/Call/CallView.swift | 588 ++++++++++++------ Linphone/UI/Main/ContentView.swift | 31 +- .../History/Fragments/DialerBottomSheet.swift | 18 +- .../Fragments/HistoryListFragment.swift | 12 +- Linphone/Utils/Avatar.swift | 64 +- 8 files changed, 479 insertions(+), 263 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 0b00df121..9cb3b0768 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -47,7 +47,6 @@ final class ContactsManager: ObservableObject { if core.globalState == GlobalState.Shutdown || core.globalState == GlobalState.Off { print("\(#function) - Core is being stopped or already destroyed, abort") } else { - do { self.friendList = try core.getFriendListByName(name: self.nativeAddressBookFriendList) ?? core.createFriendList() } catch let error { @@ -81,6 +80,7 @@ final class ContactsManager: ObservableObject { linphoneFriendList.displayName = self.linphoneAddressBookFriendList core.addFriendList(list: linphoneFriendList) } + linphoneFriendList.subscriptionsEnabled = true } } diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 10e723c1e..404098fed 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -86,12 +86,9 @@ final class CoreContext: ObservableObject { } self.mCore.autoIterateEnabled = false - self.mCore.friendsDatabasePath = "\(configDir)/friends.db" self.mCore.callkitEnabled = true self.mCore.pushNotificationEnabled = true - self.mCore.friendListSubscriptionEnabled = true - self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { self.defaultAccount = self.mCore.defaultAccount diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 76c6d0c2b..ce319cde3 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -83,13 +83,13 @@ class TelecomManager: ObservableObject { } } - func startCall(core: Core, addr: Address?, isSas: Bool, isVideo: Bool, isConference: Bool = false) throws { + func startCallCallKit(core: Core, addr: Address?, isSas: Bool, isVideo: Bool, isConference: Bool = false) throws { if addr == nil { Log.info("Can not start a call with null address!") return } - if TelecomManager.callKitEnabled(core: core) && !nextCallIsTransfer != true { + if TelecomManager.callKitEnabled(core: core) {//&& !nextCallIsTransfer != true { let uuid = UUID() let name = "outgoingTODO" // FastAddressBook.displayName(for: addr) ?? "unknow" let handle = CXHandle(type: .generic, value: addr?.asStringUriOnly() ?? "") @@ -110,7 +110,7 @@ class TelecomManager: ObservableObject { func startCall(core: Core, addr: String, isSas: Bool = false, isVideo: Bool, isConference: Bool = false) { do { let address = try Factory.Instance.createAddress(addr: addr) - try startCall(core: core, addr: address, isSas: isSas, isVideo: isVideo, isConference: isConference) + try startCallCallKit(core: core, addr: address, isSas: isSas, isVideo: isVideo, isConference: isConference) } catch { Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr) \(error) ") } @@ -118,11 +118,11 @@ class TelecomManager: ObservableObject { func doCallWithCore(addr: Address) { CoreContext.shared.doOnCoreQueue { core in - do { - try self.startCall(core: core, addr: addr, isSas: false, isVideo: false) - } catch { - Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr.asStringUriOnly()) \(error) ") - } + do { + try self.startCallCallKit(core: core, addr: addr, isSas: false, isVideo: false, isConference: false) + } catch { + Log.error("[TelecomManager] unable to create address for a new outgoing call : \(addr) \(error) ") + } } } @@ -329,6 +329,14 @@ class TelecomManager: ObservableObject { let addr = call.remoteAddress let displayName = incomingDisplayName(call: call) + #if targetEnvironment(simulator) + DispatchQueue.main.async { + withAnimation { + TelecomManager.shared.callInProgress = true + } + } + #endif + if call.replacedCall != nil { endCallKitReplacedCall = false diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index bc3baf8fa..5f3bc98bb 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -18,6 +18,7 @@ */ import SwiftUI +import CallKit struct CallView: View { @@ -29,10 +30,213 @@ struct CallView: View { @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() var body: some View { + if #available(iOS 16.4, *) { + innerView() + .background(.black) + .sheet(isPresented: .constant(true)) { + VStack { + HStack(spacing: 12) { + Button { + 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 { + muteCall() + } label: { + Image(micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(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) + } + .padding(.horizontal, 25) + .padding(.top, 20) + + HStack(spacing: 12) { + 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) + + Spacer() + + Button { + muteCall() + } label: { + Image(micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(micMutted ? .white : Color.gray500) + .cornerRadius(40) + + Spacer() + + 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) + + Spacer() + + 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) + } + .padding(.horizontal, 25) + .padding(.top, 20) + + HStack(spacing: 12) { + 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) + + Spacer() + + Button { + muteCall() + } label: { + Image(micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(micMutted ? .white : Color.gray500) + .cornerRadius(40) + + Spacer() + + 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) + + Spacer() + + 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) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } + .frame(maxHeight: .infinity, alignment: .top) + .background(.black) + .presentationDetents([.fraction(0.1), .medium]) + .interactiveDismissDisabled() + .presentationBackgroundInteraction(.enabled) + } + } + } + + @ViewBuilder + func innerView() -> some View { VStack { Rectangle() .foregroundColor(Color.orangeMain500) @@ -40,222 +244,200 @@ struct CallView: View { .frame(height: 0) HStack { - if callViewModel.direction == .Outgoing { - Image("outgoing-call") - .resizable() - .frame(width: 15, height: 15) - .padding(.horizontal) - - Text("Outgoing call") - .foregroundStyle(.white) - } else { - Image("incoming-call") - .resizable() - .frame(width: 15, height: 15) - .padding(.horizontal) - - Text("Incoming call") - .foregroundStyle(.white) - } - - Spacer() + if callViewModel.direction == .Outgoing { + Image("outgoing-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Outgoing call") + .foregroundStyle(.white) + } else { + Image("incoming-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Incoming call") + .foregroundStyle(.white) + } + + Spacer() } .frame(height: 40) - - ZStack { - VStack { - Spacer() - - if callViewModel.remoteAddress != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) - } - } else { - if callViewModel.remoteAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.displayName!, - lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.username ?? "Username Error", - lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - Text(callViewModel.displayName) - .padding(.top) - .foregroundStyle(.white) - - Text(callViewModel.remoteAddressString) - .foregroundStyle(.white) - - Spacer() - } - - if !telecomManager.callStarted { - VStack { - ActivityIndicator() - .frame(width: 20, height: 20) - .padding(.top, 100) - - Text(counterToMinutes()) - .onReceive(timer) { firedDate in - timeElapsed = Int(firedDate.timeIntervalSince(startDate)) - - } - .padding(.top) - .foregroundStyle(.white) - - Spacer() - } - .background(.clear) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray600) - .cornerRadius(20) - .padding(.horizontal, 4) - if telecomManager.callStarted { - HStack(spacing: 12) { - Button { - 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 { - } label: { - Image("microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(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) - } - .padding(.horizontal, 25) - .padding(.top, 20) - } else { - HStack(spacing: 12) { - - Spacer() - - Button { - 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) + ZStack { + VStack { + Spacer() + + if callViewModel.remoteAddress != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) + } + } else { + if callViewModel.remoteAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.displayName!, + lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.username ?? "Username Error", + lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + Text(callViewModel.displayName) + .padding(.top) + .foregroundStyle(.white) + + Text(callViewModel.remoteAddressString) + .foregroundStyle(.white) + + Spacer() + } + + if !telecomManager.callStarted { + VStack { + ActivityIndicator() + .frame(width: 20, height: 20) + .padding(.top, 100) + + Text(counterToMinutes()) + .onReceive(timer) { firedDate in + timeElapsed = Int(firedDate.timeIntervalSince(startDate)) - Button { - //telecomManager.callStarted.toggle() - } label: { - Image("phone") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.greenSuccess500) - .cornerRadius(40) - - Spacer() - } - .padding(.horizontal, 25) - .padding(.top, 20) - } + } + .padding(.top) + .foregroundStyle(.white) + + Spacer() + } + .background(.clear) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray600) + .cornerRadius(20) + .padding(.horizontal, 4) + + if telecomManager.callStarted { + HStack(spacing: 12) { + HStack { + } + .frame(width: 60, height: 60) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } else { + HStack(spacing: 12) { + + Spacer() + + Button { + 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) + + Button { + acceptCall() + } label: { + Image("phone") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.greenSuccess500) + .cornerRadius(40) + + Spacer() + } + .padding(.horizontal, 25) + .padding(.top, 20) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray900) - } + } func terminateCall() { + withAnimation { + telecomManager.callInProgress = false + telecomManager.callStarted = false + } + coreContext.doOnCoreQueue { core in - do { - // Terminates the call, whether it is ringing or running - try core.currentCall?.terminate() - } catch { NSLog(error.localizedDescription) } + 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 diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 905ef9799..cdd0ca2a4 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -446,19 +446,21 @@ struct ContentView: View { }) : ContactAvatarModel(friend: nil, withPresence: false) - HistoryContactFragment( - contactAvatarModel: contactAvatarModel!, - historyViewModel: historyViewModel, - historyListViewModel: historyListViewModel, - contactViewModel: contactViewModel, - editContactViewModel: editContactViewModel, - isShowDeleteAllHistoryPopup: $isShowDeleteAllHistoryPopup, - isShowEditContactFragment: $isShowEditContactFragment, - indexPage: $index - ) - .frame(maxWidth: .infinity) - .background(Color.gray100) - .ignoresSafeArea(.keyboard) + if contactAvatarModel != nil { + HistoryContactFragment( + contactAvatarModel: contactAvatarModel!, + historyViewModel: historyViewModel, + historyListViewModel: historyListViewModel, + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + isShowDeleteAllHistoryPopup: $isShowDeleteAllHistoryPopup, + isShowEditContactFragment: $isShowEditContactFragment, + indexPage: $index + ) + .frame(maxWidth: .infinity) + .background(Color.gray100) + .ignoresSafeArea(.keyboard) + } } } .onAppear { @@ -656,6 +658,9 @@ struct ContentView: View { coreContext.onForeground() if !isShowStartCallFragment { contactsManager.fetchContacts() + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + historyListViewModel.computeCallLogsList() + } } print("Active") } else if newPhase == .inactive { diff --git a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift index 92f932c73..dc3915be6 100644 --- a/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/DialerBottomSheet.swift @@ -231,12 +231,18 @@ struct DialerBottomSheet: View { .clipShape(Circle()) } } - .onTapGesture { - startCallViewModel.searchField += "0" - } - .onLongPressGesture(minimumDuration: 0.2) { - startCallViewModel.searchField += "+" - } + .simultaneousGesture( + LongPressGesture() + .onEnded { _ in + startCallViewModel.searchField += "+" + } + ) + .highPriorityGesture( + TapGesture() + .onEnded { _ in + startCallViewModel.searchField += "0" + } + ) Spacer() diff --git a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift index ccdfd4820..ceafcb884 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListFragment.swift @@ -51,6 +51,11 @@ struct HistoryListFragment: View { if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { if contactAvatarModel != nil { Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 45) + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) } } else { if historyListViewModel.callLogs[index].dir == .Outgoing && historyListViewModel.callLogs[index].toAddress != nil { @@ -95,7 +100,12 @@ struct HistoryListFragment: View { .frame(width: 45, height: 45) .clipShape(Circle()) } - } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 45, height: 45) + .clipShape(Circle()) + } } VStack(spacing: 0) { diff --git a/Linphone/Utils/Avatar.swift b/Linphone/Utils/Avatar.swift index 780eccc9d..8d44fa061 100644 --- a/Linphone/Utils/Avatar.swift +++ b/Linphone/Utils/Avatar.swift @@ -23,6 +23,7 @@ import linphonesw struct Avatar: View { @ObservedObject var contactAvatarModel: ContactAvatarModel + let avatarSize: CGFloat let hidePresence: Bool @@ -33,41 +34,48 @@ struct Avatar: View { } var body: some View { - AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: contactAvatarModel.friend!.photo!)) { image in - switch image { - case .empty: - ProgressView() - .frame(width: avatarSize, height: avatarSize) - case .success(let image): - ZStack { - image - .resizable() - .aspectRatio(contentMode: .fill) + if contactAvatarModel.friend != nil { + AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: contactAvatarModel.friend!.photo!)) { image in + switch image { + case .empty: + ProgressView() .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - HStack { - Spacer() - VStack { + case .success(let image): + ZStack { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + HStack { Spacer() - if !hidePresence && (contactAvatarModel.presenceStatus == .Online || contactAvatarModel.presenceStatus == .Busy) { - Image(contactAvatarModel.presenceStatus == .Online ? "presence-online" : "presence-busy") - .resizable() - .frame(width: avatarSize/4, height: avatarSize/4) - .padding(.trailing, avatarSize == 45 ? 1 : 3) - .padding(.bottom, avatarSize == 45 ? 1 : 3) + VStack { + Spacer() + if !hidePresence && (contactAvatarModel.presenceStatus == .Online || contactAvatarModel.presenceStatus == .Busy) { + Image(contactAvatarModel.presenceStatus == .Online ? "presence-online" : "presence-busy") + .resizable() + .frame(width: avatarSize/4, height: avatarSize/4) + .padding(.trailing, avatarSize == 45 ? 1 : 3) + .padding(.bottom, avatarSize == 45 ? 1 : 3) + } } } + .frame(width: avatarSize, height: avatarSize) } - .frame(width: avatarSize, height: avatarSize) + case .failure: + Image("profil-picture-default") + .resizable() + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + @unknown default: + EmptyView() } - case .failure: - Image("profil-picture-default") - .resizable() - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - @unknown default: - EmptyView() } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) } } } From 63d83b13f69d7bdf17f50a5f8e8312d29c0689ce Mon Sep 17 00:00:00 2001 From: "benoit.martins" Date: Wed, 27 Dec 2023 18:10:28 +0100 Subject: [PATCH 06/10] Fix bottom sheet in call view --- .../notebook.imageset/Contents.json | 21 + .../notebook.imageset/notebook.svg | 1 + .../screencast.imageset/Contents.json | 21 + .../screencast.imageset/screencast.svg | 1 + Linphone/Localizable.xcstrings | 21 + Linphone/UI/Call/CallView.swift | 850 +++++++++--------- 6 files changed, 510 insertions(+), 405 deletions(-) create mode 100644 Linphone/Assets.xcassets/notebook.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/notebook.imageset/notebook.svg create mode 100644 Linphone/Assets.xcassets/screencast.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/screencast.imageset/screencast.svg diff --git a/Linphone/Assets.xcassets/notebook.imageset/Contents.json b/Linphone/Assets.xcassets/notebook.imageset/Contents.json new file mode 100644 index 000000000..6a15bef13 --- /dev/null +++ b/Linphone/Assets.xcassets/notebook.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "notebook.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/notebook.imageset/notebook.svg b/Linphone/Assets.xcassets/notebook.imageset/notebook.svg new file mode 100644 index 000000000..6acc44dff --- /dev/null +++ b/Linphone/Assets.xcassets/notebook.imageset/notebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Linphone/Assets.xcassets/screencast.imageset/Contents.json b/Linphone/Assets.xcassets/screencast.imageset/Contents.json new file mode 100644 index 000000000..945a4b5b5 --- /dev/null +++ b/Linphone/Assets.xcassets/screencast.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "screencast.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/screencast.imageset/screencast.svg b/Linphone/Assets.xcassets/screencast.imageset/screencast.svg new file mode 100644 index 000000000..c3befc548 --- /dev/null +++ b/Linphone/Assets.xcassets/screencast.imageset/screencast.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index aadf76990..d6a578052 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -190,6 +190,9 @@ }, "Call history" : { + }, + "Call list" : { + }, "Calls" : { @@ -265,6 +268,9 @@ }, "Display Name" : { + }, + "Disposition" : { + }, "Do you really want to delete all calls history?" : { @@ -355,6 +361,9 @@ }, "Message" : { + }, + "Messages" : { + }, "Missed call" : { @@ -397,6 +406,9 @@ }, "Outgoing Call" : { + }, + "Participants" : { + }, "password" : { "extractionState" : "manual", @@ -414,6 +426,9 @@ } } } + }, + "Pause" : { + }, "Personnalize your profil mode" : { @@ -435,6 +450,9 @@ }, "QR code validated!" : { + }, + "Record" : { + }, "Register" : { @@ -447,6 +465,9 @@ }, "Scan QR code" : { + }, + "Screen share" : { + }, "Search contact or history call" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 5f3bc98bb..821a66cbb 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -23,428 +23,468 @@ import CallKit struct CallView: View { @ObservedObject private var coreContext = CoreContext.shared - @ObservedObject private var telecomManager = TelecomManager.shared + @ObservedObject private var telecomManager = TelecomManager.shared @ObservedObject private var contactsManager = ContactsManager.shared @ObservedObject var callViewModel: CallViewModel @State var startDate = Date.now @State var timeElapsed: Int = 0 - @State var micMutted: Bool = false + @State var micMutted: Bool = false let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - + var body: some View { - if #available(iOS 16.4, *) { - innerView() - .background(.black) - .sheet(isPresented: .constant(true)) { - VStack { - HStack(spacing: 12) { - Button { - 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 { - muteCall() - } label: { - Image(micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(micMutted ? .black : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(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) - } - .padding(.horizontal, 25) - .padding(.top, 20) - - HStack(spacing: 12) { - 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) - - Spacer() - - Button { - muteCall() - } label: { - Image(micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(micMutted ? .black : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(micMutted ? .white : Color.gray500) - .cornerRadius(40) - - Spacer() - - 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) - - Spacer() - - 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) - } - .padding(.horizontal, 25) - .padding(.top, 20) - - HStack(spacing: 12) { - 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) - - Spacer() - - Button { - muteCall() - } label: { - Image(micMutted ? "microphone-slash" : "microphone") - .renderingMode(.template) - .resizable() - .foregroundStyle(micMutted ? .black : .white) - .frame(width: 32, height: 32) - - } - .frame(width: 60, height: 60) - .background(micMutted ? .white : Color.gray500) - .cornerRadius(40) - - Spacer() - - 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) - - Spacer() - - 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) - } - .padding(.horizontal, 25) - .padding(.top, 20) - } - .frame(maxHeight: .infinity, alignment: .top) - .background(.black) - .presentationDetents([.fraction(0.1), .medium]) - .interactiveDismissDisabled() - .presentationBackgroundInteraction(.enabled) - } - } + GeometryReader { geo in + if #available(iOS 16.4, *) { + innerView() + .sheet(isPresented: $telecomManager.callStarted) { + GeometryReader { _ in + VStack(spacing: 0) { + HStack(spacing: 12) { + Button { + 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 { + muteCall() + } label: { + Image(micMutted ? "microphone-slash" : "microphone") + .renderingMode(.template) + .resizable() + .foregroundStyle(micMutted ? .black : .white) + .frame(width: 32, height: 32) + + } + .frame(width: 60, height: 60) + .background(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: geo.size.height * 0.15) + .padding(.horizontal, 20) + + HStack(spacing: 0) { + VStack { + Button { + } label: { + Image("screencast") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Screen share") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("users") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Participants") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("chat-teardrop-text") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Messages") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("notebook") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Disposition") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + } + .frame(height: geo.size.height * 0.15) + + HStack(spacing: 0) { + VStack { + Button { + } label: { + Image("phone-call") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Call list") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("pause") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Pause") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + Button { + } label: { + Image("record-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + } + .frame(width: 60, height: 60) + .background(Color.gray500) + .cornerRadius(40) + + Text("Record") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + + VStack { + 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) + + Text("Disposition") + .foregroundStyle(.white) + .default_text_style(styleSize: 15) + } + .frame(width: geo.size.width * 0.25, height: geo.size.width * 0.25) + .hidden() + } + .frame(height: geo.size.height * 0.15) + + Spacer() + } + .frame(maxHeight: .infinity, alignment: .top) + .background(.black) + .presentationDetents([.fraction(0.1), .medium]) + .interactiveDismissDisabled() + .presentationBackgroundInteraction(.enabled) + } + } + } + } + } + + @ViewBuilder + func innerView() -> some View { + VStack { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + + HStack { + if callViewModel.direction == .Outgoing { + Image("outgoing-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Outgoing call") + .foregroundStyle(.white) + } else { + Image("incoming-call") + .resizable() + .frame(width: 15, height: 15) + .padding(.horizontal) + + Text("Incoming call") + .foregroundStyle(.white) + } + + Spacer() + } + .frame(height: 40) + + ZStack { + VStack { + Spacer() + + if callViewModel.remoteAddress != nil { + let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) + + let contactAvatarModel = addressFriend != nil + ? ContactsManager.shared.avatarListModel.first(where: { + ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) + && $0.friend!.name == addressFriend!.name + && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() + }) + : ContactAvatarModel(friend: nil, withPresence: false) + + if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { + if contactAvatarModel != nil { + Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) + } + } else { + if callViewModel.remoteAddress!.displayName != nil { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.displayName!, + lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + + } else { + Image(uiImage: contactsManager.textToImage( + firstName: callViewModel.remoteAddress!.username ?? "Username Error", + lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 + ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] + : "")) + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + } + } else { + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + Text(callViewModel.displayName) + .padding(.top) + .foregroundStyle(.white) + + Text(callViewModel.remoteAddressString) + .foregroundStyle(.white) + + Spacer() + } + + if !telecomManager.callStarted { + VStack { + ActivityIndicator() + .frame(width: 20, height: 20) + .padding(.top, 100) + + Text(counterToMinutes()) + .onReceive(timer) { firedDate in + timeElapsed = Int(firedDate.timeIntervalSince(startDate)) + + } + .padding(.top) + .foregroundStyle(.white) + + Spacer() + } + .background(.clear) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray600) + .cornerRadius(20) + .padding(.horizontal, 4) + + if telecomManager.callStarted { + HStack(spacing: 12) { + HStack { + } + .frame(height: 60) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } else { + HStack(spacing: 12) { + HStack { + Spacer() + + Button { + 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) + + Button { + acceptCall() + } label: { + Image("phone") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + } + .frame(width: 90, height: 60) + .background(Color.greenSuccess500) + .cornerRadius(40) + + Spacer() + } + .frame(height: 60) + } + .padding(.horizontal, 25) + .padding(.top, 20) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray900) } - - @ViewBuilder - func innerView() -> some View { - VStack { - Rectangle() - .foregroundColor(Color.orangeMain500) - .edgesIgnoringSafeArea(.top) - .frame(height: 0) - - HStack { - if callViewModel.direction == .Outgoing { - Image("outgoing-call") - .resizable() - .frame(width: 15, height: 15) - .padding(.horizontal) - - Text("Outgoing call") - .foregroundStyle(.white) - } else { - Image("incoming-call") - .resizable() - .frame(width: 15, height: 15) - .padding(.horizontal) - - Text("Incoming call") - .foregroundStyle(.white) - } - - Spacer() - } - .frame(height: 40) - - ZStack { - VStack { - Spacer() - - if callViewModel.remoteAddress != nil { - let addressFriend = contactsManager.getFriendWithAddress(address: callViewModel.remoteAddress!) - - let contactAvatarModel = addressFriend != nil - ? ContactsManager.shared.avatarListModel.first(where: { - ($0.friend!.consolidatedPresence == .Online || $0.friend!.consolidatedPresence == .Busy) - && $0.friend!.name == addressFriend!.name - && $0.friend!.address!.asStringUriOnly() == addressFriend!.address!.asStringUriOnly() - }) - : ContactAvatarModel(friend: nil, withPresence: false) - - if addressFriend != nil && addressFriend!.photo != nil && !addressFriend!.photo!.isEmpty { - if contactAvatarModel != nil { - Avatar(contactAvatarModel: contactAvatarModel!, avatarSize: 100, hidePresence: true) - } - } else { - if callViewModel.remoteAddress!.displayName != nil { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.displayName!, - lastName: callViewModel.remoteAddress!.displayName!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.displayName!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - - } else { - Image(uiImage: contactsManager.textToImage( - firstName: callViewModel.remoteAddress!.username ?? "Username Error", - lastName: callViewModel.remoteAddress!.username!.components(separatedBy: " ").count > 1 - ? callViewModel.remoteAddress!.username!.components(separatedBy: " ")[1] - : "")) - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - } - } else { - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - - Text(callViewModel.displayName) - .padding(.top) - .foregroundStyle(.white) - - Text(callViewModel.remoteAddressString) - .foregroundStyle(.white) - - Spacer() - } - - if !telecomManager.callStarted { - VStack { - ActivityIndicator() - .frame(width: 20, height: 20) - .padding(.top, 100) - - Text(counterToMinutes()) - .onReceive(timer) { firedDate in - timeElapsed = Int(firedDate.timeIntervalSince(startDate)) - - } - .padding(.top) - .foregroundStyle(.white) - - Spacer() - } - .background(.clear) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.gray600) - .cornerRadius(20) - .padding(.horizontal, 4) - - if telecomManager.callStarted { - HStack(spacing: 12) { - HStack { - } - .frame(width: 60, height: 60) - } - .padding(.horizontal, 25) - .padding(.top, 20) - } else { - HStack(spacing: 12) { - - Spacer() - - Button { - 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) - - Button { - acceptCall() - } label: { - Image("phone") - .renderingMode(.template) - .resizable() - .foregroundStyle(.white) - .frame(width: 32, height: 32) - - } - .frame(width: 90, height: 60) - .background(Color.greenSuccess500) - .cornerRadius(40) - - Spacer() - } - .padding(.horizontal, 25) - .padding(.top, 20) - } - } - .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!) - } + 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 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)) - + 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 { From 81448d80065eeb77fe2efd1f90e385a51782330f Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 28 Dec 2023 16:53:47 +0100 Subject: [PATCH 07/10] 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) From d0ae11c880a7c162f7a846c0f4db085eea26fc03 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 2 Jan 2024 17:27:16 +0100 Subject: [PATCH 08/10] Update publishers to manage the subscriptions manually --- Linphone/Core/CoreContext.swift | 36 ++++++++------- .../Contacts/Model/ContactAvatarModel.swift | 46 ++++--------------- .../ViewModel/HistoryListViewModel.swift | 5 +- Linphone/Utils/MagicSearchSingleton.swift | 7 ++- 4 files changed, 38 insertions(+), 56 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 09270fc31..d52b91ba7 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -18,6 +18,8 @@ */ // swiftlint:disable large_tuple +// swiftlint:disable line_length + import linphonesw import Combine import UniformTypeIdentifiers @@ -34,8 +36,9 @@ final class CoreContext: ObservableObject { @Published var coreIsStarted: Bool = false private var mCore: Core! - private var mIteratePublisher: AnyCancellable? - + private var mIterateSuscription: AnyCancellable? + private var mCoreSuscriptions = Set() + private init() { do { try initialiseCore() @@ -89,7 +92,7 @@ final class CoreContext: ObservableObject { self.mCore.callkitEnabled = true self.mCore.pushNotificationEnabled = true - self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onGlobalStateChanged?.postOnMainQueue { (cbVal: (core: Core, state: GlobalState, message: String)) in if cbVal.state == GlobalState.On { self.defaultAccount = self.mCore.defaultAccount self.coreIsStarted = true @@ -97,13 +100,13 @@ final class CoreContext: ObservableObject { self.defaultAccount = nil self.coreIsStarted = true } - } + }) try? self.mCore.start() // Create a Core listener to listen for the callback we need // In this case, we want to know about the account registration status - self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: Config.ConfiguringState, message: String)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onConfiguringStatus?.postOnMainQueue { (cbVal: (core: Core, status: Config.ConfiguringState, message: String)) in NSLog("New configuration state is \(cbVal.status) = \(cbVal.message)\n") if cbVal.status == Config.ConfiguringState.Successful { ToastViewModel.shared.toastMessage = "Successful" @@ -112,11 +115,9 @@ final class CoreContext: ObservableObject { ToastViewModel.shared.toastMessage = "Failed" ToastViewModel.shared.displayToast.toggle() } - } + }) - self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue {(cbVal: - (core: Core, account: Account, state: RegistrationState, message: String) - ) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in // If account has been configured correctly, we will go through Progress and Ok states // Otherwise, we will be Failed. NSLog("New registration state is \(cbVal.state) for user id " + @@ -135,7 +136,9 @@ final class CoreContext: ObservableObject { self.loggingInProgress = false self.loggedIn = false } - }.postOnCoreQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in + }) + + self.mCoreSuscriptions.insert(self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnCoreQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in // If registration failed, remove account from core if cbVal.state != .Ok && cbVal.state != .Progress { let params = cbVal.account.params @@ -148,13 +151,13 @@ final class CoreContext: ObservableObject { cbVal.core.clearAllAuthInfo() } TelecomManager.shared.onAccountRegistrationStateChanged(core: cbVal.core, account: cbVal.account, state: cbVal.state, message: cbVal.message) - } + }) - self.mCore.publisher?.onCallStateChanged?.postOnCoreQueue { (cbVal: (core: Core, call: Call, state: Call.State, message: String)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onCallStateChanged?.postOnCoreQueue { (cbVal: (core: Core, call: Call, state: Call.State, message: String)) in TelecomManager.shared.onCallStateChanged(core: cbVal.core, call: cbVal.call, state: cbVal.state, message: cbVal.message) - } + }) - self.mCore.publisher?.onLogCollectionUploadStateChanged?.postOnMainQueue { (cbValue: (_: Core, _: Core.LogCollectionUploadState, info: String)) in + self.mCoreSuscriptions.insert(self.mCore.publisher?.onLogCollectionUploadStateChanged?.postOnMainQueue { (cbValue: (_: Core, _: Core.LogCollectionUploadState, info: String)) in if cbValue.info.starts(with: "https") { UIPasteboard.general.setValue( cbValue.info, @@ -164,9 +167,9 @@ final class CoreContext: ObservableObject { ToastViewModel.shared.toastMessage = "Success_copied_into_clipboard" ToastViewModel.shared.displayToast.toggle() } - } + }) - self.mIteratePublisher = Timer.publish(every: 0.02, on: .main, in: .common) + self.mIterateSuscription = Timer.publish(every: 0.02, on: .main, in: .common) .autoconnect() .receive(on: coreQueue) .sink { _ in @@ -202,3 +205,4 @@ final class CoreContext: ObservableObject { } // swiftlint:enable large_tuple +// swiftlint:enable line_length diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index 3c6bc2d26..bfb3f2ae2 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -19,6 +19,7 @@ import Foundation import linphonesw +import Combine class ContactAvatarModel: ObservableObject { @@ -30,7 +31,7 @@ class ContactAvatarModel: ObservableObject { @Published var presenceStatus: ConsolidatedPresence - private var friendDelegate: FriendDelegate? + private var friendSuscription: AnyCancellable? init(friend: Friend?, withPresence: Bool?) { self.friend = friend @@ -51,22 +52,20 @@ class ContactAvatarModel: ObservableObject { self.lastPresenceInfo = "" } - if self.friendDelegate != nil { - self.friend!.removeDelegate(delegate: self.friendDelegate!) - self.friendDelegate = nil + if self.friendSuscription != nil { + self.friendSuscription = nil } - addDelegate() + addSubscription() } else { self.lastPresenceInfo = "" self.presenceStatus = .Offline } } - func addDelegate() { + func addSubscription() { - /* - self.friend?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (Friend)) in + friendSuscription = self.friend?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (Friend)) in print("publisherpublisher onLogCollectionUploadStateChanged \(cbValue.address?.asStringUriOnly())") self.presenceStatus = cbValue.consolidatedPresence @@ -80,37 +79,12 @@ class ContactAvatarModel: ObservableObject { self.lastPresenceInfo = "" } } - */ - - let newFriendDelegate = FriendDelegateStub( - onPresenceReceived: { (linphoneFriend: Friend) -> Void in - DispatchQueue.main.sync { - self.presenceStatus = linphoneFriend.consolidatedPresence - if linphoneFriend.consolidatedPresence == .Online || linphoneFriend.consolidatedPresence == .Busy { - if linphoneFriend.consolidatedPresence == .Online || linphoneFriend.presenceModel!.latestActivityTimestamp != -1 { - self.lastPresenceInfo = linphoneFriend.consolidatedPresence == .Online ? "Online" : self.getCallTime(startDate: linphoneFriend.presenceModel!.latestActivityTimestamp) - } else { - self.lastPresenceInfo = "Away" - } - } else { - self.lastPresenceInfo = "" - } - } - } - ) - - - friendDelegate = newFriendDelegate - if friendDelegate != nil { - friend!.addDelegate(delegate: friendDelegate!) - } } - func removeAllDelegate() { - if friendDelegate != nil { + func removeAllSuscription() { + if friendSuscription != nil { presenceStatus = .Offline - friend!.removeDelegate(delegate: friendDelegate!) - friendDelegate = nil + friendSuscription = nil } } diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index 6d0c6419a..310ae3cc6 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -18,6 +18,7 @@ */ import linphonesw +import Combine class HistoryListViewModel: ObservableObject { @@ -27,7 +28,7 @@ class HistoryListViewModel: ObservableObject { var callLogsTmp: [CallLog] = [] var callLogsAddressToDelete = "" - + var callLogSubscription : AnyCancellable? init() { computeCallLogsList() } @@ -47,7 +48,7 @@ class HistoryListViewModel: ObservableObject { } } - core.publisher?.onCallLogUpdated?.postOnCoreQueue { (_: (_: Core, _: CallLog)) in + self.callLogSubscription = core.publisher?.onCallLogUpdated?.postOnCoreQueue { (_: (_: Core, _: CallLog)) in print("publisherpublisher onCallLogUpdated") let account = core.defaultAccount let logs = account != nil ? account!.callLogs : core.callLogs diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 0cd349bb8..518bc4245 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -18,6 +18,7 @@ */ import linphonesw +import Combine final class MagicSearchSingleton: ObservableObject { @@ -40,6 +41,8 @@ final class MagicSearchSingleton: ObservableObject { @Published var allContact = false private var domainDefaultAccount = "" + var searchSubscription : AnyCancellable? + private init() { coreContext.doOnCoreQueue { core in self.domainDefaultAccount = core.defaultAccount?.params?.domain ?? "" @@ -47,7 +50,7 @@ final class MagicSearchSingleton: ObservableObject { self.magicSearch = try? core.createMagicSearch() self.magicSearch.limitedSearch = false - self.magicSearch.publisher?.onSearchResultsReceived?.postOnMainQueue { (magicSearch: MagicSearch) in + self.searchSubscription = self.magicSearch.publisher?.onSearchResultsReceived?.postOnMainQueue { (magicSearch: MagicSearch) in self.needUpdateLastSearchContacts = true var lastSearchFriend: [SearchResult] = [] @@ -72,7 +75,7 @@ final class MagicSearchSingleton: ObservableObject { }) self.contactsManager.avatarListModel.forEach { contactAvatarModel in - contactAvatarModel.removeAllDelegate() + contactAvatarModel.removeAllSuscription() } self.contactsManager.avatarListModel.removeAll() From bcf4eefe3520529c529a02dd6ba4bbaa11d44322 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Tue, 2 Jan 2024 17:33:54 +0100 Subject: [PATCH 09/10] Fix swiftlint warnings --- Linphone/Contacts/ContactsManager.swift | 2 +- Linphone/TelecomManager/ProviderDelegate.swift | 2 +- Linphone/TelecomManager/TelecomManager.swift | 8 ++++---- Linphone/UI/Call/CallView.swift | 2 -- .../Main/Contacts/Fragments/ContactsListFragment.swift | 2 +- .../UI/Main/Contacts/Model/ContactAvatarModel.swift | 2 +- Linphone/UI/Main/ContentView.swift | 6 +++--- .../Main/History/ViewModel/HistoryListViewModel.swift | 2 +- Linphone/Utils/Extensions/ConfigExtension.swift | 10 ++++------ Linphone/Utils/Log.swift | 5 ++--- Linphone/Utils/MagicSearchSingleton.swift | 2 +- Linphone/Utils/PermissionManager.swift | 3 +-- 12 files changed, 20 insertions(+), 26 deletions(-) diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 9cb3b0768..84efe85fd 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -262,7 +262,7 @@ final class ContactsManager: ObservableObject { return imagePath } - func awaitDataWrite(data: Data, name: String, prefix: String,completion: @escaping ((), String) -> Void) { + func awaitDataWrite(data: Data, name: String, prefix: String, completion: @escaping ((), String) -> Void) { let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first if directory != nil { diff --git a/Linphone/TelecomManager/ProviderDelegate.swift b/Linphone/TelecomManager/ProviderDelegate.swift index 61443c185..c12cbed92 100644 --- a/Linphone/TelecomManager/ProviderDelegate.swift +++ b/Linphone/TelecomManager/ProviderDelegate.swift @@ -272,7 +272,7 @@ extension ProviderDelegate: CXProviderDelegate { // attempt to resume another one. action.fulfill() } else { - if call?.conference != nil && core.callsNb ?? 0 > 1 {/* + if call?.conference != nil && core.callsNb > 1 {/* try TelecomManager.shared.lc?.enterConference() action.fulfill() NotificationCenter.default.post(name: Notification.Name("LinphoneCallUpdate"), object: self) diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 0fb2979d4..d21d7c525 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -89,7 +89,7 @@ class TelecomManager: ObservableObject { return } - if TelecomManager.callKitEnabled(core: core) {//&& !nextCallIsTransfer != true { + if TelecomManager.callKitEnabled(core: core) {// && !nextCallIsTransfer != true { let uuid = UUID() let name = "outgoingTODO" // FastAddressBook.displayName(for: addr) ?? "unknow" let handle = CXHandle(type: .generic, value: addr?.asStringUriOnly() ?? "") @@ -521,9 +521,9 @@ class TelecomManager: ObservableObject { break } - //AudioRouteUtils.isBluetoothAvailable(core: core) - //AudioRouteUtils.isHeadsetAudioRouteAvailable(core: core) - //AudioRouteUtils.isBluetoothAudioRouteAvailable(core: core) + // AudioRouteUtils.isBluetoothAvailable(core: core) + // AudioRouteUtils.isHeadsetAudioRouteAvailable(core: core) + // AudioRouteUtils.isBluetoothAudioRouteAvailable(core: core) /* let readyForRoutechange = callkitAudioSessionActivated == nil || (callkitAudioSessionActivated == true) diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 6b2f1e48f..937595fc3 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -98,10 +98,8 @@ struct CallView: View { 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 { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 9dc7d0604..41bfc686b 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -98,5 +98,5 @@ struct ContactsListFragment: View { } #Preview { - ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: {addr in }) + ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: {_ in }) } diff --git a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift index bfb3f2ae2..1346767aa 100644 --- a/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift +++ b/Linphone/UI/Main/Contacts/Model/ContactAvatarModel.swift @@ -66,7 +66,7 @@ class ContactAvatarModel: ObservableObject { func addSubscription() { friendSuscription = self.friend?.publisher?.onPresenceReceived?.postOnMainQueue { (cbValue: (Friend)) in - print("publisherpublisher onLogCollectionUploadStateChanged \(cbValue.address?.asStringUriOnly())") + print("publisherpublisher onLogCollectionUploadStateChanged \(cbValue.address?.asStringUriOnly() ?? "")") self.presenceStatus = cbValue.consolidatedPresence if cbValue.consolidatedPresence == .Online || cbValue.consolidatedPresence == .Busy { diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 4c81f0c7c..64f63dc5b 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -527,7 +527,7 @@ struct ContentView: View { showingDialer: $showingDialer ) .presentationDetents([.medium]) - //.interactiveDismissDisabled() + // .interactiveDismissDisabled() .presentationBackgroundInteraction(.enabled(upThrough: .medium)) } } else { @@ -666,10 +666,10 @@ struct ContentView: View { .transition(.scale.combined(with: .move(edge: .top))) } - //if sharedMainViewModel.displayToast { + // if sharedMainViewModel.displayToast { ToastView() .zIndex(3) - //} + // } } } .overlay { diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index 310ae3cc6..d08ad626c 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -28,7 +28,7 @@ class HistoryListViewModel: ObservableObject { var callLogsTmp: [CallLog] = [] var callLogsAddressToDelete = "" - var callLogSubscription : AnyCancellable? + var callLogSubscription: AnyCancellable? init() { computeCallLogsList() } diff --git a/Linphone/Utils/Extensions/ConfigExtension.swift b/Linphone/Utils/Extensions/ConfigExtension.swift index 3659c3eae..9d83cd891 100644 --- a/Linphone/Utils/Extensions/ConfigExtension.swift +++ b/Linphone/Utils/Extensions/ConfigExtension.swift @@ -17,8 +17,6 @@ * along with this program. If not, see . */ - - import Foundation import linphonesw @@ -26,10 +24,10 @@ import linphonesw extension Config { - private static var _instance : Config? + private static var _instance: Config? - public func getDouble(section:String, key:String, defaultValue:Double) -> Double { - if (self.hasEntry(section: section, key: key) != 1) { + public func getDouble(section: String, key: String, defaultValue: Double) -> Double { + if self.hasEntry(section: section, key: key) != 1 { return defaultValue } let stringValue = self.getString(section: section, key: key, defaultString: "") @@ -51,7 +49,7 @@ extension Config { static let appGroupName = "group.org.linphone.phone.logs" // Needs to be the same name in App Group (capabilities in ALL targets - app & extensions - content + service), can't be stored in the Config itself the Config needs this value to get created static let teamID = Config.get().getString(section: "app", key: "team_id", defaultString: "") - static let earlymediaContentExtensionCagetoryIdentifier = Config.get().getString(section: "app", key: "extension_category", defaultString: "") + static let earlymediaContentExtCatIdentifier = Config.get().getString(section: "app", key: "extension_category", defaultString: "") // Default values in app static let serveraddress = Config.get().getString(section: "app", key: "server", defaultString: "") diff --git a/Linphone/Utils/Log.swift b/Linphone/Utils/Log.swift index 25aecc8e5..bb4e61cb1 100644 --- a/Linphone/Utils/Log.swift +++ b/Linphone/Utils/Log.swift @@ -84,19 +84,18 @@ class Log: LoggingServiceDelegate { private func output(_ message: String, _ level: Int, _ domain: String = Bundle.main.bundleIdentifier!) { let log = "[\(domain)][\(levelToStrings[level] ?? "Unkown")] \(message)\n" if #available(iOS 10.0, *) { - os_log("%{public}@", type: levelToOSleLogLevel[level] ?? .info,log) + os_log("%{public}@", type: levelToOSleLogLevel[level] ?? .info, log) } else { NSLog(log) } } - func onLogMessageWritten(logService: linphonesw.LoggingService, domain: String, level: linphonesw.LogLevel, message: String) { output(message, level.rawValue, domain) } public class func stackTrace() { - Thread.callStackSymbols.forEach{ print($0) } + Thread.callStackSymbols.forEach { print($0) } } // Debug diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 518bc4245..b65c09bf5 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -41,7 +41,7 @@ final class MagicSearchSingleton: ObservableObject { @Published var allContact = false private var domainDefaultAccount = "" - var searchSubscription : AnyCancellable? + var searchSubscription: AnyCancellable? private init() { coreContext.doOnCoreQueue { core in diff --git a/Linphone/Utils/PermissionManager.swift b/Linphone/Utils/PermissionManager.swift index e19833012..fb3f72bcc 100644 --- a/Linphone/Utils/PermissionManager.swift +++ b/Linphone/Utils/PermissionManager.swift @@ -31,8 +31,7 @@ class PermissionManager: ObservableObject { private init() {} - - func getPermissions(){ + func getPermissions() { photoLibraryRequestPermission() cameraRequestPermission() contactsRequestPermission() From 111fef6603a1b940692a004678d122168bc19ebc Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Wed, 3 Jan 2024 16:34:04 +0100 Subject: [PATCH 10/10] Edit audio route and add an audio reminder --- Linphone/Localizable.xcstrings | 3 + Linphone/UI/Call/CallView.swift | 40 ++++------- .../UI/Call/ViewModel/CallViewModel.swift | 66 ++++--------------- 3 files changed, 30 insertions(+), 79 deletions(-) diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index b5524e746..467b2feee 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -316,6 +316,9 @@ }, "First name*" : { + }, + "Headphones" : { + }, "History has been deleted" : { diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 937595fc3..b9d952974 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -36,7 +36,6 @@ struct CallView: View { let pub = NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) @State var startDate = Date.now - @State var audioRouteIsSpeaker: Bool = false @State var audioRouteSheet: Bool = false @State var hideButtonsSheet: Bool = false @State var options: Int = 1 @@ -96,10 +95,6 @@ struct CallView: View { .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 { @@ -108,12 +103,9 @@ struct CallView: View { DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { audioRouteSheet = true } - } else { - audioRouteIsSpeaker = !audioRouteIsSpeaker - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) + try AVAudioSession.sharedInstance().overrideOutputAudioPort(AVAudioSession.sharedInstance().currentRoute.outputs.filter({ $0.portType.rawValue == "Speaker" }).isEmpty ? .speaker : .none) } catch _ { } @@ -310,17 +302,17 @@ struct CallView: View { 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) + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) + if callViewModel.isHeadPhoneAvailable() { + try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Receiver") }).first) + } else { + try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.first) + } } catch _ { } @@ -332,12 +324,12 @@ struct CallView: View { .foregroundStyle(.white) .frame(width: 25, height: 25, alignment: .leading) - Text("Earpiece") + Text(!callViewModel.isHeadPhoneAvailable() ? "Earpiece" : "Headphones") .default_text_style_white(styleSize: 15) Spacer() - Image("ear") + Image(!callViewModel.isHeadPhoneAvailable() ? "ear" : "headset") .renderingMode(.template) .resizable() .foregroundStyle(.white) @@ -349,10 +341,8 @@ struct CallView: View { Button(action: { options = 2 - audioRouteIsSpeaker = true - do { - try AVAudioSession.sharedInstance().overrideOutputAudioPort(audioRouteIsSpeaker ? .speaker : .none) + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) } catch _ { } @@ -381,12 +371,9 @@ struct CallView: View { 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) + try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) + try AVAudioSession.sharedInstance().setPreferredInput(AVAudioSession.sharedInstance().availableInputs?.filter({ $0.portType.rawValue.contains("Bluetooth") }).first) } catch _ { } @@ -653,12 +640,11 @@ struct CallView: View { } 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 + callViewModel.isHeadPhoneAvailable() ? "headset" : "speaker-slash" ) diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index c7ad9cca7..1cf74ad54 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -38,7 +38,14 @@ class CallViewModel: ObservableObject { let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() init() { - setupNotifications() + + do { + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat, options: .allowBluetooth) + try AVAudioSession.sharedInstance().setActive(true) + } catch _ { + + } + coreContext.doOnCoreQueue { core in if core.currentCall != nil && core.currentCall!.remoteAddress != nil { DispatchQueue.main.async { @@ -113,59 +120,14 @@ class CallViewModel: ObservableObject { } } - 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 { - + func isHeadPhoneAvailable() -> Bool { + guard let availableInputs = AVAudioSession.sharedInstance().availableInputs else {return false} + for inputDevice in availableInputs { + if inputDevice.portType == .headsetMic || inputDevice.portType == .headphones { + return true } - */ - 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 + return false } func getAudioRoute() -> Int {