Merge branch 'call-permission-improvements' into call-detailed-info

pull/1061/head
Ryan ZHAO 1 month ago
commit a2d7be369c

@ -13,8 +13,6 @@ import SessionUtilitiesKit
import SessionSnodeKit import SessionSnodeKit
public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
@objc static let isEnabled = true
private let dependencies: Dependencies private let dependencies: Dependencies
// MARK: - Metadata Properties // MARK: - Metadata Properties
@ -87,7 +85,9 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
didSet { didSet {
stateDidChange?() stateDidChange?()
hasConnectedDidChange?() 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.") SNLog("[Calls] Did receive remote sdp.")
self.updateCallDetailedStatus?(self.mode == .offer ? "Received Answer" : "Received Call Offer")
remoteSDP = sdp remoteSDP = sdp
if hasStartedConnecting { if hasStartedConnecting {
webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
} }
if mode == .answer {
self.updateCallDetailedStatus?(Constants.call_connection_steps_receiver[0])
}
} }
// MARK: - Actions // MARK: - Actions
@ -253,7 +255,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
self.callInteractionId = interaction?.id self.callInteractionId = interaction?.id
self.updateCallDetailedStatus?("Creating Call") self.updateCallDetailedStatus?(Constants.call_connection_steps_sender[0])
try? webRTCSession try? webRTCSession
.sendPreOffer( .sendPreOffer(
@ -266,7 +268,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
// Start the timeout timer for the call // Start the timeout timer for the call
.handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() }) .handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() })
.flatMap { [weak self] _ in .flatMap { [weak self] _ in
self?.updateCallDetailedStatus?("Sending Call Offer") self?.updateCallDetailedStatus?(Constants.call_connection_steps_sender[1])
return webRTCSession return webRTCSession
.sendOffer(to: thread) .sendOffer(to: thread)
.retry(5) .retry(5)
@ -276,7 +278,6 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
switch result { switch result {
case .finished: case .finished:
SNLog("[Calls] Offer message sent") SNLog("[Calls] Offer message sent")
self?.updateCallDetailedStatus?("Sending Connection Candidates")
case .failure(let error): case .failure(let error):
SNLog("[Calls] Error initializing call after 5 retries: \(error), ending call...") SNLog("[Calls] Error initializing call after 5 retries: \(error), ending call...")
self?.handleCallInitializationFailed() self?.handleCallInitializationFailed()
@ -292,7 +293,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
if let sdp = remoteSDP { if let sdp = remoteSDP {
SNLog("[Calls] Got remote sdp already") 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 webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
} }
} }
@ -305,21 +306,20 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
func endSessionCall() { func endSessionCall() {
guard !hasEnded else { return } guard !hasEnded else { return }
let sessionId: String = self.sessionId
webRTCSession.hangUp() webRTCSession.hangUp()
webRTCSession.endCall(
with: self.sessionId Storage.shared.writeAsync { [weak self] db in
) try self?.webRTCSession.endCall(db, with: sessionId)
.sinkUntilComplete( }
receiveCompletion: { [weak self] _ in
self?.hasEnded = true hasEnded = true
Singleton.callManager.cleanUpPreviousCall()
}
)
} }
func handleCallInitializationFailed() { func handleCallInitializationFailed() {
self.endSessionCall() self.endSessionCall()
Singleton.callManager.reportCurrentCallEnded(reason: .failed) Singleton.callManager.reportCurrentCallEnded(reason: nil)
} }
// MARK: - Call Message Handling // MARK: - Call Message Handling
@ -432,15 +432,27 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
isRemoteVideoEnabled = isEnabled isRemoteVideoEnabled = isEnabled
} }
public func iceCandidateDidSend() { public func sendingIceCandidates() {
DispatchQueue.main.async { 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() { public func iceCandidateDidReceive() {
DispatchQueue.main.async { 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]
)
} }
} }

