apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

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:
MPackage.swift | 6+++++-
ASources/RadrootsKit/RadrootsApplePermissionStatus.swift | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsApplePermissionStatusTests.swift | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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