Files
OSGKeyboard/OSGKeyboard/Services/AppPermissions.swift
T
Rocky fb97ec2937 feat(keyboard): unify assistant voice and AI workflows
Merge dictation and AI controls into one assistant surface, preserve safe insertion and clipboard actions, and align settings and tests with the new flow.
2026-08-16 12:07:06 +08:00

138 lines
4.4 KiB
Swift

// AppPermissions.swift
// OSGKeyboard · Main App
//
// Central permission handling for onboarding, Flow, and clipboard access.
import AVFoundation
import Speech
import UIKit
enum AppPermissions {
enum MicStatus: Equatable {
case undetermined
case granted
case denied
}
enum SpeechStatus: Equatable {
case undetermined
case granted
case denied
case restricted
}
enum PasteAccessResult: Equatable {
case verified
case noTextAvailable
case unavailable
}
private static let pasteAccessVerifiedKey = "clipboard.pasteAccessVerified.v1"
static var micStatus: MicStatus {
switch AVAudioApplication.shared.recordPermission {
case .granted: return .granted
case .denied: return .denied
case .undetermined: return .undetermined
@unknown default: return .denied
}
}
static var speechStatus: SpeechStatus {
switch SFSpeechRecognizer.authorizationStatus() {
case .authorized: return .granted
case .denied: return .denied
case .restricted: return .restricted
case .notDetermined: return .undetermined
@unknown default: return .denied
}
}
/// Both permissions required for Flow voice sessions.
static var flowRequirementsMet: Bool {
micStatus == .granted && speechStatus == .granted
}
/// iOS does not expose the current "Paste from Other Apps" setting.
/// This records the last explicit, successful user-initiated read instead.
static var hasVerifiedPasteAccess: Bool {
UserDefaults.standard.bool(forKey: pasteAccessVerifiedKey)
}
/// Show guided permission pages when any Flow permission is not granted.
static var needsPermissionGuidance: Bool {
micStatus != .granted || speechStatus != .granted
}
static func requestMicrophone() async -> Bool {
switch micStatus {
case .granted: return true
case .denied: return false
case .undetermined: return await AVAudioApplication.requestRecordPermission()
}
}
static func requestSpeechRecognition() async -> Bool {
switch speechStatus {
case .granted: return true
case .denied, .restricted: return false
case .undetermined:
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
SFSpeechRecognizer.requestAuthorization { status in
cont.resume(returning: status == .authorized)
}
}
}
}
@MainActor
static func openSystemSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
/// Performs an explicit direct read so iOS can present paste authorization
/// and create the app's "Paste from Other Apps" settings entry.
@MainActor
@discardableResult
static func requestPasteAccess() -> PasteAccessResult {
let pasteboard = UIPasteboard.general
guard pasteboard.hasStrings else { return .noTextAvailable }
guard pasteboard.string != nil else {
UserDefaults.standard.set(false, forKey: pasteAccessVerifiedKey)
return .unavailable
}
UserDefaults.standard.set(true, forKey: pasteAccessVerifiedKey)
return .verified
}
/// Home-screen guidance when Flow permissions are missing after onboarding.
static var homePermissionGuidanceMessage: String {
let micMissing = micStatus != .granted
let speechMissing = speechStatus != .granted
if micMissing && speechMissing {
return AppL10n.string("home.setup.permission.both")
}
if micMissing {
return AppL10n.string("home.setup.permission.mic")
}
return AppL10n.string("home.setup.permission.speech")
}
/// True when at least one permission can still be requested in-app.
static var canRequestPermissionsInApp: Bool {
micStatus == .undetermined || speechStatus == .undetermined
}
/// Requests any still-undetermined Flow permissions in order.
static func requestFlowPermissionsIfNeeded() async {
if micStatus == .undetermined {
_ = await requestMicrophone()
}
if speechStatus == .undetermined {
_ = await requestSpeechRecognition()
}
}
}