@ -3,11 +3,12 @@
import UIKit import UIKit
import YYImage import YYImage
import MediaPlayer import MediaPlayer
import AVKit
import SessionUIKit import SessionUIKit
import SessionMessagingKit import SessionMessagingKit
import SessionUtilitiesKit import SessionUtilitiesKit
final class CallVC: UIViewController, VideoPreviewDelegate { final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDelegate {
private static let avatarRadius: CGFloat = (isIPhone6OrSmaller ? 100 : 120) private static let avatarRadius: CGFloat = (isIPhone6OrSmaller ? 100 : 120)
private static let floatingVideoViewWidth: CGFloat = (UIDevice.current.isIPad ? 160 : 80) private static let floatingVideoViewWidth: CGFloat = (UIDevice.current.isIPad ? 160 : 80)
private static let floatingVideoViewHeight: CGFloat = (UIDevice.current.isIPad ? 346: 173) private static let floatingVideoViewHeight: CGFloat = (UIDevice.current.isIPad ? 346: 173)
@ -275,11 +276,20 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
return result return result
}() }()
private lazy var volumeView: MPVolumeView = { private lazy var routePickerView: AVRoutePickerView = {
let result = MPVolumeView() let result = AVRoutePickerView()
result.showsVolumeSlider = false result.delegate = self
result.showsRouteButton = true result.alpha = 0
result.setRouteButtonImage( 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")? UIImage(named: "Speaker")?
.withRenderingMode(.alwaysTemplate), .withRenderingMode(.alwaysTemplate),
for: .normal for: .normal
@ -287,6 +297,20 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
result.themeTintColor = .textPrimary result.themeTintColor = .textPrimary
result.themeBackgroundColor = .backgroundSecondary result.themeBackgroundColor = .backgroundSecondary
result.layer.cornerRadius = 30 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(.width, to: 60)
result.set(.height, to: 60) result.set(.height, to: 60)
@ -294,7 +318,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
}() }()
private lazy var operationPanel: UIStackView = { 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.axis = .horizontal
result.spacing = Values.veryLargeSpacing result.spacing = Values.veryLargeSpacing
@ -590,7 +614,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
self.switchAudioButton.transform = transform self.switchAudioButton.transform = transform
self.switchCameraButton.transform = transform self.switchCameraButton.transform = transform
self.videoButton.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() { @objc private func audioRouteDidChange() {
let currentSession = AVAudioSession.sharedInstance() let currentSession = AVAudioSession.sharedInstance()
let currentRoute = currentSession.currentRoute let currentRoute = currentSession.currentRoute
@ -767,35 +802,35 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
switch currentOutput.portType { switch currentOutput.portType {
case .builtInSpeaker: case .builtInSpeaker:
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate) let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal) routePickerButton.setImage(image, for: .normal)
volumeView.themeTintColor = .backgroundSecondary routePickerButton.themeTintColor = .backgroundSecondary
volumeView.themeBackgroundColor = .textPrimary routePickerButton.themeBackgroundColor = .textPrimary
case .headphones: case .headphones:
let image = UIImage(named: "Headsets")?.withRenderingMode(.alwaysTemplate) let image = UIImage(named: "Headsets")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal) routePickerButton.setImage(image, for: .normal)
volumeView.themeTintColor = .backgroundSecondary routePickerButton.themeTintColor = .backgroundSecondary
volumeView.themeBackgroundColor = .textPrimary routePickerButton.themeBackgroundColor = .textPrimary
case .bluetoothLE: fallthrough case .bluetoothLE: fallthrough
case .bluetoothA2DP: case .bluetoothA2DP:
let image = UIImage(named: "Bluetooth")?.withRenderingMode(.alwaysTemplate) let image = UIImage(named: "Bluetooth")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal) routePickerButton.setImage(image, for: .normal)
volumeView.themeTintColor = .backgroundSecondary routePickerButton.themeTintColor = .backgroundSecondary
volumeView.themeBackgroundColor = .textPrimary routePickerButton.themeBackgroundColor = .textPrimary
case .bluetoothHFP: case .bluetoothHFP:
let image = UIImage(named: "Airpods")?.withRenderingMode(.alwaysTemplate) let image = UIImage(named: "Airpods")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal) routePickerButton.setImage(image, for: .normal)
volumeView.themeTintColor = .backgroundSecondary routePickerButton.themeTintColor = .backgroundSecondary
volumeView.themeBackgroundColor = .textPrimary routePickerButton.themeBackgroundColor = .textPrimary
case .builtInReceiver: fallthrough case .builtInReceiver: fallthrough
default: default:
let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate) let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate)
volumeView.setRouteButtonImage(image, for: .normal) routePickerButton.setImage(image, for: .normal)
volumeView.themeTintColor = .backgroundSecondary routePickerButton.themeTintColor = .textPrimary
volumeView.themeBackgroundColor = .textPrimary routePickerButton.themeBackgroundColor = .backgroundSecondary
} }
} }
} }
@ -809,4 +844,14 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
self.callDurationLabel.alpha = isHidden ? 1 : 0 self.callDurationLabel.alpha = isHidden ? 1 : 0
} }
} }
// MARK: - AVRoutePickerViewDelegate
func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) {
}
func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) {
}
} }

