79be7384dd
13 items, 555-line diff, build + 15/15 tests green.
ARCH-A3: Phase.error now carries ErrorKind (micDenied/speechDenied/asr/llm/
appGroupUnavailable/unknown) so the UI can pick icons/copy without parsing
free-form strings. Phase.ErrorKind, Phase, LLMError all Equatable.
ARCH-A4: Every TextField in APISettingsCard gets .keyboardType(.asciiCapable)
to defeat SwiftUI's iOS 18 system-keyboard hand-off that auto-suggests
Chinese/emoji and corrupts API keys / URLs / model names.
ARCH-A5 + DOC-3: PrivacyInfo.xcprivacy audited for honesty. Removed three
declared-but-unused APIs (FileTimestamp / DiskSpace / SystemBootTime) and
added ActiveKeyboards (DDA9.1) to the extension (it actually calls
advanceToNextInputMode in the tap path). Main App now declares only
UserDefaults (CA92.1). CHANGELOG updated.
ARCH-A6: Extracted PermissionManager (mic+speech permission flow, iOS 17
branching) and AppGroupPersistor (App Group load/persist) from the God
Object. KeyboardViewController drops 515 → 459 lines. KeyboardPipelineController
left in-place per risk plan — pressBegan state machine is too race-sensitive
to refactor in this pass.
RED-2: Deleted unused Theme enum (no call sites).
RED-3: Deleted unused cardStyle() alias (no call sites).
RED-7: Single source of truth for LLM timeout — LLMClient.requestTimeout +
LLMClientFactory.defaultRequestTimeout; PolishingService derives timeout from
defaultRequestTimeout+1 instead of hardcoding 15.
RED-8: ASRService.transcribe now emits .capability(onDeviceSupported:) as
first event per session; StatusBadge shows REC ⚠️ when the locale fell
back to cloud. New @Published var onDeviceSupported on State.
TEST-1: testPolishThrowsOnTransportTimeout now actually exercises
cancellation: StubURLProtocol delays response 5s, client.polish is
cancelled via Task.cancel(), test asserts the client throws .cancelled /
.transport / .decoding (was: silently passed).
TEST-2: New testPolisherSkipsNetworkWhenModeOff — PolishingService now
short-circuits when modeId == 'off' and returns trimmed input without
invoking LLMClient (proved via injected CountingLLMClient). Service was
moved to OSGKeyboardShared to be reachable from the test target.
TEST-3: KeyboardState (formerly KeyboardViewController.State) extracted
into OSGKeyboardShared so tests can @testable-import it. 5 phase/mode
tests in new KeyboardStateTests. Typealias preserves the old name.
TEST-4: New OSGKeyboardExtTests target with 6 tests covering State
initial values, phase transitions, structured-error round-trip, mode
switching, and InputMode rawValue round-trip.
78 lines
3.0 KiB
Swift
78 lines
3.0 KiB
Swift
// PermissionManager.swift
|
|
// OSGKeyboard · Keyboard Extension
|
|
//
|
|
// Extracted from KeyboardViewController so the view controller doesn't
|
|
// need to know about AVAudioApplication vs AVAudioSession branching
|
|
// or SFSpeechRecognizer.requestAuthorization callback bridging.
|
|
//
|
|
// Contract:
|
|
// • `requestMicPermission()` returns true if the user has authorised
|
|
// or *just* authorised; false otherwise. Idempotent within a
|
|
// process — the second call will not prompt again if the user has
|
|
// already answered.
|
|
// • `requestSpeechPermission()` mirrors the same shape but for
|
|
// SFSpeechRecognizer.
|
|
|
|
import Foundation
|
|
import AVFoundation
|
|
import Speech
|
|
|
|
@MainActor
|
|
public final class PermissionManager: @unchecked Sendable {
|
|
|
|
public init() {}
|
|
|
|
private var didRequestMicOnce: Bool = false
|
|
|
|
/// Request microphone access. Returns true if granted (already or
|
|
/// after this call). iOS 17 uses `AVAudioApplication.recordPermission`;
|
|
/// older systems fall back to `AVAudioSession.recordPermission`.
|
|
public func requestMicPermission() async -> Bool {
|
|
if #available(iOS 17.0, *) {
|
|
switch AVAudioApplication.shared.recordPermission {
|
|
case .granted: return true
|
|
case .denied: return false
|
|
case .undetermined:
|
|
if !didRequestMicOnce {
|
|
didRequestMicOnce = true
|
|
return await AVAudioApplication.requestRecordPermission()
|
|
}
|
|
return false
|
|
@unknown default: return false
|
|
}
|
|
} else {
|
|
let session = AVAudioSession.sharedInstance()
|
|
switch session.recordPermission {
|
|
case .granted: return true
|
|
case .denied: return false
|
|
case .undetermined:
|
|
if !didRequestMicOnce {
|
|
didRequestMicOnce = true
|
|
return await withCheckedContinuation { cont in
|
|
session.requestRecordPermission { cont.resume(returning: $0) }
|
|
}
|
|
}
|
|
return false
|
|
@unknown default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Request Speech Recognition permission. Returns true if granted
|
|
/// (already or after this call). For iOS 18 SFSpeechRecognizer this
|
|
/// is required before recognition can begin; for iOS 26 SpeechAnalyzer
|
|
/// the framework prompts on first use, so this call is a no-op there.
|
|
public func requestSpeechPermission() async -> Bool {
|
|
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
SFSpeechRecognizer.requestAuthorization { status in
|
|
switch status {
|
|
case .authorized: cont.resume(returning: true)
|
|
case .denied, .restricted, .notDetermined:
|
|
cont.resume(returning: false)
|
|
@unknown default:
|
|
cont.resume(returning: false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |