diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 09f53b22c..45b15ff8a 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -13,8 +13,6 @@ import SessionUtilitiesKit import SessionSnodeKit public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { - @objc static let isEnabled = true - private let dependencies: Dependencies // MARK: - Metadata Properties @@ -87,7 +85,9 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { didSet { stateDidChange?() hasConnectedDidChange?() - updateCallDetailedStatus?("Call Connected") + updateCallDetailedStatus?( + mode == .offer ? Constants.call_connection_steps_sender[5] : Constants.call_connection_steps_receiver[4] + ) } } @@ -208,11 +208,13 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { } SNLog("[Calls] Did receive remote sdp.") - self.updateCallDetailedStatus?(self.mode == .offer ? "Received Answer" : "Received Call Offer") remoteSDP = sdp if hasStartedConnecting { webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally } + if mode == .answer { + self.updateCallDetailedStatus?(Constants.call_connection_steps_receiver[0]) + } } // MARK: - Actions @@ -253,7 +255,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { self.callInteractionId = interaction?.id - self.updateCallDetailedStatus?("Creating Call") + self.updateCallDetailedStatus?(Constants.call_connection_steps_sender[0]) try? webRTCSession .sendPreOffer( @@ -266,7 +268,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { // Start the timeout timer for the call .handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() }) .flatMap { [weak self] _ in - self?.updateCallDetailedStatus?("Sending Call Offer") + self?.updateCallDetailedStatus?(Constants.call_connection_steps_sender[1]) return webRTCSession .sendOffer(to: thread) .retry(5) @@ -276,7 +278,6 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { switch result { case .finished: SNLog("[Calls] Offer message sent") - self?.updateCallDetailedStatus?("Sending Connection Candidates") case .failure(let error): SNLog("[Calls] Error initializing call after 5 retries: \(error), ending call...") self?.handleCallInitializationFailed() @@ -292,7 +293,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { if let sdp = remoteSDP { SNLog("[Calls] Got remote sdp already") - self.updateCallDetailedStatus?("Sending Call Answer") + self.updateCallDetailedStatus?(Constants.call_connection_steps_receiver[1]) webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally } } @@ -305,21 +306,20 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { func endSessionCall() { guard !hasEnded else { return } + let sessionId: String = self.sessionId + webRTCSession.hangUp() - webRTCSession.endCall( - with: self.sessionId - ) - .sinkUntilComplete( - receiveCompletion: { [weak self] _ in - self?.hasEnded = true - Singleton.callManager.cleanUpPreviousCall() - } - ) + + Storage.shared.writeAsync { [weak self] db in + try self?.webRTCSession.endCall(db, with: sessionId) + } + + hasEnded = true } func handleCallInitializationFailed() { self.endSessionCall() - Singleton.callManager.reportCurrentCallEnded(reason: .failed) + Singleton.callManager.reportCurrentCallEnded(reason: nil) } // MARK: - Call Message Handling @@ -432,15 +432,27 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { isRemoteVideoEnabled = isEnabled } - public func iceCandidateDidSend() { + public func sendingIceCandidates() { DispatchQueue.main.async { - self.updateCallDetailedStatus?(self.mode == .offer ? "Awaiting Recipient Answer..." : "Awaiting Connection") + self.updateCallDetailedStatus?( + self.mode == .offer ? Constants.call_connection_steps_sender[2] : Constants.call_connection_steps_receiver[2] + ) + } + } + + public func iceCandidateDidSend() { + if self.mode == .offer { + DispatchQueue.main.async { + self.updateCallDetailedStatus?(Constants.call_connection_steps_sender[3]) + } } } public func iceCandidateDidReceive() { DispatchQueue.main.async { - self.updateCallDetailedStatus?("Handling Connection Candidates") + self.updateCallDetailedStatus?( + self.mode == .offer ? Constants.call_connection_steps_sender[4] : Constants.call_connection_steps_receiver[3] + ) } } diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index a7edd33e4..377c555ef 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -3,11 +3,12 @@ import UIKit import YYImage import MediaPlayer +import AVKit import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit -final class CallVC: UIViewController, VideoPreviewDelegate { +final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDelegate { private static let avatarRadius: CGFloat = (isIPhone6OrSmaller ? 100 : 120) private static let floatingVideoViewWidth: CGFloat = (UIDevice.current.isIPad ? 160 : 80) private static let floatingVideoViewHeight: CGFloat = (UIDevice.current.isIPad ? 346: 173) @@ -275,11 +276,20 @@ final class CallVC: UIViewController, VideoPreviewDelegate { return result }() - private lazy var volumeView: MPVolumeView = { - let result = MPVolumeView() - result.showsVolumeSlider = false - result.showsRouteButton = true - result.setRouteButtonImage( + private lazy var routePickerView: AVRoutePickerView = { + let result = AVRoutePickerView() + result.delegate = self + result.alpha = 0 + result.layer.cornerRadius = 30 + result.set(.width, to: 60) + result.set(.height, to: 60) + + return result + }() + + private lazy var routePickerButton: UIButton = { + let result = UIButton(type: .custom) + result.setImage( UIImage(named: "Speaker")? .withRenderingMode(.alwaysTemplate), for: .normal @@ -287,6 +297,20 @@ final class CallVC: UIViewController, VideoPreviewDelegate { result.themeTintColor = .textPrimary result.themeBackgroundColor = .backgroundSecondary result.layer.cornerRadius = 30 + result.addTarget(self, action: #selector(switchRoute), for: UIControl.Event.touchUpInside) + result.set(.width, to: 60) + result.set(.height, to: 60) + + return result + }() + + private lazy var routePickerContainer: UIView = { + let result = UIView() + result.addSubview(routePickerView) + routePickerView.pin(to: result) + result.addSubview(routePickerButton) + routePickerButton.pin(to: result) + result.layer.cornerRadius = 30 result.set(.width, to: 60) result.set(.height, to: 60) @@ -294,7 +318,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate { }() private lazy var operationPanel: UIStackView = { - let result = UIStackView(arrangedSubviews: [switchCameraButton, videoButton, switchAudioButton, volumeView]) + let result = UIStackView(arrangedSubviews: [switchCameraButton, videoButton, switchAudioButton, routePickerContainer]) result.axis = .horizontal result.spacing = Values.veryLargeSpacing @@ -590,7 +614,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate { self.switchAudioButton.transform = transform self.switchCameraButton.transform = transform self.videoButton.transform = transform - self.volumeView.transform = transform + self.routePickerContainer.transform = transform } } @@ -756,6 +780,17 @@ final class CallVC: UIViewController, VideoPreviewDelegate { } } + @objc private func switchRoute() { + simulateRoutePickerViewTapping() + } + + private func simulateRoutePickerViewTapping() { + guard let routeButton = routePickerView.subviews.first(where: { $0 is UIButton }) as? UIButton else { + return + } + routeButton.sendActions(for: .touchUpInside) + } + @objc private func audioRouteDidChange() { let currentSession = AVAudioSession.sharedInstance() let currentRoute = currentSession.currentRoute @@ -767,35 +802,35 @@ final class CallVC: UIViewController, VideoPreviewDelegate { switch currentOutput.portType { case .builtInSpeaker: let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate) - volumeView.setRouteButtonImage(image, for: .normal) - volumeView.themeTintColor = .backgroundSecondary - volumeView.themeBackgroundColor = .textPrimary + routePickerButton.setImage(image, for: .normal) + routePickerButton.themeTintColor = .backgroundSecondary + routePickerButton.themeBackgroundColor = .textPrimary case .headphones: let image = UIImage(named: "Headsets")?.withRenderingMode(.alwaysTemplate) - volumeView.setRouteButtonImage(image, for: .normal) - volumeView.themeTintColor = .backgroundSecondary - volumeView.themeBackgroundColor = .textPrimary + routePickerButton.setImage(image, for: .normal) + routePickerButton.themeTintColor = .backgroundSecondary + routePickerButton.themeBackgroundColor = .textPrimary case .bluetoothLE: fallthrough case .bluetoothA2DP: let image = UIImage(named: "Bluetooth")?.withRenderingMode(.alwaysTemplate) - volumeView.setRouteButtonImage(image, for: .normal) - volumeView.themeTintColor = .backgroundSecondary - volumeView.themeBackgroundColor = .textPrimary + routePickerButton.setImage(image, for: .normal) + routePickerButton.themeTintColor = .backgroundSecondary + routePickerButton.themeBackgroundColor = .textPrimary case .bluetoothHFP: let image = UIImage(named: "Airpods")?.withRenderingMode(.alwaysTemplate) - volumeView.setRouteButtonImage(image, for: .normal) - volumeView.themeTintColor = .backgroundSecondary - volumeView.themeBackgroundColor = .textPrimary + routePickerButton.setImage(image, for: .normal) + routePickerButton.themeTintColor = .backgroundSecondary + routePickerButton.themeBackgroundColor = .textPrimary case .builtInReceiver: fallthrough default: let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate) - volumeView.setRouteButtonImage(image, for: .normal) - volumeView.themeTintColor = .backgroundSecondary - volumeView.themeBackgroundColor = .textPrimary + routePickerButton.setImage(image, for: .normal) + routePickerButton.themeTintColor = .textPrimary + routePickerButton.themeBackgroundColor = .backgroundSecondary } } } @@ -809,4 +844,14 @@ final class CallVC: UIViewController, VideoPreviewDelegate { self.callDurationLabel.alpha = isHidden ? 1 : 0 } } + + // MARK: - AVRoutePickerViewDelegate + + func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) { + + } + + func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) { + + } } diff --git a/Session/Calls/Views & Modals/CallMissedTipsModal.swift b/Session/Calls/Views & Modals/CallMissedTipsModal.swift index 3a2df54bf..e08bf645f 100644 --- a/Session/Calls/Views & Modals/CallMissedTipsModal.swift +++ b/Session/Calls/Views & Modals/CallMissedTipsModal.swift @@ -72,10 +72,23 @@ final class CallMissedTipsModal: Modal { // MARK: - Lifecycle - init(caller: String) { + init(caller: String, presentingViewController: UIViewController?) { self.caller = caller - super.init() + super.init( + afterClosed: { + let navController: UINavigationController = StyledNavigationController( + rootViewController: SessionTableViewController( + viewModel: PrivacySettingsViewModel( + shouldShowCloseButton: true, + shouldAutomaticallyShowCallModal: true + ) + ) + ) + navController.modalPresentationStyle = .fullScreen + presentingViewController?.present(navController, animated: true, completion: nil) + } + ) self.modalPresentationStyle = .overFullScreen self.modalTransitionStyle = .crossDissolve @@ -86,7 +99,7 @@ final class CallMissedTipsModal: Modal { } override func populateContentView() { - cancelButton.setTitle("okay".localized(), for: .normal) + cancelButton.setTitle("sessionSettings".localized(), for: .normal) contentView.addSubview(mainStackView) tipsIconContainerView.addSubview(tipsIconImageView) diff --git a/Session/Calls/WebRTC/WebRTCSession.swift b/Session/Calls/WebRTC/WebRTCSession.swift index adc81af73..fac261ce7 100644 --- a/Session/Calls/WebRTC/WebRTCSession.swift +++ b/Session/Calls/WebRTC/WebRTCSession.swift @@ -12,6 +12,7 @@ public protocol WebRTCSessionDelegate: AnyObject { func webRTCIsConnected() func isRemoteVideoDidChange(isEnabled: Bool) + func sendingIceCandidates() func iceCandidateDidSend() func iceCandidateDidReceive() func dataChannelDidOpen() @@ -300,6 +301,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } private func sendICECandidates() { + self.delegate?.sendingIceCandidates() let candidates: [RTCIceCandidate] = self.queuedICECandidates let uuid: String = self.uuid let contactSessionId: String = self.contactSessionId @@ -472,13 +474,15 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { } extension WebRTCSession { - public func configureAudioSession(outputAudioPort: AVAudioSession.PortOverride = .none) { + public func configureAudioSession() { let audioSession = RTCAudioSession.sharedInstance() audioSession.lockForConfiguration() do { - try audioSession.setCategory(AVAudioSession.Category.playAndRecord) - try audioSession.setMode(AVAudioSession.Mode.voiceChat) - try audioSession.overrideOutputAudioPort(outputAudioPort) + try audioSession.setCategory( + .playAndRecord, + mode: .videoChat, + options: [.allowBluetooth, .allowBluetoothA2DP] + ) try audioSession.setActive(true) } catch let error { SNLog("Couldn't set up WebRTC audio session due to error: \(error)") diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 3e41f9174..25c01be5a 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -88,7 +88,6 @@ extension ConversationVC: // MARK: - Call @objc func startCall(_ sender: Any?) { - guard SessionCall.isEnabled else { return } guard viewModel.threadData.threadIsBlocked == false else { return } guard Storage.shared[.areCallsEnabled] else { let confirmationModal: ConfirmationModal = ConfirmationModal( @@ -102,7 +101,8 @@ extension ConversationVC: let navController: UINavigationController = StyledNavigationController( rootViewController: SessionTableViewController( viewModel: PrivacySettingsViewModel( - shouldShowCloseButton: true + shouldShowCloseButton: true, + shouldAutomaticallyShowCallModal: true ) ) ) @@ -116,7 +116,39 @@ extension ConversationVC: return } - Permissions.requestMicrophonePermissionIfNeeded() + guard Permissions.microphone == .granted else { + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "Permissions Required", + body: .text("Microphone access is required to make calls and record audio messages. Toggle the \"Microphone\" permission in Settings to continue."), + showCondition: .disabled, + confirmTitle: "sessionSettings".localized(), + onConfirm: { _ in + UIApplication.shared.openSystemSettings() + } + ) + ) + + self.navigationController?.present(confirmationModal, animated: true, completion: nil) + return + } + + guard Permissions.localNetwork == .granted else { + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "Permissions Required", + body: .text("Local Network access is required to facilitate calls. Toggle the \"Local Network\" permission in Settings to continue."), + showCondition: .disabled, + confirmTitle: "sessionSettings".localized(), + onConfirm: { _ in + UIApplication.shared.openSystemSettings() + } + ) + ) + + self.navigationController?.present(confirmationModal, animated: true, completion: nil) + return + } let threadId: String = self.viewModel.threadData.threadId @@ -916,12 +948,13 @@ extension ConversationVC: ), messageInfo.state == .permissionDeniedMicrophone else { - let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(caller: cellViewModel.authorName) + let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal( + caller: cellViewModel.authorName, + presentingViewController: self + ) present(callMissedTipsModal, animated: true, completion: nil) return } - - Permissions.requestMicrophonePermissionIfNeeded(presentingViewController: self) return } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 0eb05a8fd..ae1723b13 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1288,7 +1288,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa } else { let shouldHaveCallButton: Bool = ( - SessionCall.isEnabled && (threadData?.threadVariant ?? initialVariant) == .contact && (threadData?.threadIsNoteToSelf ?? initialIsNoteToSelf) == false && (threadData?.threadIsBlocked ?? initialIsBlocked) == false diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index fa66701c1..5744c3570 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -261,7 +261,6 @@ final class ConversationTitleView: UIView { // Contact threads also have the call button to compensate for let shouldShowCallButton: Bool = ( - SessionCall.isEnabled && !isNoteToSelf && threadVariant == .contact ) diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 06286f526..2dd96580a 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -269,6 +269,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD guard Singleton.hasAppContext else { return } Singleton.appContext.clearOldTemporaryDirectories() + + if Storage.shared[.areCallsEnabled] { + Permissions.checkLocalNetworkPermission() + } } func applicationWillResignActive(_ application: UIApplication) { @@ -803,7 +807,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD else { preconditionFailure() } let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal( - caller: Profile.displayName(id: callerId) + caller: Profile.displayName(id: callerId), + presentingViewController: presentingVC ) presentingVC.present(callMissedTipsModal, animated: true, completion: nil) diff --git a/Session/Meta/Session-Info.plist b/Session/Meta/Session-Info.plist index bd9e0d8cd..03f9a8add 100644 --- a/Session/Meta/Session-Info.plist +++ b/Session/Meta/Session-Info.plist @@ -152,5 +152,9 @@ UIViewControllerBasedStatusBarAppearance + NSBonjourServices + + _session_local_network_access_check._tcp + diff --git a/Session/Settings/PrivacySettingsViewModel.swift b/Session/Settings/PrivacySettingsViewModel.swift index ddb279b7c..afa77a82c 100644 --- a/Session/Settings/PrivacySettingsViewModel.swift +++ b/Session/Settings/PrivacySettingsViewModel.swift @@ -16,12 +16,14 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav public let state: TableDataState = TableDataState() public let observableState: ObservableTableSourceState = ObservableTableSourceState() private let shouldShowCloseButton: Bool + private let shouldAutomaticallyShowCallModal: Bool // MARK: - Initialization - init(shouldShowCloseButton: Bool = false, using dependencies: Dependencies = Dependencies()) { + init(shouldShowCloseButton: Bool = false, shouldAutomaticallyShowCallModal: Bool = false, using dependencies: Dependencies = Dependencies()) { self.dependencies = dependencies self.shouldShowCloseButton = shouldShowCloseButton + self.shouldAutomaticallyShowCallModal = shouldAutomaticallyShowCallModal } // MARK: - Config @@ -53,13 +55,16 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav } public enum TableItem: Differentiable { + case calls + case microphone + case camera + case localNetwork case screenLock case communityMessageRequests case screenshotNotifications case readReceipts case typingIndicators case linkPreviews - case calls } // MARK: - Navigation @@ -102,6 +107,108 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav } .mapWithPrevious { [dependencies] previous, current -> [SectionModel] in return [ + SectionModel( + model: .calls, + elements: [ + SessionCell.Info( + id: .calls, + title: "callsVoiceAndVideo".localized(), + subtitle: "callsVoiceAndVideoToggleDescription".localized(), + rightAccessory: .toggle( + .boolValue( + key: .areCallsEnabled, + value: current.areCallsEnabled, + oldValue: (previous ?? current).areCallsEnabled + ), + accessibility: Accessibility( + identifier: "Voice and Video Calls - Switch" + ) + ), + accessibility: Accessibility( + label: "Allow voice and video calls" + ), + confirmationInfo: ConfirmationModal.Info( + title: "callsVoiceAndVideoBeta".localized(), + body: .text("callsVoiceAndVideoModalDescription".localized()), + showCondition: .disabled, + confirmTitle: "theContinue".localized(), + confirmStyle: .danger, + cancelStyle: .alert_text, + onConfirm: { _ in + Permissions.requestMicrophonePermissionIfNeeded() + Permissions.requestCameraPermissionIfNeeded() + Permissions.requestLocalNetworkPermissionIfNeeded() + } + ), + onTap: { + Storage.shared.write { db in + try db.setAndUpdateConfig( + .areCallsEnabled, + to: !db[.areCallsEnabled], + using: dependencies + ) + } + } + ) + ].appending( + contentsOf: ( + !current.areCallsEnabled ? nil : + [ + SessionCell.Info( + id: .microphone, + title: "permissionsMicrophone".localized(), + subtitle: "Allow access to microphone for voice calls and audio messages", + rightAccessory: .toggle( + .staticBoolValue((Permissions.microphone == .granted)), + accessibility: Accessibility( + identifier: "Microphone Permission - Switch" + ) + ), + accessibility: Accessibility( + label: "Grant microphone permission" + ), + onTap: { + UIApplication.shared.openSystemSettings() + } + ), + SessionCell.Info( + id: .camera, + title: "contentDescriptionCamera".localized(), + subtitle: "Allow access to camera for video calls", + rightAccessory: .toggle( + .staticBoolValue((Permissions.camera == .granted)), + accessibility: Accessibility( + identifier: "Camera Permission - Switch" + ) + ), + accessibility: Accessibility( + label: "Grant camera permission" + ), + onTap: { + UIApplication.shared.openSystemSettings() + } + ), + SessionCell.Info( + id: .localNetwork, + title: "Local Network", + subtitle: "Allow access to local network to facilitate voice and video calls", + rightAccessory: .toggle( + .staticBoolValue((Permissions.localNetwork == .granted)), + accessibility: Accessibility( + identifier: "Local Network Permission - Switch" + ) + ), + accessibility: Accessibility( + label: "Grant local network permission" + ), + onTap: { + UIApplication.shared.openSystemSettings() + } + ) + ] + ) + ) + ), SectionModel( model: .screenSecurity, elements: [ @@ -299,48 +406,35 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav } ) ] - ), - SectionModel( - model: .calls, - elements: [ - SessionCell.Info( - id: .calls, - title: "callsVoiceAndVideo".localized(), - subtitle: "callsVoiceAndVideoToggleDescription".localized(), - rightAccessory: .toggle( - .boolValue( - key: .areCallsEnabled, - value: current.areCallsEnabled, - oldValue: (previous ?? current).areCallsEnabled - ), - accessibility: Accessibility( - identifier: "Voice and Video Calls - Switch" - ) - ), - accessibility: Accessibility( - label: "Allow voice and video calls" - ), - confirmationInfo: ConfirmationModal.Info( - title: "callsVoiceAndVideoBeta".localized(), - body: .text("callsVoiceAndVideoModalDescription".localized()), - showCondition: .disabled, - confirmTitle: "theContinue".localized(), - confirmStyle: .danger, - cancelStyle: .alert_text, - onConfirm: { _ in Permissions.requestMicrophonePermissionIfNeeded() } - ), - onTap: { - Storage.shared.write { db in - try db.setAndUpdateConfig( - .areCallsEnabled, - to: !db[.areCallsEnabled], - using: dependencies - ) - } - } - ) - ] ) ] } + + func onAppear(targetViewController: BaseVC) { + if self.shouldAutomaticallyShowCallModal { + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "callsVoiceAndVideoBeta".localized(), + body: .text("callsVoiceAndVideoModalDescription".localized()), + showCondition: .disabled, + confirmTitle: "theContinue".localized(), + confirmStyle: .danger, + cancelStyle: .alert_text, + onConfirm: { _ in + Permissions.requestMicrophonePermissionIfNeeded() + Permissions.requestCameraPermissionIfNeeded() + Permissions.requestLocalNetworkPermissionIfNeeded() + Storage.shared.write { db in + try db.setAndUpdateConfig( + .areCallsEnabled, + to: !db[.areCallsEnabled], + using: self.dependencies + ) + } + } + ) + ) + targetViewController.present(confirmationModal, animated: true, completion: nil) + } + } } diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index b6ec2fea8..1c716319f 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -163,6 +163,7 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa viewHasAppeared = true autoLoadNextPageIfNeeded() + viewModel.onAppear(targetViewController: self) } override func viewWillDisappear(_ animated: Bool) { @@ -583,7 +584,12 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa let confirmationModal: ConfirmationModal = ConfirmationModal( targetView: tappedView, info: confirmationInfo - .with(onConfirm: { _ in performAction() }) + .with( + onConfirm: { modal in + confirmationInfo.onConfirm?(modal) + performAction() + } + ) ) present(confirmationModal, animated: true, completion: nil) } diff --git a/Session/Shared/SessionTableViewModel.swift b/Session/Shared/SessionTableViewModel.swift index 4273d5819..ba3fff398 100644 --- a/Session/Shared/SessionTableViewModel.swift +++ b/Session/Shared/SessionTableViewModel.swift @@ -25,6 +25,7 @@ protocol SessionTableViewModel: AnyObject, SectionedTableData { func canEditRow(at indexPath: IndexPath) -> Bool func leadingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration? func trailingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration? + func onAppear(targetViewController: BaseVC) } extension SessionTableViewModel { @@ -43,6 +44,7 @@ extension SessionTableViewModel { func canEditRow(at indexPath: IndexPath) -> Bool { false } func leadingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration? { nil } func trailingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration? { nil } + func onAppear(targetViewController: BaseVC) { } } // MARK: - SessionTableViewCellType diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index fcf9f6d63..8a83c41e5 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -415,6 +415,7 @@ extension SessionCell.Accessory { public enum DataSource: Hashable, Equatable { case boolValue(key: String, value: Bool, oldValue: Bool) case dynamicString(() -> String?) + case staticBoolValue(Bool) static func boolValue(_ value: Bool, oldValue: Bool) -> DataSource { return .boolValue(key: "", value: value, oldValue: oldValue) @@ -430,12 +431,14 @@ extension SessionCell.Accessory { switch self { case .boolValue(_, let value, _): return value case .dynamicString: return false + case .staticBoolValue(let value): return value } } public var oldBoolValue: Bool { switch self { case .boolValue(_, _, let oldValue): return oldValue + case .staticBoolValue(let value): return value default: return false } } @@ -457,6 +460,7 @@ extension SessionCell.Accessory { oldValue.hash(into: &hasher) case .dynamicString(let generator): generator().hash(into: &hasher) + case .staticBoolValue(let value): value.hash(into: &hasher) } } @@ -471,6 +475,9 @@ extension SessionCell.Accessory { case (.dynamicString(let lhsGenerator), .dynamicString(let rhsGenerator)): return (lhsGenerator() == rhsGenerator()) + + case (.staticBoolValue(let lhsValue), .staticBoolValue(let rhsValue)): + return (lhsValue == rhsValue) default: return false } diff --git a/Session/Utilities/Permissions.swift b/Session/Utilities/Permissions.swift index e0f6a997f..f12d34cdc 100644 --- a/Session/Utilities/Permissions.swift +++ b/Session/Utilities/Permissions.swift @@ -7,6 +7,7 @@ import AVFAudio import SessionUIKit import SessionUtilitiesKit import SessionMessagingKit +import Network extension Permissions { @discardableResult public static func requestCameraPermissionIfNeeded( @@ -165,4 +166,140 @@ extension Permissions { default: return } } + + public static func requestLocalNetworkPermissionIfNeeded() { + checkLocalNetworkPermission() + } + + public static func checkLocalNetworkPermission() { + Task { + do { + if try await requestLocalNetworkAuthorization() { + // Permission is granted, continue to next onboarding step + UserDefaults.sharedLokiProject?[.lastSeenHasLocalNetworkPermission] = true + } else { + // Permission denied, explain why we need it and show button to open Settings + UserDefaults.sharedLokiProject?[.lastSeenHasLocalNetworkPermission] = false + } + } catch { + // Networking failure, handle error + } + } + } + + public static func requestLocalNetworkAuthorization() async throws -> Bool { + let type = "_session_local_network_access_check._tcp" + let queue = DispatchQueue(label: "localNetworkAuthCheck") + + let listener = try NWListener(using: NWParameters(tls: .none, tcp: NWProtocolTCP.Options())) + listener.service = NWListener.Service(name: UUID().uuidString, type: type) + listener.newConnectionHandler = { _ in } // Must be set or else the listener will error with POSIX error 22 + + let parameters = NWParameters() + parameters.includePeerToPeer = true + let browser = NWBrowser(for: .bonjour(type: type, domain: nil), using: parameters) + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + class LocalState { + var didResume = false + } + let local = LocalState() + @Sendable func resume(with result: Result) { + if local.didResume { + print("Already resumed, ignoring subsequent result.") + return + } + local.didResume = true + + // Teardown listener and browser + listener.stateUpdateHandler = { _ in } + browser.stateUpdateHandler = { _ in } + browser.browseResultsChangedHandler = { _, _ in } + listener.cancel() + browser.cancel() + + continuation.resume(with: result) + } + + // Do not setup listener/browser is we're already cancelled, it does work but logs a lot of very ugly errors + if Task.isCancelled { + resume(with: .failure(CancellationError())) + return + } + + listener.stateUpdateHandler = { newState in + switch newState { + case .setup: + print("Listener performing setup.") + case .ready: + print("Listener ready to be discovered.") + case .cancelled: + print("Listener cancelled.") + resume(with: .failure(CancellationError())) + case .failed(let error): + print("Listener failed, stopping. \(error)") + resume(with: .failure(error)) + case .waiting(let error): + print("Listener waiting, stopping. \(error)") + resume(with: .failure(error)) + @unknown default: + print("Ignoring unknown listener state: \(String(describing: newState))") + } + } + listener.start(queue: queue) + + browser.stateUpdateHandler = { newState in + switch newState { + case .setup: + print("Browser performing setup.") + return + case .ready: + print("Browser ready to discover listeners.") + return + case .cancelled: + print("Browser cancelled.") + resume(with: .failure(CancellationError())) + case .failed(let error): + print("Browser failed, stopping. \(error)") + resume(with: .failure(error)) + case let .waiting(error): + switch error { + case .dns(DNSServiceErrorType(kDNSServiceErr_PolicyDenied)): + print("Browser permission denied, reporting failure.") + resume(with: .success(false)) + default: + print("Browser waiting, stopping. \(error)") + resume(with: .failure(error)) + } + @unknown default: + print("Ignoring unknown browser state: \(String(describing: newState))") + return + } + } + + browser.browseResultsChangedHandler = { results, changes in + if results.isEmpty { + print("Got empty result set from browser, ignoring.") + return + } + + print("Discovered \(results.count) listeners, reporting success.") + resume(with: .success(true)) + } + browser.start(queue: queue) + + // Task cancelled while setting up listener & browser, tear down immediatly + if Task.isCancelled { + print("Task cancelled during listener & browser start. (Some warnings might be logged by the listener or browser.)") + resume(with: .failure(CancellationError())) + return + } + } + } onCancel: { + listener.cancel() + browser.cancel() + } + } } + diff --git a/SessionUIKit/Components/ConfirmationModal.swift b/SessionUIKit/Components/ConfirmationModal.swift index 7aa16fb5a..b3332af0d 100644 --- a/SessionUIKit/Components/ConfirmationModal.swift +++ b/SessionUIKit/Components/ConfirmationModal.swift @@ -426,7 +426,7 @@ public extension ConfirmationModal { let hasCloseButton: Bool let dismissOnConfirm: Bool let dismissType: Modal.DismissType - let onConfirm: ((ConfirmationModal) -> ())? + public let onConfirm: ((ConfirmationModal) -> ())? let onCancel: ((ConfirmationModal) -> ())? let afterClosed: (() -> ())? diff --git a/SessionUtilitiesKit/General/Constants.swift b/SessionUtilitiesKit/General/Constants.swift index 92bd8bde3..e3030a6ee 100644 --- a/SessionUtilitiesKit/General/Constants.swift +++ b/SessionUtilitiesKit/General/Constants.swift @@ -8,4 +8,22 @@ public enum Constants { public static let session_download_url: String = "https://getsession.org/download" public static let gif: String = "GIF" public static let oxen_foundation: String = "Oxen Foundation" + + // TODO: Localize call connection steps + public static let call_connection_steps_sender: [String] = [ + "Creating Call 1/6", + "Sending Call Offer 2/6", + "Sending Connection Candidates 3/6", + "Awaiting Recipient Answer... 4/6", + "Handling Connection Candidates 5/6", + "Call Connected 6/6", + ] + public static let call_connection_steps_receiver: [String] = [ + "Received Call Offer 1/5", + "Answering Call 2/5", + "Sending Connection Candidates 3/5", + "Handling Connection Candidates 4/5", + "Call Connected 5/5", + ] } + diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index ff6579913..f3b2da3df 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -35,6 +35,7 @@ public enum SNUserDefaults { case isMainAppActive case isCallOngoing case lastSeenHasMicrophonePermission + case lastSeenHasLocalNetworkPermission } public enum Date: Swift.String { diff --git a/SessionUtilitiesKit/Utilities/Permissions.swift b/SessionUtilitiesKit/Utilities/Permissions.swift index e8e907fcf..a9d026952 100644 --- a/SessionUtilitiesKit/Utilities/Permissions.swift +++ b/SessionUtilitiesKit/Utilities/Permissions.swift @@ -1,17 +1,18 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import AVFAudio +import AVFoundation public enum Permissions { - public enum MicrophonePermisson { + public enum Status { case denied case granted case undetermined case unknown } - public static var microphone: MicrophonePermisson { + public static var microphone: Status { if #available(iOSApplicationExtension 17.0, *) { switch AVAudioApplication.shared.recordPermission { case .undetermined: @@ -36,4 +37,27 @@ public enum Permissions { } } } + + public static var camera: Status { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .notDetermined: + return .undetermined + case .restricted, .denied: + return .denied + case .authorized: + return .granted + @unknown default: + return .unknown + } + } + + public static var localNetwork: Status { + let status: Bool? = UserDefaults.sharedLokiProject?[.lastSeenHasLocalNetworkPermission] + switch status { + case .none: + return .unknown + case .some(let value): + return value ? .granted : .denied + } + } }