@ -72,10 +72,23 @@ final class CallMissedTipsModal: Modal {
// MARK: - Lifecycle // MARK: - Lifecycle
init(caller: String) { init(caller: String, presentingViewController: UIViewController?) {
self.caller = caller 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.modalPresentationStyle = .overFullScreen
self.modalTransitionStyle = .crossDissolve self.modalTransitionStyle = .crossDissolve
@ -86,7 +99,7 @@ final class CallMissedTipsModal: Modal {
} }
override func populateContentView() { override func populateContentView() {
cancelButton.setTitle("okay".localized(), for: .normal) cancelButton.setTitle("sessionSettings".localized(), for: .normal)
contentView.addSubview(mainStackView) contentView.addSubview(mainStackView)
tipsIconContainerView.addSubview(tipsIconImageView) tipsIconContainerView.addSubview(tipsIconImageView)

@ -12,6 +12,7 @@ public protocol WebRTCSessionDelegate: AnyObject {
func webRTCIsConnected() func webRTCIsConnected()
func isRemoteVideoDidChange(isEnabled: Bool) func isRemoteVideoDidChange(isEnabled: Bool)
func sendingIceCandidates()
func iceCandidateDidSend() func iceCandidateDidSend()
func iceCandidateDidReceive() func iceCandidateDidReceive()
func dataChannelDidOpen() func dataChannelDidOpen()
@ -300,6 +301,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate {
} }
private func sendICECandidates() { private func sendICECandidates() {
self.delegate?.sendingIceCandidates()
let candidates: [RTCIceCandidate] = self.queuedICECandidates let candidates: [RTCIceCandidate] = self.queuedICECandidates
let uuid: String = self.uuid let uuid: String = self.uuid
let contactSessionId: String = self.contactSessionId let contactSessionId: String = self.contactSessionId
@ -472,13 +474,15 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate {
} }
extension WebRTCSession { extension WebRTCSession {
public func configureAudioSession(outputAudioPort: AVAudioSession.PortOverride = .none) { public func configureAudioSession() {
let audioSession = RTCAudioSession.sharedInstance() let audioSession = RTCAudioSession.sharedInstance()
audioSession.lockForConfiguration() audioSession.lockForConfiguration()
do { do {
try audioSession.setCategory(AVAudioSession.Category.playAndRecord) try audioSession.setCategory(
try audioSession.setMode(AVAudioSession.Mode.voiceChat) .playAndRecord,
try audioSession.overrideOutputAudioPort(outputAudioPort) mode: .videoChat,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
try audioSession.setActive(true) try audioSession.setActive(true)
} catch let error { } catch let error {
SNLog("Couldn't set up WebRTC audio session due to error: \(error)") SNLog("Couldn't set up WebRTC audio session due to error: \(error)")

@ -88,7 +88,6 @@ extension ConversationVC:
// MARK: - Call // MARK: - Call
@objc func startCall(_ sender: Any?) { @objc func startCall(_ sender: Any?) {
guard SessionCall.isEnabled else { return }
guard viewModel.threadData.threadIsBlocked == false else { return } guard viewModel.threadData.threadIsBlocked == false else { return }
guard Storage.shared[.areCallsEnabled] else { guard Storage.shared[.areCallsEnabled] else {
let confirmationModal: ConfirmationModal = ConfirmationModal( let confirmationModal: ConfirmationModal = ConfirmationModal(
@ -102,7 +101,8 @@ extension ConversationVC:
let navController: UINavigationController = StyledNavigationController( let navController: UINavigationController = StyledNavigationController(
rootViewController: SessionTableViewController( rootViewController: SessionTableViewController(
viewModel: PrivacySettingsViewModel( viewModel: PrivacySettingsViewModel(
shouldShowCloseButton: true shouldShowCloseButton: true,
shouldAutomaticallyShowCallModal: true
) )
) )
) )
@ -116,7 +116,39 @@ extension ConversationVC:
return 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 let threadId: String = self.viewModel.threadData.threadId
@ -916,12 +948,13 @@ extension ConversationVC:
), ),
messageInfo.state == .permissionDeniedMicrophone messageInfo.state == .permissionDeniedMicrophone
else { else {
let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(caller: cellViewModel.authorName) let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(
caller: cellViewModel.authorName,
presentingViewController: self
)
present(callMissedTipsModal, animated: true, completion: nil) present(callMissedTipsModal, animated: true, completion: nil)
return return
} }
Permissions.requestMicrophonePermissionIfNeeded(presentingViewController: self)
return return
} }

@ -1288,7 +1288,6 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
} }
else { else {
let shouldHaveCallButton: Bool = ( let shouldHaveCallButton: Bool = (
SessionCall.isEnabled &&
(threadData?.threadVariant ?? initialVariant) == .contact && (threadData?.threadVariant ?? initialVariant) == .contact &&
(threadData?.threadIsNoteToSelf ?? initialIsNoteToSelf) == false && (threadData?.threadIsNoteToSelf ?? initialIsNoteToSelf) == false &&
(threadData?.threadIsBlocked ?? initialIsBlocked) == false (threadData?.threadIsBlocked ?? initialIsBlocked) == false

@ -261,7 +261,6 @@ final class ConversationTitleView: UIView {
// Contact threads also have the call button to compensate for // Contact threads also have the call button to compensate for
let shouldShowCallButton: Bool = ( let shouldShowCallButton: Bool = (
SessionCall.isEnabled &&
!isNoteToSelf && !isNoteToSelf &&
threadVariant == .contact threadVariant == .contact
) )

@ -269,6 +269,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
guard Singleton.hasAppContext else { return } guard Singleton.hasAppContext else { return }
Singleton.appContext.clearOldTemporaryDirectories() Singleton.appContext.clearOldTemporaryDirectories()
if Storage.shared[.areCallsEnabled] {
Permissions.checkLocalNetworkPermission()
}
} }
func applicationWillResignActive(_ application: UIApplication) { func applicationWillResignActive(_ application: UIApplication) {
@ -803,7 +807,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
else { preconditionFailure() } else { preconditionFailure() }
let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal( let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(
caller: Profile.displayName(id: callerId) caller: Profile.displayName(id: callerId),
presentingViewController: presentingVC
) )
presentingVC.present(callMissedTipsModal, animated: true, completion: nil) presentingVC.present(callMissedTipsModal, animated: true, completion: nil)

@ -152,5 +152,9 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true/>
<key>NSBonjourServices</key>
<array>
<string>_session_local_network_access_check._tcp</string>
</array>
</dict> </dict>
</plist> </plist>

@ -16,12 +16,14 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav
public let state: TableDataState<Section, TableItem> = TableDataState() public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState() public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
private let shouldShowCloseButton: Bool private let shouldShowCloseButton: Bool
private let shouldAutomaticallyShowCallModal: Bool
// MARK: - Initialization // 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.dependencies = dependencies
self.shouldShowCloseButton = shouldShowCloseButton self.shouldShowCloseButton = shouldShowCloseButton
self.shouldAutomaticallyShowCallModal = shouldAutomaticallyShowCallModal
} }
// MARK: - Config // MARK: - Config
@ -53,13 +55,16 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav
} }
public enum TableItem: Differentiable { public enum TableItem: Differentiable {
case calls
case microphone
case camera
case localNetwork
case screenLock case screenLock
case communityMessageRequests case communityMessageRequests
case screenshotNotifications case screenshotNotifications
case readReceipts case readReceipts
case typingIndicators case typingIndicators
case linkPreviews case linkPreviews
case calls
} }
// MARK: - Navigation // MARK: - Navigation
@ -102,6 +107,108 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav
} }
.mapWithPrevious { [dependencies] previous, current -> [SectionModel] in .mapWithPrevious { [dependencies] previous, current -> [SectionModel] in
return [ 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( SectionModel(
model: .screenSecurity, model: .screenSecurity,
elements: [ 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)
}
}
} }

@ -163,6 +163,7 @@ class SessionTableViewController<ViewModel>: BaseVC, UITableViewDataSource, UITa
viewHasAppeared = true viewHasAppeared = true
autoLoadNextPageIfNeeded() autoLoadNextPageIfNeeded()
viewModel.onAppear(targetViewController: self)
} }
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
@ -583,7 +584,12 @@ class SessionTableViewController<ViewModel>: BaseVC, UITableViewDataSource, UITa
let confirmationModal: ConfirmationModal = ConfirmationModal( let confirmationModal: ConfirmationModal = ConfirmationModal(
targetView: tappedView, targetView: tappedView,
info: confirmationInfo info: confirmationInfo
.with(onConfirm: { _ in performAction() }) .with(
onConfirm: { modal in
confirmationInfo.onConfirm?(modal)
performAction()
}
)
) )
present(confirmationModal, animated: true, completion: nil) present(confirmationModal, animated: true, completion: nil)
} }

@ -25,6 +25,7 @@ protocol SessionTableViewModel: AnyObject, SectionedTableData {
func canEditRow(at indexPath: IndexPath) -> Bool func canEditRow(at indexPath: IndexPath) -> Bool
func leadingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration? 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 trailingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration?
func onAppear(targetViewController: BaseVC)
} }
extension SessionTableViewModel { extension SessionTableViewModel {
@ -43,6 +44,7 @@ extension SessionTableViewModel {
func canEditRow(at indexPath: IndexPath) -> Bool { false } func canEditRow(at indexPath: IndexPath) -> Bool { false }
func leadingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration? { nil } 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 trailingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration? { nil }
func onAppear(targetViewController: BaseVC) { }
} }
// MARK: - SessionTableViewCellType // MARK: - SessionTableViewCellType

@ -415,6 +415,7 @@ extension SessionCell.Accessory {
public enum DataSource: Hashable, Equatable { public enum DataSource: Hashable, Equatable {
case boolValue(key: String, value: Bool, oldValue: Bool) case boolValue(key: String, value: Bool, oldValue: Bool)
case dynamicString(() -> String?) case dynamicString(() -> String?)
case staticBoolValue(Bool)
static func boolValue(_ value: Bool, oldValue: Bool) -> DataSource { static func boolValue(_ value: Bool, oldValue: Bool) -> DataSource {
return .boolValue(key: "", value: value, oldValue: oldValue) return .boolValue(key: "", value: value, oldValue: oldValue)
@ -430,12 +431,14 @@ extension SessionCell.Accessory {
switch self { switch self {
case .boolValue(_, let value, _): return value case .boolValue(_, let value, _): return value
case .dynamicString: return false case .dynamicString: return false
case .staticBoolValue(let value): return value
} }
} }
public var oldBoolValue: Bool { public var oldBoolValue: Bool {
switch self { switch self {
case .boolValue(_, _, let oldValue): return oldValue case .boolValue(_, _, let oldValue): return oldValue
case .staticBoolValue(let value): return value
default: return false default: return false
} }
} }
@ -457,6 +460,7 @@ extension SessionCell.Accessory {
oldValue.hash(into: &hasher) oldValue.hash(into: &hasher)
case .dynamicString(let generator): generator().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)): case (.dynamicString(let lhsGenerator), .dynamicString(let rhsGenerator)):
return (lhsGenerator() == rhsGenerator()) return (lhsGenerator() == rhsGenerator())
case (.staticBoolValue(let lhsValue), .staticBoolValue(let rhsValue)):
return (lhsValue == rhsValue)
default: return false default: return false
} }

@ -7,6 +7,7 @@ import AVFAudio
import SessionUIKit import SessionUIKit
import SessionUtilitiesKit import SessionUtilitiesKit
import SessionMessagingKit import SessionMessagingKit
import Network
extension Permissions { extension Permissions {
@discardableResult public static func requestCameraPermissionIfNeeded( @discardableResult public static func requestCameraPermissionIfNeeded(
@ -165,4 +166,140 @@ extension Permissions {
default: return 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<Bool, Error>) in
class LocalState {
var didResume = false
}
let local = LocalState()
@Sendable func resume(with result: Result<Bool, Error>) {
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()
}
}
} }

@ -426,7 +426,7 @@ public extension ConfirmationModal {
let hasCloseButton: Bool let hasCloseButton: Bool
let dismissOnConfirm: Bool let dismissOnConfirm: Bool
let dismissType: Modal.DismissType let dismissType: Modal.DismissType
let onConfirm: ((ConfirmationModal) -> ())? public let onConfirm: ((ConfirmationModal) -> ())?
let onCancel: ((ConfirmationModal) -> ())? let onCancel: ((ConfirmationModal) -> ())?
let afterClosed: (() -> ())? let afterClosed: (() -> ())?

@ -8,4 +8,22 @@ public enum Constants {
public static let session_download_url: String = "https://getsession.org/download" public static let session_download_url: String = "https://getsession.org/download"
public static let gif: String = "GIF" public static let gif: String = "GIF"
public static let oxen_foundation: String = "Oxen Foundation" 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",
]
} }

@ -35,6 +35,7 @@ public enum SNUserDefaults {
case isMainAppActive case isMainAppActive
case isCallOngoing case isCallOngoing
case lastSeenHasMicrophonePermission case lastSeenHasMicrophonePermission
case lastSeenHasLocalNetworkPermission
} }
public enum Date: Swift.String { public enum Date: Swift.String {

@ -1,17 +1,18 @@
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
import AVFAudio import AVFAudio
import AVFoundation
public enum Permissions { public enum Permissions {
public enum MicrophonePermisson { public enum Status {
case denied case denied
case granted case granted
case undetermined case undetermined
case unknown case unknown
} }
public static var microphone: MicrophonePermisson { public static var microphone: Status {
if #available(iOSApplicationExtension 17.0, *) { if #available(iOSApplicationExtension 17.0, *) {
switch AVAudioApplication.shared.recordPermission { switch AVAudioApplication.shared.recordPermission {
case .undetermined: 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
}
}
} }

Loading…
Cancel
Save