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
+ }
+ }
}