Skip to content

Commit b08dc1a

Browse files
committed
PermissionsStore implementation
1 parent bf957cb commit b08dc1a

25 files changed

+878
-122
lines changed

DemoApp/Sources/AppDelegate.swift

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import UIKit
1010
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
1111

1212
@Injected(\.streamVideo) var streamVideo
13+
@Injected(\.permissions) var permissions
1314

1415
func application(
1516
_ application: UIApplication,
@@ -95,15 +96,21 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
9596
// MARK: - Private Helpers
9697

9798
private func setUpRemoteNotifications() {
98-
UNUserNotificationCenter
99-
.current()
100-
.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
101-
if granted {
102-
Task { @MainActor in
103-
UIApplication.shared.registerForRemoteNotifications()
104-
}
99+
Task {
100+
do {
101+
guard
102+
try await permissions.requestPushNotificationPermission(with: [.alert, .sound, .badge])
103+
else {
104+
log.warning("Push notifications request not granted.")
105+
return
105106
}
107+
_ = await Task { @MainActor in
108+
UIApplication.shared.registerForRemoteNotifications()
109+
}.result
110+
} catch {
111+
log.error("Push notifications request failed.", error: error)
106112
}
113+
}
107114
}
108115

109116
private func setUpPerformanceTracking() {

DemoApp/Sources/Views/CallTopView/DemoCallTopView.swift

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,7 @@ struct DemoCallTopView: View {
5454
.padding(.horizontal, 16)
5555
.padding(.top)
5656
.frame(maxWidth: .infinity)
57-
.overlay(
58-
viewModel.call?.state.isCurrentUserScreensharing == true ?
59-
SharingIndicator(
60-
viewModel: viewModel,
61-
sharingPopupDismissed: $sharingPopupDismissed
62-
)
63-
.opacity(sharingPopupDismissed ? 0 : 1)
64-
: nil
65-
)
57+
.overlay(overlayView)
6658
}
6759

6860
private var isCallLivestream: Bool {
@@ -75,6 +67,26 @@ struct DemoCallTopView: View {
7567
&& viewModel.call?.state.isCurrentUserScreensharing == false
7668
}
7769

70+
@ViewBuilder
71+
private var overlayView: some View {
72+
if viewModel.call?.state.isCurrentUserScreensharing == true, !sharingPopupDismissed {
73+
SharingIndicator(
74+
viewModel: viewModel,
75+
sharingPopupDismissed: $sharingPopupDismissed
76+
)
77+
} else {
78+
if let call = viewModel.call {
79+
if call.callType == .livestream, call.currentUserHasCapability(.startBroadcastCall) {
80+
PermissionsPromptView()
81+
} else if call.callType != .livestream {
82+
PermissionsPromptView()
83+
} else {
84+
EmptyView()
85+
}
86+
}
87+
}
88+
}
89+
7890
@ViewBuilder
7991
private var livestreamControlsView: some View {
8092
if let call = viewModel.call, call.callType == .livestream, call.currentUserHasCapability(.startBroadcastCall) {

Sources/StreamVideo/Utils/AudioSession/AudioRecorder/Namespace/Middleware/StreamCallAudioRecorder+AVAudioRecorderMiddleware.swift

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ extension StreamCallAudioRecorder.Namespace {
2323
final class AVAudioRecorderMiddleware: Middleware<StreamCallAudioRecorder.Namespace>, @unchecked Sendable {
2424

2525
/// The audio store for managing permissions and session state.
26-
@Injected(\.audioStore) private var audioStore
26+
@Injected(\.permissions) private var permissions
2727

2828
/// Builder for creating and caching the audio recorder instance.
2929
private var audioRecorder: AVAudioRecorder?
@@ -121,21 +121,27 @@ extension StreamCallAudioRecorder.Namespace {
121121
return
122122
}
123123

124-
audioRecorder.isMeteringEnabled = true
125-
guard
126-
await audioStore.requestRecordPermission(),
127-
audioRecorder.record()
128-
else {
129-
dispatcher?.dispatch(.setIsRecording(false))
130-
audioRecorder.isMeteringEnabled = false
131-
return
132-
}
124+
do {
125+
let hasPermission = try await permissions.requestMicrophonePermission()
126+
audioRecorder.isMeteringEnabled = true
133127

134-
updateMetersCancellable = DefaultTimer
135-
.publish(every: ScreenPropertiesAdapter.currentValue.refreshRate)
136-
.map { [weak audioRecorder] _ in audioRecorder?.updateMeters() }
137-
.compactMap { [weak audioRecorder] in audioRecorder?.averagePower(forChannel: 0) }
138-
.sink { [weak self] in self?.dispatcher?.dispatch(.setMeter($0)) }
128+
guard
129+
hasPermission,
130+
audioRecorder.record()
131+
else {
132+
dispatcher?.dispatch(.setIsRecording(false))
133+
audioRecorder.isMeteringEnabled = false
134+
return
135+
}
136+
137+
updateMetersCancellable = DefaultTimer
138+
.publish(every: ScreenPropertiesAdapter.currentValue.refreshRate)
139+
.map { [weak audioRecorder] _ in audioRecorder?.updateMeters() }
140+
.compactMap { [weak audioRecorder] in audioRecorder?.averagePower(forChannel: 0) }
141+
.sink { [weak self] in self?.dispatcher?.dispatch(.setMeter($0)) }
142+
} catch {
143+
log.error(error, subsystems: .audioRecording)
144+
}
139145
}
140146
}
141147

Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/RTCAudioStore.swift

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -166,21 +166,6 @@ final class RTCAudioStore: @unchecked Sendable {
166166
}
167167
}
168168

169-
// MARK: - Helpers
170-
171-
/// Requests record permission from the user, updating state.
172-
func requestRecordPermission() async -> Bool {
173-
guard
174-
!state.hasRecordingPermission
175-
else {
176-
return true
177-
}
178-
179-
let result = await session.requestRecordPermission()
180-
dispatch(.audioSession(.setHasRecordingPermission(result)))
181-
return result
182-
}
183-
184169
// MARK: - Private Helpers
185170

186171
private func perform(
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import AVFoundation
6+
import Foundation
7+
import StreamWebRTC
8+
9+
extension PermissionStore {
10+
11+
final class CameraMiddleware: Middleware<Namespace>, @unchecked Sendable {
12+
13+
override var dispatcher: Store<PermissionStore.Namespace>.Dispatcher? {
14+
didSet { dispatcher?.dispatch(.setCameraPermission(systemPermission)) }
15+
}
16+
17+
override func apply(
18+
state: PermissionStore.StoreState,
19+
action: PermissionStore.StoreAction,
20+
file: StaticString,
21+
function: StaticString,
22+
line: UInt
23+
) {
24+
switch action {
25+
case .requestCameraPermission:
26+
requestPermission()
27+
28+
default:
29+
break
30+
}
31+
}
32+
33+
// MARK: - Private Helpers
34+
35+
private var systemPermission: Permission {
36+
switch AVCaptureDevice.authorizationStatus(for: .video) {
37+
case .notDetermined:
38+
return .unknown
39+
case .restricted:
40+
return .denied
41+
case .denied:
42+
return .denied
43+
case .authorized:
44+
return .granted
45+
@unknown default:
46+
return .unknown
47+
}
48+
}
49+
50+
private func requestPermission() {
51+
AVCaptureDevice.requestAccess(for: .video) { [weak self] in
52+
self?.dispatcher?.dispatch(.setCameraPermission($0 ? .granted : .denied))
53+
}
54+
}
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import AVFoundation
6+
import Foundation
7+
import StreamWebRTC
8+
9+
extension PermissionStore {
10+
11+
final class MicrophoneMiddleware: Middleware<Namespace>, @unchecked Sendable {
12+
13+
override var dispatcher: Store<PermissionStore.Namespace>.Dispatcher? {
14+
didSet { dispatcher?.dispatch(.setMicrophonePermission(systemPermission)) }
15+
}
16+
17+
override func apply(
18+
state: PermissionStore.StoreState,
19+
action: PermissionStore.StoreAction,
20+
file: StaticString,
21+
function: StaticString,
22+
line: UInt
23+
) {
24+
switch action {
25+
case .requestMicrophonePermission:
26+
requestPermission()
27+
28+
default:
29+
break
30+
}
31+
}
32+
33+
// MARK: - Private Helpers
34+
35+
private var systemPermission: Permission {
36+
if #available(iOS 17.0, *) {
37+
switch AVAudioApplication.shared.recordPermission {
38+
case .undetermined:
39+
return .unknown
40+
case .denied:
41+
return .denied
42+
case .granted:
43+
return .granted
44+
@unknown default:
45+
return .unknown
46+
}
47+
} else {
48+
switch AVAudioSession.sharedInstance().recordPermission {
49+
case .undetermined:
50+
return .unknown
51+
case .denied:
52+
return .denied
53+
case .granted:
54+
return .granted
55+
@unknown default:
56+
return .unknown
57+
}
58+
}
59+
}
60+
61+
private func requestPermission() {
62+
if #available(iOS 17.0, *) {
63+
AVAudioApplication.requestRecordPermission { [weak self] in
64+
self?.dispatcher?.dispatch(.setMicrophonePermission($0 ? .granted : .denied))
65+
}
66+
} else {
67+
return AVAudioSession.sharedInstance().requestRecordPermission { [weak self] in
68+
self?.dispatcher?.dispatch(.setMicrophonePermission($0 ? .granted : .denied))
69+
}
70+
}
71+
}
72+
}
73+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
import StreamWebRTC
7+
import UserNotifications
8+
9+
extension PermissionStore {
10+
11+
final class PushNotificationsMiddleware: Middleware<Namespace>, @unchecked Sendable {
12+
13+
override var dispatcher: Store<PermissionStore.Namespace>.Dispatcher? {
14+
didSet { didUpdate(dispatcher) }
15+
}
16+
17+
override func apply(
18+
state: PermissionStore.StoreState,
19+
action: PermissionStore.StoreAction,
20+
file: StaticString,
21+
function: StaticString,
22+
line: UInt
23+
) {
24+
switch action {
25+
case let .requestPushNotificationPermission(options):
26+
requestPermission(with: options)
27+
28+
default:
29+
break
30+
}
31+
}
32+
33+
// MARK: - Private Helpers
34+
35+
private func systemPermission() async -> Permission {
36+
let authorizationStatus = await UNUserNotificationCenter
37+
.current()
38+
.notificationSettings()
39+
.authorizationStatus
40+
41+
switch authorizationStatus {
42+
case .notDetermined:
43+
return .unknown
44+
case .provisional:
45+
return .granted
46+
case .denied:
47+
return .denied
48+
case .authorized:
49+
return .granted
50+
case .ephemeral:
51+
return .granted
52+
@unknown default:
53+
return .unknown
54+
}
55+
}
56+
57+
private func didUpdate(_ dispatcher: Store<Namespace>.Dispatcher?) {
58+
guard dispatcher != nil else {
59+
return
60+
}
61+
Task { [weak self] in
62+
guard let self else {
63+
return
64+
}
65+
let permission = await systemPermission()
66+
dispatcher?.dispatch(.setPushNotificationPermission(permission))
67+
}
68+
}
69+
70+
private func requestPermission(with options: UNAuthorizationOptions) {
71+
UNUserNotificationCenter
72+
.current()
73+
.requestAuthorization(options: options) { [weak self] in
74+
self?.didUpdateRequestAuthorization(granted: $0, error: $1)
75+
}
76+
}
77+
78+
private func didUpdateRequestAuthorization(granted: Bool, error: Error?) {
79+
if let error {
80+
log.error(error)
81+
dispatcher?.dispatch(.setPushNotificationPermission(.unknown))
82+
} else {
83+
dispatcher?.dispatch(.setPushNotificationPermission(granted ? .granted : .denied))
84+
}
85+
}
86+
}
87+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
import UserNotifications
7+
8+
extension PermissionStore {
9+
10+
public enum StoreAction: Sendable {
11+
case setMicrophonePermission(Permission)
12+
case requestMicrophonePermission
13+
14+
case setCameraPermission(Permission)
15+
case requestCameraPermission
16+
17+
case setPushNotificationPermission(Permission)
18+
case requestPushNotificationPermission(UNAuthorizationOptions)
19+
}
20+
}

0 commit comments

Comments
 (0)