commit 1a6a3b207d4891c1bc81f0c96c2f7e318e5e1882
parent 522bbb42c89ad6f9347b8f5300c4d0f6f4920bb1
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 03:29:17 -0700
kit: add Apple permission status provider
- add adapter-backed Apple permission status snapshots
- normalize notification capture photo microphone and location states
- link the Apple frameworks used by the status provider
- cover provider routing and platform enum mappings with Swift tests
Diffstat:
3 files changed, 316 insertions(+), 1 deletion(-)
diff --git a/Package.swift b/Package.swift
@@ -22,7 +22,11 @@ let package = Package(
name: "RadrootsKit",
linkerSettings: [
.linkedFramework("Security"),
- .linkedFramework("LocalAuthentication")
+ .linkedFramework("LocalAuthentication"),
+ .linkedFramework("UserNotifications"),
+ .linkedFramework("AVFoundation"),
+ .linkedFramework("Photos"),
+ .linkedFramework("CoreLocation")
]
),
.target(
diff --git a/Sources/RadrootsKit/RadrootsApplePermissionStatus.swift b/Sources/RadrootsKit/RadrootsApplePermissionStatus.swift
@@ -0,0 +1,207 @@
+import Foundation
+
+#if canImport(AVFoundation)
+import AVFoundation
+#endif
+
+#if canImport(CoreLocation)
+import CoreLocation
+#endif
+
+#if canImport(Photos)
+import Photos
+#endif
+
+#if canImport(UserNotifications)
+import UserNotifications
+#endif
+
+public struct RadrootsApplePermissionStatusAdapters: Sendable {
+ public let now: @Sendable () -> Date
+ public let notificationStatus: @Sendable () async -> RadrootsPermissionStatus
+ public let cameraStatus: @Sendable () -> RadrootsPermissionStatus
+ public let photosStatus: @Sendable () -> RadrootsPermissionStatus
+ public let microphoneStatus: @Sendable () -> RadrootsPermissionStatus
+ public let locationStatus: @Sendable () -> RadrootsPermissionStatus
+
+ public init(
+ now: @escaping @Sendable () -> Date = Date.init,
+ notificationStatus: @escaping @Sendable () async -> RadrootsPermissionStatus,
+ cameraStatus: @escaping @Sendable () -> RadrootsPermissionStatus,
+ photosStatus: @escaping @Sendable () -> RadrootsPermissionStatus,
+ microphoneStatus: @escaping @Sendable () -> RadrootsPermissionStatus,
+ locationStatus: @escaping @Sendable () -> RadrootsPermissionStatus
+ ) {
+ self.now = now
+ self.notificationStatus = notificationStatus
+ self.cameraStatus = cameraStatus
+ self.photosStatus = photosStatus
+ self.microphoneStatus = microphoneStatus
+ self.locationStatus = locationStatus
+ }
+
+ public static var live: Self {
+ Self(
+ notificationStatus: {
+ await Self.currentNotificationStatus()
+ },
+ cameraStatus: {
+ Self.currentCameraStatus()
+ },
+ photosStatus: {
+ Self.currentPhotosStatus()
+ },
+ microphoneStatus: {
+ Self.currentMicrophoneStatus()
+ },
+ locationStatus: {
+ Self.currentLocationStatus()
+ }
+ )
+ }
+
+ public static func currentNotificationStatus() async -> RadrootsPermissionStatus {
+ #if canImport(UserNotifications)
+ return await withCheckedContinuation { continuation in
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
+ continuation.resume(returning: Self.permissionStatus(for: settings.authorizationStatus))
+ }
+ }
+ #else
+ return .unsupported
+ #endif
+ }
+
+ public static func currentCameraStatus() -> RadrootsPermissionStatus {
+ #if canImport(AVFoundation)
+ return permissionStatus(for: AVCaptureDevice.authorizationStatus(for: .video))
+ #else
+ return .unsupported
+ #endif
+ }
+
+ public static func currentPhotosStatus() -> RadrootsPermissionStatus {
+ #if canImport(Photos)
+ return permissionStatus(for: PHPhotoLibrary.authorizationStatus(for: .readWrite))
+ #else
+ return .unsupported
+ #endif
+ }
+
+ public static func currentMicrophoneStatus() -> RadrootsPermissionStatus {
+ #if canImport(AVFoundation)
+ return permissionStatus(for: AVCaptureDevice.authorizationStatus(for: .audio))
+ #else
+ return .unsupported
+ #endif
+ }
+
+ public static func currentLocationStatus() -> RadrootsPermissionStatus {
+ #if canImport(CoreLocation)
+ guard CLLocationManager.locationServicesEnabled() else {
+ return .unavailable
+ }
+ return permissionStatus(for: CLLocationManager().authorizationStatus)
+ #else
+ return .unsupported
+ #endif
+ }
+
+ #if canImport(UserNotifications)
+ public static func permissionStatus(for authorizationStatus: UNAuthorizationStatus) -> RadrootsPermissionStatus {
+ switch authorizationStatus {
+ case .notDetermined:
+ .notDetermined
+ case .denied:
+ .denied
+ case .authorized:
+ .authorized
+ case .provisional:
+ .limited
+ case .ephemeral:
+ .limited
+ @unknown default:
+ .unavailable
+ }
+ }
+ #endif
+
+ #if canImport(AVFoundation)
+ public static func permissionStatus(for authorizationStatus: AVAuthorizationStatus) -> RadrootsPermissionStatus {
+ switch authorizationStatus {
+ case .notDetermined:
+ .notDetermined
+ case .restricted:
+ .restricted
+ case .denied:
+ .denied
+ case .authorized:
+ .authorized
+ @unknown default:
+ .unavailable
+ }
+ }
+ #endif
+
+ #if canImport(Photos)
+ public static func permissionStatus(for authorizationStatus: PHAuthorizationStatus) -> RadrootsPermissionStatus {
+ switch authorizationStatus {
+ case .notDetermined:
+ .notDetermined
+ case .restricted:
+ .restricted
+ case .denied:
+ .denied
+ case .authorized:
+ .authorized
+ case .limited:
+ .limited
+ @unknown default:
+ .unavailable
+ }
+ }
+ #endif
+
+ #if canImport(CoreLocation)
+ public static func permissionStatus(for authorizationStatus: CLAuthorizationStatus) -> RadrootsPermissionStatus {
+ switch authorizationStatus {
+ case .notDetermined:
+ .notDetermined
+ case .restricted:
+ .restricted
+ case .denied:
+ .denied
+ case .authorizedAlways:
+ .authorized
+ case .authorizedWhenInUse:
+ .authorized
+ @unknown default:
+ .unavailable
+ }
+ }
+ #endif
+}
+
+public final class RadrootsApplePermissionStatusProvider: RadrootsPermissionStatusProvider, Sendable {
+ private let adapters: RadrootsApplePermissionStatusAdapters
+
+ public init(adapters: RadrootsApplePermissionStatusAdapters = .live) {
+ self.adapters = adapters
+ }
+
+ public func snapshot(for kind: RadrootsPermissionKind) async throws -> RadrootsPermissionSnapshot {
+ let status = switch kind {
+ case .notifications:
+ await adapters.notificationStatus()
+ case .camera:
+ adapters.cameraStatus()
+ case .photos:
+ adapters.photosStatus()
+ case .microphone:
+ adapters.microphoneStatus()
+ case .location:
+ adapters.locationStatus()
+ }
+ return RadrootsPermissionSnapshot(kind: kind, status: status, observedAt: adapters.now())
+ }
+}
diff --git a/Tests/RadrootsKitTests/RadrootsApplePermissionStatusTests.swift b/Tests/RadrootsKitTests/RadrootsApplePermissionStatusTests.swift
@@ -0,0 +1,104 @@
+import Foundation
+import Testing
+@testable import RadrootsKit
+
+#if canImport(AVFoundation)
+import AVFoundation
+#endif
+
+#if canImport(CoreLocation)
+import CoreLocation
+#endif
+
+#if canImport(Photos)
+import Photos
+#endif
+
+#if canImport(UserNotifications)
+import UserNotifications
+#endif
+
+@Test func applePermissionStatusProviderUsesAdaptersForEachPermissionKind() async throws {
+ let observedAt = Date(timeIntervalSince1970: 42)
+ let provider = RadrootsApplePermissionStatusProvider(
+ adapters: RadrootsApplePermissionStatusAdapters(
+ now: { observedAt },
+ notificationStatus: { .limited },
+ cameraStatus: { .authorized },
+ photosStatus: { .denied },
+ microphoneStatus: { .restricted },
+ locationStatus: { .unavailable }
+ )
+ )
+
+ let snapshots = try await provider.snapshots(for: [
+ .notifications,
+ .camera,
+ .photos,
+ .microphone,
+ .location
+ ])
+
+ #expect(snapshots.map(\.kind) == [.notifications, .camera, .photos, .microphone, .location])
+ #expect(snapshots.map(\.status) == [.limited, .authorized, .denied, .restricted, .unavailable])
+ #expect(snapshots.allSatisfy { $0.observedAt == observedAt })
+}
+
+@Test func applePermissionStatusProviderReturnsSingleSnapshot() async throws {
+ let provider = RadrootsApplePermissionStatusProvider(
+ adapters: RadrootsApplePermissionStatusAdapters(
+ now: { Date(timeIntervalSince1970: 5) },
+ notificationStatus: { .unsupported },
+ cameraStatus: { .authorized },
+ photosStatus: { .authorized },
+ microphoneStatus: { .authorized },
+ locationStatus: { .denied }
+ )
+ )
+
+ let snapshot = try await provider.snapshot(for: .location)
+
+ #expect(snapshot.kind == .location)
+ #expect(snapshot.status == .denied)
+ #expect(snapshot.observedAt == Date(timeIntervalSince1970: 5))
+}
+
+#if canImport(UserNotifications)
+@Test func applePermissionStatusMapsNotificationStatuses() {
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: UNAuthorizationStatus.notDetermined) == .notDetermined)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: UNAuthorizationStatus.denied) == .denied)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: UNAuthorizationStatus.authorized) == .authorized)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: UNAuthorizationStatus.provisional) == .limited)
+}
+#endif
+
+#if canImport(AVFoundation)
+@Test func applePermissionStatusMapsCaptureStatuses() {
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: AVAuthorizationStatus.notDetermined) == .notDetermined)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: AVAuthorizationStatus.restricted) == .restricted)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: AVAuthorizationStatus.denied) == .denied)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: AVAuthorizationStatus.authorized) == .authorized)
+}
+#endif
+
+#if canImport(Photos)
+@Test func applePermissionStatusMapsPhotoStatuses() {
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.notDetermined) == .notDetermined)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.restricted) == .restricted)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.denied) == .denied)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.authorized) == .authorized)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: PHAuthorizationStatus.limited) == .limited)
+}
+#endif
+
+#if canImport(CoreLocation)
+@Test func applePermissionStatusMapsLocationStatuses() {
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.notDetermined) == .notDetermined)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.restricted) == .restricted)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.denied) == .denied)
+ #if os(iOS)
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.authorizedWhenInUse) == .authorized)
+ #endif
+ #expect(RadrootsApplePermissionStatusAdapters.permissionStatus(for: CLAuthorizationStatus.authorizedAlways) == .authorized)
+}
+#endif