Files
openclaw/apps/ios/Sources/Permissions/DevicePermissions.swift
Peter Steinberger d2b382528e feat(ios): guide first launch through pairing explainer and permissions (#106129)
* feat(ios): guide first launch through pairing explainer and permissions

* fix(ios): keep first-run flow resumable and surface limited grants in onboarding

* chore(ios): regenerate native i18n after rebase

* chore(android): project refreshed native translations into strings.xml

* fix(ios): move i18n wrapper contract to shared permission row and sync catalog
2026-07-13 00:35:31 -07:00

240 lines
8.7 KiB
Swift

import AVFoundation
import Contacts
import CoreLocation
import EventKit
import Photos
import SwiftUI
import UserNotifications
/// Closed grant state for one device permission, shared by onboarding and Settings rows.
enum DevicePermissionGrant: Equatable {
case granted
case limited
case notRequested
case denied
}
/// Device permissions the connected agent can use from this iPhone.
enum DevicePermissionKind: String, CaseIterable, Identifiable {
case notifications
case camera
case microphone
case photos
case contacts
case calendar
case reminders
case location
var id: String {
self.rawValue
}
var symbol: String {
switch self {
case .notifications: "bell.badge.fill"
case .camera: "camera.fill"
case .microphone: "mic.fill"
case .photos: "photo.on.rectangle"
case .contacts: "person.crop.circle.fill"
case .calendar: "calendar"
case .reminders: "checklist"
case .location: "location.fill"
}
}
var tint: Color {
switch self {
case .notifications: .red
case .camera: .indigo
case .microphone: .pink
case .photos: .orange
case .contacts: .blue
case .calendar: .teal
case .reminders: .green
case .location: .purple
}
}
var title: LocalizedStringResource {
switch self {
case .notifications: LocalizedStringResource("Notifications")
case .camera: LocalizedStringResource("Camera")
case .microphone: LocalizedStringResource("Microphone")
case .photos: LocalizedStringResource("Photos")
case .contacts: LocalizedStringResource("Contacts")
case .calendar: LocalizedStringResource("Calendar")
case .reminders: LocalizedStringResource("Reminders")
case .location: LocalizedStringResource("Location")
}
}
var detail: LocalizedStringResource {
switch self {
case .notifications: LocalizedStringResource("Pairing approvals and replies from your agent.")
case .camera: LocalizedStringResource("Scan setup codes and take photos on request.")
case .microphone: LocalizedStringResource("Talk with your agent using voice.")
case .photos: LocalizedStringResource("Share recent photos when you ask.")
case .contacts: LocalizedStringResource("Look up and add people you mention.")
case .calendar: LocalizedStringResource("Check and add calendar events.")
case .reminders: LocalizedStringResource("List, add, and complete reminders.")
case .location: LocalizedStringResource("Answer location and nearby questions.")
}
}
}
/// Pure statusgrant maps so onboarding and Settings agree on one vocabulary.
enum DevicePermissionStatusMap {
static func contacts(_ status: CNAuthorizationStatus) -> DevicePermissionGrant {
switch status {
case .authorized: .granted
case .limited: .limited
case .notDetermined: .notRequested
case .denied, .restricted: .denied
@unknown default: .denied
}
}
static func photos(_ status: PHAuthorizationStatus) -> DevicePermissionGrant {
switch status {
case .authorized: .granted
case .limited: .limited
case .notDetermined: .notRequested
case .denied, .restricted: .denied
@unknown default: .denied
}
}
/// Full read access; `.writeOnly` surfaces as `.limited` ("Add-Only").
static func eventKitRead(_ status: EKAuthorizationStatus) -> DevicePermissionGrant {
switch status {
case .authorized, .fullAccess: .granted
case .writeOnly: .limited
case .notDetermined: .notRequested
case .denied, .restricted: .denied
@unknown default: .denied
}
}
/// Add-events access; `.writeOnly` already satisfies it.
static func eventKitWrite(_ status: EKAuthorizationStatus) -> DevicePermissionGrant {
switch status {
case .authorized, .fullAccess, .writeOnly: .granted
case .notDetermined: .notRequested
case .denied, .restricted: .denied
@unknown default: .denied
}
}
static func capture(_ status: AVAuthorizationStatus) -> DevicePermissionGrant {
switch status {
case .authorized: .granted
case .notDetermined: .notRequested
case .denied, .restricted: .denied
@unknown default: .denied
}
}
static func microphone(_ permission: AVAudioApplication.recordPermission) -> DevicePermissionGrant {
switch permission {
case .granted: .granted
case .undetermined: .notRequested
case .denied: .denied
@unknown default: .denied
}
}
static func notifications(_ status: UNAuthorizationStatus) -> DevicePermissionGrant {
switch status {
case .authorized, .ephemeral, .provisional: .granted
case .notDetermined: .notRequested
case .denied: .denied
@unknown default: .denied
}
}
static func location(_ status: CLAuthorizationStatus) -> DevicePermissionGrant {
switch status {
case .authorizedAlways, .authorizedWhenInUse: .granted
case .notDetermined: .notRequested
case .denied, .restricted: .denied
@unknown default: .denied
}
}
}
/// Live grant state + request plumbing for the onboarding permissions step.
/// Requests only raise the system prompts; product opt-ins (push relay serving,
/// Health sharing) remain explicit choices in Settings.
@MainActor
@Observable
final class DevicePermissionsModel {
private(set) var grants: [DevicePermissionKind: DevicePermissionGrant] = [:]
private(set) var requesting: Set<DevicePermissionKind> = []
/// Owns its manager so authorization callbacks resolve without the app-wide service.
private let locationService = LocationService()
func grant(for kind: DevicePermissionKind) -> DevicePermissionGrant {
self.grants[kind] ?? .notRequested
}
func refresh() async {
var next: [DevicePermissionKind: DevicePermissionGrant] = [
.camera: DevicePermissionStatusMap.capture(AVCaptureDevice.authorizationStatus(for: .video)),
.microphone: DevicePermissionStatusMap.microphone(AVAudioApplication.shared.recordPermission),
.photos: DevicePermissionStatusMap.photos(PhotoLibraryAccess.authorizationStatus()),
.contacts: DevicePermissionStatusMap.contacts(CNContactStore.authorizationStatus(for: .contacts)),
.calendar: DevicePermissionStatusMap.eventKitRead(EKEventStore.authorizationStatus(for: .event)),
.reminders: DevicePermissionStatusMap.eventKitRead(EKEventStore.authorizationStatus(for: .reminder)),
.location: DevicePermissionStatusMap.location(self.locationService.locationManager.authorizationStatus),
]
let settings = await UNUserNotificationCenter.current().notificationSettings()
next[.notifications] = DevicePermissionStatusMap.notifications(settings.authorizationStatus)
self.grants = next
}
func request(_ kind: DevicePermissionKind) async {
guard self.grant(for: kind) == .notRequested, !self.requesting.contains(kind) else { return }
self.requesting.insert(kind)
defer { self.requesting.remove(kind) }
switch kind {
case .notifications:
_ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [
.alert,
.badge,
.sound,
])
case .camera:
_ = await PermissionRequestBridge.awaitRequest { completion in
AVCaptureDevice.requestAccess(for: .video, completionHandler: completion)
}
case .microphone:
_ = await TalkModeManager.requestMicrophonePermission()
case .photos:
_ = await PhotoLibraryAccess.requestReadWrite()
case .contacts:
_ = await PermissionRequestBridge.awaitRequest { completion in
CNContactStore().requestAccess(for: .contacts) { granted, _ in
completion(granted)
}
}
case .calendar:
_ = await PermissionRequestBridge.awaitRequest { completion in
EKEventStore().requestFullAccessToEvents { granted, _ in
completion(granted)
}
}
case .reminders:
_ = await PermissionRequestBridge.awaitRequest { completion in
EKEventStore().requestFullAccessToReminders { granted, _ in
completion(granted)
}
}
case .location:
_ = await self.locationService.ensureAuthorization(mode: .whileUsing)
}
await self.refresh()
}
}