diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index cd7aff7..2d05160 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ 5BCED5AFADACD4118AE59AF9 /* Font+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */; }; 5E7322F3F3624211985F2D99 /* ICloudSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED090ADA1FB259AD2AFEDE2 /* ICloudSyncService.swift */; }; 5ECA2501EC257777DBE670AD /* EmptySlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68E5C82608F60FF8798BB81 /* EmptySlotView.swift */; }; + 64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */; }; 6553473B007AFD04079878C6 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F0A5C2FF1B797A4BB69B39 /* ContentView.swift */; }; 6685C591F837FAFB29A322F7 /* DeduplicationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3CBAFE191AD6643F861B7 /* DeduplicationService.swift */; }; 670A11A9850B321D9FF76B41 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */; }; @@ -170,6 +171,7 @@ 623EDDC4CD134883FC9D242D /* Dish.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dish.swift; sourceTree = ""; }; 62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PremiumSyncServiceTests.swift; sourceTree = ""; }; 69B309A43E486B5A723324BA /* WeekCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekCalendarView.swift; sourceTree = ""; }; + 69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpeechDictationServiceTests.swift; sourceTree = ""; }; 6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = ""; }; 70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = ""; }; @@ -399,6 +401,7 @@ 168A6CA95246E1BB72797D4E /* OnboardingViewModelTests.swift */, 17B976BF8B0BD6D1AA5F8C68 /* TagLocalizationTests.swift */, 62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */, + 69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */, ); path = MealMoodTests; sourceTree = ""; @@ -761,6 +764,7 @@ 016D78C0DC1B1F6E671F36F7 /* OnboardingViewModelTests.swift in Sources */, ACAEFA5F4C170C6438CD2652 /* TagLocalizationTests.swift in Sources */, 2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */, + 64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1017,7 +1021,7 @@ CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MealMood/Resources/Info.plist; @@ -1140,7 +1144,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = MealMood/Resources/MealMood.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MealMood/Resources/Info.plist; diff --git a/MealMood/Services/SpeechDictationService.swift b/MealMood/Services/SpeechDictationService.swift index b965db4..cef9c97 100644 --- a/MealMood/Services/SpeechDictationService.swift +++ b/MealMood/Services/SpeechDictationService.swift @@ -32,21 +32,43 @@ final class SpeechDictationService: ObservableObject { guard state != .recording else { return } transcript = "" - SFSpeechRecognizer.requestAuthorization { [weak self] speechAuth in - guard let self else { return } - Task { @MainActor in - guard speechAuth == .authorized else { self.state = .denied; return } - self.requestMicAndStart(localeIdentifier: localeIdentifier) + Task { @MainActor in + guard await Self.requestSpeechAuthorization() == .authorized else { + state = .denied + return + } + guard await Self.requestMicrophoneAccess() else { + state = .denied + return + } + beginSession(localeIdentifier: localeIdentifier) + } + } + + // MARK: Permissions + // + // Both are pre-concurrency Objective-C APIs. A closure literal written + // inside this `@MainActor` type is inferred as main-actor isolated, and + // Swift 6 then inserts a runtime isolation check on entry. TCC invokes these + // handlers on a background queue, so that check tripped `dispatch_assert_queue` + // and killed the app the moment the user answered the permission prompt — + // every time, including when permission had already been granted. + // + // Declaring the wrappers `nonisolated` keeps the handlers free of isolation; + // the continuation resumes safely from whatever queue TCC used. + + private nonisolated static func requestSpeechAuthorization() async -> SFSpeechRecognizerAuthorizationStatus { + await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) } } } - private func requestMicAndStart(localeIdentifier: String) { - AVAudioApplication.requestRecordPermission { [weak self] granted in - guard let self else { return } - Task { @MainActor in - guard granted else { self.state = .denied; return } - self.beginSession(localeIdentifier: localeIdentifier) + private nonisolated static func requestMicrophoneAccess() async -> Bool { + await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) } } } @@ -74,21 +96,29 @@ final class SpeechDictationService: ObservableObject { let inputNode = audioEngine.inputNode let format = inputNode.outputFormat(forBus: 0) inputNode.removeTap(onBus: 0) - inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in - self?.request?.append(buffer) + // Captures the request directly instead of reaching through `self`: + // this runs on the realtime audio thread, which must never touch + // main-actor state. `append` is designed to be fed from that thread. + inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in + request.append(buffer) } audioEngine.prepare() try audioEngine.start() state = .recording - task = recognizer.recognitionTask(with: request) { [weak self] result, error in - guard let self else { return } + // Same pre-concurrency shape as the permission handlers: this is + // called off the main thread, so the closure must not be isolated. + // The result is not Sendable, so only plain values cross the hop. + task = recognizer.recognitionTask(with: request) { @Sendable [weak self] result, error in + let text = result?.bestTranscription.formattedString + let hasFinished = error != nil || (result?.isFinal ?? false) Task { @MainActor in - if let result { - self.transcript = result.bestTranscription.formattedString + guard let self else { return } + if let text { + self.transcript = text } - if error != nil || (result?.isFinal ?? false) { + if hasFinished { self.teardownAudio() if self.state == .recording { self.state = .idle } } diff --git a/MealMoodTests/SpeechDictationServiceTests.swift b/MealMoodTests/SpeechDictationServiceTests.swift new file mode 100644 index 0000000..07e6f33 --- /dev/null +++ b/MealMoodTests/SpeechDictationServiceTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import MealMood + +/// Smoke cover for the dictation entry point. +/// +/// **This does not reproduce the crash it was written for.** The production +/// crash (`dispatch_assert_queue` inside the `requestAuthorization` handler) +/// needs TCC to deliver the callback asynchronously on a background queue, which +/// is what happens on device when the permission prompt is answered. The +/// simulator delivers it synchronously on the calling thread instead, so the +/// isolation check passes and the pre-fix code runs clean here — verified by +/// reverting the fix and watching these tests still pass. +/// +/// What it does cover: `start` settles instead of hanging, and does not report +/// recording when permission is unavailable. Verifying the crash itself needs a +/// real device with the speech and microphone permissions reset. +@MainActor +final class SpeechDictationServiceTests: XCTestCase { + + func testStartSettlesWithoutRecordingWhenPermissionIsUnavailable() async throws { + let service = SpeechDictationService() + XCTAssertEqual(service.state, .idle) + + service.start(localeIdentifier: "en-US") + + let deadline = Date().addingTimeInterval(10) + while service.state == .idle && Date() < deadline { + try await Task.sleep(nanoseconds: 100_000_000) + } + + XCTAssertNotEqual(service.state, .recording, + "No permission is granted in this environment, so it must not record") + } + + func testStartTwiceDoesNotBlowUp() { + let service = SpeechDictationService() + service.start(localeIdentifier: "en-US") + service.start(localeIdentifier: "en-US") + XCTAssertNotEqual(service.state, .recording) + } +}