diff --git a/Packages/Features/Sources/Recording/Models/Transcription.swift b/Packages/Features/Sources/Recording/Models/Transcription.swift index 7c2a23c..32e39cf 100644 --- a/Packages/Features/Sources/Recording/Models/Transcription.swift +++ b/Packages/Features/Sources/Recording/Models/Transcription.swift @@ -2,7 +2,7 @@ import Foundation /// A transcription of a processed recording. public struct Transcription: Equatable, Identifiable, Sendable { - + // MARK: Properties /// The unique identifier of the transcription. diff --git a/Packages/Features/Sources/Recording/Protocols/Capturing.swift b/Packages/Features/Sources/Recording/Protocols/Capturing.swift index e238875..200545a 100644 --- a/Packages/Features/Sources/Recording/Protocols/Capturing.swift +++ b/Packages/Features/Sources/Recording/Protocols/Capturing.swift @@ -47,7 +47,9 @@ public extension Capturing { // MARK: - Events /// An event emitted by a ``Capturing`` service outside its method calls. -public enum CapturingEvent: Sendable { +public enum CapturingEvent: Equatable, Sendable { /// The system interrupted the ongoing capture, pausing it. case interrupted + /// The system ended the interruption of the capture, hinting whether the capture may resume right away. + case interruptionEnded(shouldResume: Bool) } diff --git a/Packages/Features/Sources/Recording/Protocols/Preinstalling.swift b/Packages/Features/Sources/Recording/Protocols/Preinstalling.swift index ce660ab..52adbbb 100644 --- a/Packages/Features/Sources/Recording/Protocols/Preinstalling.swift +++ b/Packages/Features/Sources/Recording/Protocols/Preinstalling.swift @@ -36,6 +36,9 @@ public protocol Preinstalling: Sendable { // MARK: - Events /// An event emitted by a ``Preinstalling`` service while preinstalling speech model assets. +/// +/// Every case carries the locale the preinstallation was requested for — not its supported equivalent — so a host can match the +/// event against the locale it passed to ``Preinstalling/preinstall(for:)``. public enum PreinstallingEvent: Sendable { /// The download of the locale's speech model assets was cancelled before it finished. case cancelled(Locale) diff --git a/Packages/Features/Sources/Recording/Services/AssetPreinstalling.swift b/Packages/Features/Sources/Recording/Services/AssetPreinstalling.swift index 6e5f303..f8939db 100644 --- a/Packages/Features/Sources/Recording/Services/AssetPreinstalling.swift +++ b/Packages/Features/Sources/Recording/Services/AssetPreinstalling.swift @@ -74,18 +74,19 @@ public final class AssetPreinstalling: Preinstalling { } /// Preinstalls the speech model assets for the supported equivalent of the given locale, emitting the started, cancelled, and - /// failed downloads through ``events`` — a download that finishes, or assets that are already installed, emit nothing. + /// failed downloads through ``events`` — a download that finishes, or assets that are already installed, emit nothing. The + /// emitted events carry the given locale, not its supported equivalent, so a caller can match them against its requests. /// /// - Parameter locale: The locale to preinstall the speech model assets for. public func preinstall( for locale: Locale ) async { - guard let locale = await supportedLocale(equivalentTo: locale) else { + guard let supported = await supportedLocale(equivalentTo: locale) else { return } do { - try await install(for: locale) { [continuation] in + try await install(for: supported) { [continuation] in continuation.yield(.started(locale)) } } catch { @@ -94,7 +95,7 @@ public final class AssetPreinstalling: Preinstalling { } else { continuation.yield(.failed(locale)) - logger.error("The speech model assets for the \"\(locale.identifier, privacy: .public)\" locale failed to preinstall: \(String(describing: error), privacy: .public)") + logger.error("The speech model assets for the \"\(supported.identifier, privacy: .public)\" locale failed to preinstall: \(String(describing: error), privacy: .public)") } } } diff --git a/Packages/Features/Sources/Recording/Services/AudioCapturing.swift b/Packages/Features/Sources/Recording/Services/AudioCapturing.swift index 36c54ae..e4daac8 100644 --- a/Packages/Features/Sources/Recording/Services/AudioCapturing.swift +++ b/Packages/Features/Sources/Recording/Services/AudioCapturing.swift @@ -5,7 +5,8 @@ import AVFoundation /// The service records into a temporary `.m4a` file through an `AVAudioRecorder`, and returns the file's location when the recording stops. /// The user's permission to record is requested before a recording starts and, on the platforms that require it, the shared audio session is /// configured for recording while the capture is in progress. When the system interrupts the session while a recording is in progress — -/// because of a phone call or Siri, for example — the service emits ``CapturingEvent/interrupted`` through ``events``. +/// because of a phone call or Siri, for example — the service emits ``CapturingEvent/interrupted`` through ``events``, followed by +/// ``CapturingEvent/interruptionEnded(shouldResume:)`` once the interruption is over. @MainActor public final class AudioCapturing: Capturing { @@ -137,7 +138,7 @@ public final class AudioCapturing: Capturing { self.recorder = nil #if os(iOS) || os(visionOS) - try? AVAudioSession.sharedInstance().setActive(false) + try? AVAudioSession.sharedInstance().setActive(false) #endif return Constant.File.url @@ -146,31 +147,60 @@ public final class AudioCapturing: Capturing { } #if os(iOS) || os(visionOS) -// MARK: - Helpers + // MARK: - Helpers -private extension AudioCapturing { + private extension AudioCapturing { - /// Handles an interruption notification of the audio session, emitting ``CapturingEvent/interrupted`` through ``events`` - /// when the system began interrupting an ongoing recording. The end of an interruption is deliberately ignored: the capture is - /// never resumed without the user asking for it. - /// - /// - Parameter notification: The interruption notification posted by the audio session. - func handleInterruption( - _ notification: Notification - ) { - guard - recorder != nil, - let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, - AVAudioSession.InterruptionType(rawValue: typeValue) == .began - else { - return + /// Handles an interruption notification of the audio session while a recording exists, emitting the event the notification + /// describes through ``events``: the beginning of an interruption — which pauses the capture — emits + /// ``CapturingEvent/interrupted``, and its end emits ``CapturingEvent/interruptionEnded(shouldResume:)`` with the + /// system's hint on whether the capture may resume right away. + /// + /// - Parameter notification: The interruption notification posted by the audio session. + func handleInterruption( + _ notification: Notification + ) { + guard + recorder != nil, + let event = CapturingEvent(interruption: notification.userInfo) + else { + return + } + + continuation.yield(event) } - continuation.yield(.interrupted) + } +#endif + +// MARK: - CapturingEvent+Interruptions + +extension CapturingEvent { + + /// Creates the event described by the user info of an audio session interruption notification, or `nil` when the user info + /// describes no known interruption. + /// + /// - Parameter userInfo: The user info dictionary of the interruption notification. + init?( + interruption userInfo: [AnyHashable: Any]? + ) { + guard let typeValue = userInfo?[Constant.Interruption.keyType] as? UInt else { + return nil + } + + switch typeValue { + case Constant.Interruption.typeBegan: + self = .interrupted + case Constant.Interruption.typeEnded: + let optionsValue = userInfo?[Constant.Interruption.keyOptions] as? UInt ?? 0 + + self = .interruptionEnded(shouldResume: optionsValue & Constant.Interruption.optionShouldResume != 0) + default: + return nil + } } } -#endif // MARK: - Constants @@ -193,4 +223,32 @@ private enum Constant { /// The location of the temporary file the audio is captured into. static let url = FileManager.default.temporaryDirectory.appending(path: "recording.m4a") } + + /// The interruption constants, matching AVFoundation's audio session interruption keys and values — mirrored as literals on + /// the platforms without an audio session, so the interruption parsing stays compilable, and therefore testable, everywhere. + enum Interruption { + #if os(iOS) || os(visionOS) + /// The user info key of the interruption options. + static let keyOptions = AVAudioSessionInterruptionOptionKey + /// The user info key of the interruption type. + static let keyType = AVAudioSessionInterruptionTypeKey + /// The raw value of the interruption option hinting the capture may resume. + static let optionShouldResume = AVAudioSession.InterruptionOptions.shouldResume.rawValue + /// The raw value of the beginning of an interruption. + static let typeBegan = AVAudioSession.InterruptionType.began.rawValue + /// The raw value of the end of an interruption. + static let typeEnded = AVAudioSession.InterruptionType.ended.rawValue + #else + /// The user info key of the interruption options. + static let keyOptions = "AVAudioSessionInterruptionOptionKey" + /// The user info key of the interruption type. + static let keyType = "AVAudioSessionInterruptionTypeKey" + /// The raw value of the interruption option hinting the capture may resume. + static let optionShouldResume: UInt = 1 + /// The raw value of the beginning of an interruption. + static let typeBegan: UInt = 1 + /// The raw value of the end of an interruption. + static let typeEnded: UInt = 0 + #endif + } } diff --git a/Packages/Features/Sources/Recording/Styles/Buttons/RecordingButtonStyle.swift b/Packages/Features/Sources/Recording/Styles/Buttons/RecordingButtonStyle.swift index 3ef942b..c009a71 100644 --- a/Packages/Features/Sources/Recording/Styles/Buttons/RecordingButtonStyle.swift +++ b/Packages/Features/Sources/Recording/Styles/Buttons/RecordingButtonStyle.swift @@ -13,17 +13,21 @@ struct RecordingButtonStyle: ButtonStyle { // MARK: Properties /// The color scheme of the environment. - @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorScheme) + private var colorScheme /// Whether the button allows user interaction. - @Environment(\.isEnabled) private var isEnabled - + @Environment(\.isEnabled) + private var isEnabled + /// The padding between the button's label and its background, scaled relative to the current dynamic type size. - @ScaledMetric private var padding = Constant.Padding.button + @ScaledMetric + private var padding = Constant.Padding.button /// The width and height of the button's label, scaled relative to the current dynamic type size. - @ScaledMetric private var size = Constant.Size.button - + @ScaledMetric + private var size = Constant.Size.button + /// Whether the color scheme of the button's label should be inverted from the environment's. private let invertStyle: Bool @@ -57,7 +61,7 @@ struct RecordingButtonStyle: ButtonStyle { .foregroundStyle(.windowBackground.opacity(opacity)) .environment( \.colorScheme, - colorSchemeLabel + colorSchemeLabel ) .glassEffect( .regular @@ -73,9 +77,9 @@ struct RecordingButtonStyle: ButtonStyle { // MARK: - Helpers private extension RecordingButtonStyle { - + // MARK: Computed - + /// The color scheme for the button's label: the opposite of the environment's when ``invertStyle`` is set, the environment's otherwise. var colorSchemeLabel: ColorScheme { guard invertStyle else { @@ -147,9 +151,12 @@ private enum Constant { #Preview( "Recording button style" ) { - @Previewable @State var isDisabled: Bool = false - @Previewable @State var isStyleInverted: Bool = false - + @Previewable @State + var isDisabled: Bool = false + + @Previewable @State + var isStyleInverted: Bool = false + Button { // Button action closure. } label: { diff --git a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift index e7a95ae..308bce7 100644 --- a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift +++ b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift @@ -5,9 +5,13 @@ extension RecordingView { /// The observable model that drives ``RecordingView``. /// - /// The model implements the recording flow as a ``State`` machine: it exposes the visibility and icon of the view's controls for the current state, counts the - /// elapsed recording time, and processes the recorded input once it is sent. The audio capture and its transcription are delegated to the - /// ``Capturing`` and ``Transcribing`` attached at initialization; when either of them fails, the model falls back to the not-recording state. + /// The model implements the recording flow as a ``State`` machine: it exposes the visibility, icons, accessibility labels, and alert + /// message of the view's controls for the current state, counts the elapsed recording time, and processes the recorded input once + /// it is sent. The audio capture and its transcription are delegated to the ``Capturing`` and ``Transcribing`` attached at + /// initialization; when either of them fails, the model falls back to the not-recording state and publishes the failure in ``error``. + /// + /// A recording paused by a system interruption of the capture resumes by itself once the interruption ends with the system's + /// resume hint, and a recording already being processed can still be discarded, cancelling its in-flight transcription. @MainActor @Observable final class Model { @@ -33,7 +37,7 @@ extension RecordingView { /// The instant the current recording stretch started, or `nil` while not recording. @ObservationIgnored private var anchor: ContinuousClock.Instant? - + /// The service that captures the audio from a microphone. @ObservationIgnored private let capturer: any Capturing @@ -42,6 +46,10 @@ extension RecordingView { @ObservationIgnored private let clock = ContinuousClock() + /// Whether the recording was paused by a system interruption of the audio capture, as opposed to by the user. + @ObservationIgnored + private var isPausedByInterruption = false + /// The binding to the locale of the spoken language to transcribe. @ObservationIgnored private let locale: Binding @@ -103,6 +111,22 @@ extension RecordingView { state == .processing } + /// The accessibility label for the main button, matching its action in the current state. + var labelMain: LocalizedStringResource { + switch state { + case .notRecording, .processing: .viewRecordingButtonMainLabelRecord + case .recording: .viewRecordingButtonMainLabelPause + case .paused: .viewRecordingButtonMainLabelResume + } + } + + /// The accessibility label for the send button: the sending action while paused, the ongoing processing otherwise. + var labelSend: LocalizedStringResource { + isProcessing + ? .viewRecordingButtonSendLabelProcessing + : .viewRecordingButtonSendLabelSend + } + /// Whether the main button should be disabled. var shouldDisableMain: Bool { state == .processing @@ -119,6 +143,17 @@ extension RecordingView { state != .notRecording } + /// The localized message describing ``error`` for the alert, or `nil` when no error occurred. + var textAlertMessage: LocalizedStringResource? { + switch error { + case .assetsUnavailable: .viewRecordingAlertErrorMessageAssets + case .captureFailed: .viewRecordingAlertErrorMessageCapture + case .permissionDenied: .viewRecordingAlertErrorMessagePermission + case .transcriptionFailed: .viewRecordingAlertErrorMessageTranscription + default: nil + } + } + /// The elapsed recording time, formatted as `mm:ss` for the timer label. var textTimer: String { Duration @@ -140,26 +175,45 @@ extension RecordingView { /// Handles a press of the discard button. /// - /// Throws away a paused recording: it resets the timer, returns to the not-recording state, and stops the audio capture, - /// deleting the captured audio file. Does nothing in any other state. + /// Throws away a paused recording — or one already being processed, cancelling its in-flight transcription: it resets the + /// timer, returns to the not-recording state, and stops the audio capture, deleting the captured audio file. Does nothing in + /// any other state. func pressedDiscard() { - guard state == .paused else { - return - } + isPausedByInterruption = false - state = .notRecording + switch state { + case .paused: + state = .notRecording - stopTimer() + stopTimer() - accumulated = .zero - elapsedSeconds = 0 + accumulated = .zero + elapsedSeconds = 0 - enqueueCapturer { - guard let audio = try? await self.capturer.stop() else { - return + enqueueCapturer { + guard let audio = try? await self.capturer.stop() else { + return + } + + try? FileManager.default.removeItem(at: audio) } + case .processing: + taskCapturer?.cancel() - try? FileManager.default.removeItem(at: audio) + state = .notRecording + + accumulated = .zero + elapsedSeconds = 0 + + enqueueCapturer { + guard let audio = try? await self.capturer.stop() else { + return + } + + try? FileManager.default.removeItem(at: audio) + } + default: + break } } @@ -168,6 +222,8 @@ extension RecordingView { /// Starts a recording when idle, pauses an ongoing recording, or resumes a paused one — starting and stopping the timer /// and the audio capture accordingly. Does nothing while the input is being processed. func pressedMain() { + isPausedByInterruption = false + switch state { case .notRecording: state = .recording @@ -178,10 +234,7 @@ extension RecordingView { case .recording: pauseRecording() case .paused: - state = .recording - - startTimer(false) - startCapturer(false) + resumeRecording() case .processing: break } @@ -196,6 +249,8 @@ extension RecordingView { /// /// Moves a paused recording into processing, stopping the timer and kicking off ``processInput()``. Does nothing in any other state. func pressedSend() { + isPausedByInterruption = false + switch state { case .paused: state = .processing @@ -232,7 +287,8 @@ private extension RecordingView.Model { /// Processes the recorded input: it stops the audio capture, transcribes the captured audio, and publishes the result in ``transcription``; /// when finished — or when either service fails, in which case the failure is published in ``error`` — it resets ``elapsedSeconds`` - /// and returns the model to the not-recording state. + /// and returns the model to the not-recording state. A processing cancelled through the discard button publishes nothing: + /// the discard already reset the model. func processInput() async { guard state == .processing else { return @@ -242,21 +298,46 @@ private extension RecordingView.Model { let audio = try await capturer.stop() do { - transcription = try await transcribe( + let transcription = try await transcribe( audio, locale: locale.wrappedValue ) - } catch { + + guard !Task.isCancelled else { + return + } + + self.transcription = transcription + } + catch is CancellationError { + return + } + catch { + guard !Task.isCancelled else { + return + } + transcription = nil - self.error = error as? AudioTranscribingError == .assetsNotInstalled + self.error = + error as? AudioTranscribingError == .assetsNotInstalled ? .assetsUnavailable : .transcriptionFailed } + } catch is CancellationError { + return } catch { + guard !Task.isCancelled else { + return + } + transcription = nil self.error = .captureFailed } + guard !Task.isCancelled else { + return + } + accumulated = .zero elapsedSeconds = 0 state = .notRecording @@ -276,7 +357,8 @@ private extension RecordingView.Model { } /// Starts listening to the events emitted by the capturing service, unless already listening: an interruption of the capture pauses - /// an ongoing recording, exactly like a press of the main button would. + /// an ongoing recording, exactly like a press of the main button would, and the end of an interruption resumes the recording it + /// paused — but only when the system hints the capture may resume, and never one the user paused themselves. func listenToEvents() { guard taskEvents == nil else { return @@ -291,8 +373,16 @@ private extension RecordingView.Model { switch event { case .interrupted: if state == .recording { + isPausedByInterruption = true + pauseRecording() } + case .interruptionEnded(let shouldResume): + if shouldResume, state == .paused, isPausedByInterruption { + resumeRecording() + } + + isPausedByInterruption = false } } } @@ -309,6 +399,14 @@ private extension RecordingView.Model { } } + /// Resumes the paused recording: it restarts the timer, without resetting it, and resumes the audio capture. + func resumeRecording() { + state = .recording + + startTimer(false) + startCapturer(false) + } + /// Starts the audio capture through the attached ``Capturing``, falling back to the not-recording state and publishing the failure /// in ``error`` when the service fails. /// @@ -325,7 +423,8 @@ private extension RecordingView.Model { self.stopTimer() self.state = .notRecording - self.error = error as? AudioCapturingError == .permissionNotGranted + self.error = + error as? AudioCapturingError == .permissionNotGranted ? .permissionDenied : .captureFailed } diff --git a/Packages/Features/Sources/Recording/Views/RecordingView.swift b/Packages/Features/Sources/Recording/Views/RecordingView.swift index 61a6deb..518f4c5 100644 --- a/Packages/Features/Sources/Recording/Views/RecordingView.swift +++ b/Packages/Features/Sources/Recording/Views/RecordingView.swift @@ -1,7 +1,7 @@ import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #endif /// A view that drives a recording session. @@ -11,17 +11,20 @@ import UIKit /// /// All state and control behavior lives in the view's ``Model``; the view itself only renders it and forwards button presses. public struct RecordingView: View { - + // MARK: Properties - + /// The action that opens a URL, used to open the app's settings from the error alert. - @Environment(\.openURL) private var openURL + @Environment(\.openURL) + private var openURL + + /// The font size of the timer's label, scaled relative to the current dynamic type size. + @ScaledMetric + private var fontSize = Constant.Size.timer /// The model that owns the recording state and drives the view's controls. - @State private var model: Model - - /// The font size of the timer's label, scaled relative to the current dynamic type size. - @ScaledMetric private var fontSize = Constant.Size.timer + @State + private var model: Model /// The closure invoked with the transcription of every processed recording. private let onTranscription: (Transcription) -> Void @@ -69,10 +72,10 @@ public struct RecordingView: View { .easeInOut, value: model.textTimer ) - .accessibilityLabel(Constant.Text.labelTimer) + .accessibilityLabel(.viewRecordingTimerLabel) .accessibilityValue(model.textTimerAccessible) } - + GlassEffectContainer { HStack( spacing: Constant.Spacing.stack @@ -87,7 +90,7 @@ public struct RecordingView: View { invertStyle: true )) .disabled(model.shouldDisableMain) - .accessibilityLabel(labelMain) + .accessibilityLabel(model.labelMain) if model.shouldShowActions { Button { @@ -104,19 +107,18 @@ public struct RecordingView: View { invertStyle: !model.isProcessing )) .disabled(model.isProcessing) - .accessibilityLabel(labelSend) - + .accessibilityLabel(model.labelSend) + Button { model.pressedDiscard() } label: { - Image(systemName: Constant.Symbol.discard) + Image.Icon.discard .resizable() } .buttonStyle(.recording( invertStyle: true )) - .disabled(model.shouldDisableMain) - .accessibilityLabel(Constant.Text.labelDiscard) + .accessibilityLabel(.viewRecordingButtonDiscardLabel) } } } @@ -134,72 +136,32 @@ public struct RecordingView: View { } } .alert( - Constant.Text.titleError, - isPresented: .init( - get: { model.error != nil }, - set: { isPresented in - if !isPresented { - model.dismissedError() - } + .viewRecordingAlertErrorTitle, + isPresented: .init { + model.error != nil + } set: { isPresented in + if !isPresented { + model.dismissedError() } - ), + }, presenting: model.error ) { error in - if error == .permissionDenied, let url = Constant.URLs.settings { - Button(Constant.Text.buttonSettings) { + if error == .permissionDenied, let url = URL.settings { + Button(.viewRecordingAlertErrorButtonSettings) { openURL(url) } } Button( - Constant.Text.buttonOK, + .viewRecordingAlertErrorButtonOk, role: .cancel ) { // Dismissal is handled by the presentation binding. } - } message: { error in - Text(message(for: error)) - } - } - -} - -// MARK: - Helpers - -private extension RecordingView { - - // MARK: Computed - - /// The accessibility label for the main button, matching its action in the current state. - var labelMain: String { - switch model.state { - case .notRecording, .processing: Constant.Text.labelRecord - case .recording: Constant.Text.labelPause - case .paused: Constant.Text.labelResume - } - } - - /// The accessibility label for the send button: the sending action while paused, the ongoing processing otherwise. - var labelSend: String { - model.isProcessing - ? Constant.Text.labelProcessing - : Constant.Text.labelSend - } - - // MARK: Methods - - /// Returns the message describing the given error for the alert. - /// - /// - Parameter error: The error to describe. - /// - Returns: The message describing the error. - func message( - for error: RecordingError - ) -> String { - switch error { - case .assetsUnavailable: Constant.Text.messageAssetsUnavailable - case .captureFailed: Constant.Text.messageCaptureFailed - case .permissionDenied: Constant.Text.messagePermissionDenied - case .transcriptionFailed: Constant.Text.messageTranscriptionFailed + } message: { _ in + if let message = model.textAlertMessage { + Text(message) + } } } @@ -219,98 +181,29 @@ private enum Constant { /// The spacing between the elements of a stack. static let stack: CGFloat = 16 } +} +// MARK: - Image+Constants + +private extension Image { /// The symbol constants. - enum Symbol { + enum Icon { /// The system symbol of the discard button's image. - static let discard = "trash" + static let discard = Image(systemName: "trash") } +} - /// The text constants, localized through the package's string catalog. - enum Text { - /// The title of the alert's dismissal button. - static let buttonOK = String( - localized: "view.recording.alert.error.button.ok", - bundle: .module - ) - /// The accessibility label of the discard button. - static let labelDiscard = String( - localized: "view.recording.button.discard.label", - bundle: .module - ) - /// The accessibility label of the main button while recording. - static let labelPause = String( - localized: "view.recording.button.main.label.pause", - bundle: .module - ) - /// The accessibility label of the send button while processing. - static let labelProcessing = String( - localized: "view.recording.button.send.label.processing", - bundle: .module - ) - /// The accessibility label of the main button while not recording. - static let labelRecord = String( - localized: "view.recording.button.main.label.record", - bundle: .module - ) - /// The accessibility label of the main button while paused. - static let labelResume = String( - localized: "view.recording.button.main.label.resume", - bundle: .module - ) - /// The accessibility label of the send button while paused. - static let labelSend = String( - localized: "view.recording.button.send.label.send", - bundle: .module - ) - /// The accessibility label of the timer. - static let labelTimer = String( - localized: "view.recording.timer.label", - bundle: .module - ) - /// The title of the alert's button that opens the app's settings. - static let buttonSettings = String( - localized: "view.recording.alert.error.button.settings", - bundle: .module - ) - /// The message describing unavailable speech model assets. - static let messageAssetsUnavailable = String( - localized: "view.recording.alert.error.message.assets", - bundle: .module - ) - /// The message describing a failure of the audio capture. - static let messageCaptureFailed = String( - localized: "view.recording.alert.error.message.capture", - bundle: .module - ) - /// The message describing a denied microphone permission. - static let messagePermissionDenied = String( - localized: "view.recording.alert.error.message.permission", - bundle: .module - ) - /// The message describing a failure of the transcription. - static let messageTranscriptionFailed = String( - localized: "view.recording.alert.error.message.transcription", - bundle: .module - ) - /// The title of the error alert. - static let titleError = String( - localized: "view.recording.alert.error.title", - bundle: .module - ) - } +// MARK: - URL+Constants - /// The URL constants. - enum URLs { - /// The location that opens the app's settings, where the microphone permission can be granted. - static let settings: URL? = { - #if os(macOS) - URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") - #else - URL(string: UIApplication.openSettingsURLString) - #endif - }() - } +private extension URL { + /// The location that opens the app's settings, where the microphone permission can be granted. + static let settings: URL? = { + #if os(macOS) + .init(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone") + #else + .init(string: UIApplication.openSettingsURLString) + #endif + }() } // MARK: - Previews diff --git a/Packages/Features/Tests/Recording/Mocks/CapturingMock.swift b/Packages/Features/Tests/Recording/Mocks/CapturingMock.swift index 4c640ea..be82ba7 100644 --- a/Packages/Features/Tests/Recording/Mocks/CapturingMock.swift +++ b/Packages/Features/Tests/Recording/Mocks/CapturingMock.swift @@ -5,6 +5,8 @@ import Recording /// configured to fail. @MainActor final class CapturingMock: Capturing { + + // MARK: Properties /// The stream of events the service emits outside its method calls. let events: AsyncStream @@ -20,16 +22,29 @@ final class CapturingMock: Capturing { /// The continuation that feeds ``events``. private let continuation: AsyncStream.Continuation + + // MARK: Initializers init() { (events, continuation) = AsyncStream.makeStream(of: CapturingEvent.self) } + + // MARK: Methods /// Emits an interruption of the ongoing capture through ``events``. func interrupt() { continuation.yield(.interrupted) } + /// Emits the end of an interruption of the capture through ``events``. + /// + /// - Parameter shouldResume: Whether the capture may resume right away. + func endInterruption( + shouldResume: Bool + ) { + continuation.yield(.interruptionEnded(shouldResume: shouldResume)) + } + func start() async throws { try await called("start") } diff --git a/Packages/Features/Tests/Recording/Mocks/PreinstallingMock.swift b/Packages/Features/Tests/Recording/Mocks/PreinstallingMock.swift deleted file mode 100644 index a5da06f..0000000 --- a/Packages/Features/Tests/Recording/Mocks/PreinstallingMock.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation -import Recording - -/// A preinstalling service that records its requests, resolves the supported locales from configurable tables, and emits events -/// on demand. -@MainActor -final class PreinstallingMock: Preinstalling { - - /// The stream of events the service emits while preinstalling. - let events: AsyncStream - - /// The supported equivalents the service resolves, keyed by the requested locale. - var equivalents: [Locale: Locale] = [:] - - /// The locales the service reports as supported. - var localesSupported: [Locale] = [] - - /// The locales a preinstallation was requested for, in request order. - private(set) var localesPreinstalled: [Locale] = [] - - /// The continuation that feeds ``events``. - private let continuation: AsyncStream.Continuation - - init() { - (events, continuation) = AsyncStream.makeStream(of: PreinstallingEvent.self) - } - - /// Emits the given event through ``events``. - /// - /// - Parameter event: The event to emit. - func emit( - _ event: PreinstallingEvent - ) { - continuation.yield(event) - } - - func preinstall( - for locale: Locale - ) async { - localesPreinstalled.append(locale) - } - - func supportedLocale( - equivalentTo locale: Locale - ) async -> Locale? { - equivalents[locale] - } - - func supportedLocales() async -> [Locale] { - localesSupported - } - -} diff --git a/Packages/Features/Tests/Recording/Mocks/TranscribingMock.swift b/Packages/Features/Tests/Recording/Mocks/TranscribingMock.swift index 9aedbc4..45e031e 100644 --- a/Packages/Features/Tests/Recording/Mocks/TranscribingMock.swift +++ b/Packages/Features/Tests/Recording/Mocks/TranscribingMock.swift @@ -4,6 +4,8 @@ import Recording /// A transcribing service with a configurable delay, output, and failure. @MainActor final class TranscribingMock: Transcribing { + + // MARK: Properties /// The duration of the simulated transcription work. var delay: Duration = .seconds(0.2) @@ -13,6 +15,8 @@ final class TranscribingMock: Transcribing { /// The transcription the service returns. var transcription = "This is a mocked transcription." + + // MARK: Methods func callAsFunction( _ audio: URL, diff --git a/Packages/Features/Tests/Recording/Models/TranscriptionTests.swift b/Packages/Features/Tests/Recording/Models/TranscriptionTests.swift index 3f8ed1f..8efc09a 100644 --- a/Packages/Features/Tests/Recording/Models/TranscriptionTests.swift +++ b/Packages/Features/Tests/Recording/Models/TranscriptionTests.swift @@ -15,7 +15,7 @@ struct TranscriptionTests { "", "This is a transcription.", " ", - "Multiline\ntranscribed\ntext." + "Multiline\ntranscribed\ntext.", ]) func `initializer stores the given text`(text: String) { let transcription = Transcription(text: text) @@ -23,7 +23,8 @@ struct TranscriptionTests { #expect(transcription.text == text) } - @Test func `initializer stores the given identifier`() { + @Test + func `initializer stores the given identifier`() { let id = UUID() let transcription = Transcription( id: id, @@ -40,10 +41,12 @@ struct TranscriptionTests { @Suite("Computed properties") struct ComputedProperties { - @Test(arguments: zip( - ["", "This is a transcription.", " "], - [true, false, false] - )) + @Test( + arguments: zip( + ["", "This is a transcription.", " "], + [true, false, false] + ) + ) func `is empty only when the text has no characters`( for text: String, expected: Bool @@ -60,7 +63,8 @@ struct TranscriptionTests { @Suite("Equatable") struct EquatableConformance { - @Test func `transcriptions with the same identifier and text are equal`() { + @Test + func `transcriptions with the same identifier and text are equal`() { let id = UUID() let first = Transcription( @@ -75,7 +79,8 @@ struct TranscriptionTests { #expect(first == second) } - @Test func `transcriptions with generated identifiers are never equal`() { + @Test + func `transcriptions with generated identifiers are never equal`() { let first = Transcription(text: "This is a transcription.") let second = Transcription(text: "This is a transcription.") @@ -89,13 +94,15 @@ struct TranscriptionTests { @Suite("Identifiable") struct IdentifiableConformance { - @Test func `identifier is stable across accesses`() { + @Test + func `identifier is stable across accesses`() { let transcription = Transcription(text: "This is a transcription.") #expect(transcription.id == transcription.id) } - @Test func `identifiers differ between transcriptions`() { + @Test + func `identifiers differ between transcriptions`() { let first = Transcription(text: "This is a transcription.") let second = Transcription(text: "This is a transcription.") diff --git a/Packages/Features/Tests/Recording/Services/AudioCapturingTests.swift b/Packages/Features/Tests/Recording/Services/AudioCapturingTests.swift new file mode 100644 index 0000000..c1163e4 --- /dev/null +++ b/Packages/Features/Tests/Recording/Services/AudioCapturingTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing + +@testable import Recording + +@Suite("Capturing interruption events") +struct CapturingEventInterruptionTests { + + @Test + func `a began interruption maps to the interrupted event`() { + let event = CapturingEvent(interruption: [ + "AVAudioSessionInterruptionTypeKey": UInt(1) + ]) + + #expect(event == .interrupted) + } + + @Test + func `an ended interruption without options maps to a non-resuming end event`() { + let event = CapturingEvent(interruption: [ + "AVAudioSessionInterruptionTypeKey": UInt(0) + ]) + + #expect(event == .interruptionEnded(shouldResume: false)) + } + + @Test + func `an ended interruption with the resume option maps to a resuming end event`() { + let event = CapturingEvent(interruption: [ + "AVAudioSessionInterruptionTypeKey": UInt(0), + "AVAudioSessionInterruptionOptionKey": UInt(1), + ]) + + #expect(event == .interruptionEnded(shouldResume: true)) + } + + @Test + func `a missing interruption type maps to no event`() { + #expect(CapturingEvent(interruption: [:]) == nil) + #expect(CapturingEvent(interruption: nil) == nil) + } + + @Test + func `an unknown interruption type maps to no event`() { + let event = CapturingEvent(interruption: [ + "AVAudioSessionInterruptionTypeKey": UInt(99) + ]) + + #expect(event == nil) + } + + @Test + func `a mistyped interruption type maps to no event`() { + let event = CapturingEvent(interruption: [ + "AVAudioSessionInterruptionTypeKey": "began" + ]) + + #expect(event == nil) + } + +} diff --git a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift index c2e68be..c50d4d0 100644 --- a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift +++ b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift @@ -14,12 +14,14 @@ struct RecordingViewModelTests { @Suite("Initial state") struct InitialState { - @Test func `initial state is not recording with a zeroed timer`() { + @Test + func `initial state is not recording with a zeroed timer`() { let model = Model() #expect(model.state == .notRecording) #expect(model.elapsedSeconds == 0) #expect(model.error == nil) + #expect(model.textAlertMessage == nil) #expect(model.textTimer == "00:00") #expect(model.transcription == nil) } @@ -68,7 +70,7 @@ struct RecordingViewModelTests { @Test(arguments: zip( [Model.State.notRecording, .recording, .paused, .processing], - [Model.State.notRecording, .recording, .notRecording, .processing] + [Model.State.notRecording, .recording, .notRecording, .notRecording] )) func `pressed discard transitions to the expected state`( from initial: Model.State, @@ -136,6 +138,46 @@ struct RecordingViewModelTests { #expect(model.isProcessing == expected) } + @Test(arguments: zip( + [Model.State.notRecording, .recording, .paused, .processing], + [ + LocalizedStringResource.viewRecordingButtonMainLabelRecord, + .viewRecordingButtonMainLabelPause, + .viewRecordingButtonMainLabelResume, + .viewRecordingButtonMainLabelRecord, + ] + )) + func `main label matches the state`( + for state: Model.State, + expected: LocalizedStringResource + ) { + let model = Model() + + model.drive(to: state) + + #expect(model.labelMain == expected) + } + + @Test(arguments: zip( + [Model.State.notRecording, .recording, .paused, .processing], + [ + LocalizedStringResource.viewRecordingButtonSendLabelSend, + .viewRecordingButtonSendLabelSend, + .viewRecordingButtonSendLabelSend, + .viewRecordingButtonSendLabelProcessing, + ] + )) + func `send label matches the state`( + for state: Model.State, + expected: LocalizedStringResource + ) { + let model = Model() + + model.drive(to: state) + + #expect(model.labelSend == expected) + } + @Test(arguments: zip( [Model.State.notRecording, .recording, .paused, .processing], [false, false, false, true] @@ -155,7 +197,7 @@ struct RecordingViewModelTests { [Model.State.notRecording, .recording, .paused, .processing], [false, false, true, true] )) - func `discard button is visible only while paused`( + func `discard button is visible while paused or processing`( for state: Model.State, expected: Bool ) { @@ -204,7 +246,8 @@ struct RecordingViewModelTests { @Suite("Timer") struct Timer { - @Test func `ticks while recording`() async throws { + @Test + func `ticks while recording`() async throws { let model = Model() model.pressedMain() @@ -217,7 +260,8 @@ struct RecordingViewModelTests { model.pressedMain() } - @Test func `stops while paused`() async throws { + @Test + func `stops while paused`() async throws { let model = Model() model.pressedMain() @@ -234,7 +278,8 @@ struct RecordingViewModelTests { #expect(model.elapsedSeconds == secondsWhenPaused) } - @Test func `keeps elapsed seconds when resumed`() async throws { + @Test + func `keeps elapsed seconds when resumed`() async throws { let model = Model() model.pressedMain() @@ -252,7 +297,8 @@ struct RecordingViewModelTests { model.pressedMain() } - @Test func `restarts for a new recording`() async throws { + @Test + func `restarts for a new recording`() async throws { let model = Model( transcribe: TranscribingMock() ) @@ -283,7 +329,8 @@ struct RecordingViewModelTests { @Suite("Capturer") struct Capturer { - @Test func `starts the capture for a new recording`() async throws { + @Test + func `starts the capture for a new recording`() async throws { let capturer = CapturingMock() let model = Model(capturer: capturer) @@ -296,7 +343,8 @@ struct RecordingViewModelTests { model.pressedMain() } - @Test func `pauses the capture while paused`() async throws { + @Test + func `pauses the capture while paused`() async throws { let capturer = CapturingMock() let model = Model(capturer: capturer) @@ -308,7 +356,8 @@ struct RecordingViewModelTests { #expect(capturer.calls == ["start", "pause"]) } - @Test func `resumes the capture after a pause`() async throws { + @Test + func `resumes the capture after a pause`() async throws { let capturer = CapturingMock() let model = Model(capturer: capturer) @@ -323,7 +372,8 @@ struct RecordingViewModelTests { model.pressedMain() } - @Test func `stops the capture when the input is sent`() async throws { + @Test + func `stops the capture when the input is sent`() async throws { let capturer = CapturingMock() let model = Model( capturer: capturer, @@ -337,7 +387,8 @@ struct RecordingViewModelTests { #expect(capturer.calls == ["start", "pause", "stop"]) } - @Test func `stops the capture when a paused recording is discarded`() async throws { + @Test + func `stops the capture when a paused recording is discarded`() async throws { let capturer = CapturingMock() let model = Model(capturer: capturer) @@ -352,7 +403,8 @@ struct RecordingViewModelTests { #expect(model.elapsedSeconds == 0) } - @Test func `serializes the calls when states change rapidly`() async throws { + @Test + func `serializes the calls when states change rapidly`() async throws { let capturer = CapturingMock() capturer.delay = .seconds(0.2) @@ -374,7 +426,8 @@ struct RecordingViewModelTests { model.pressedMain() } - @Test func `falls back to not recording when the capture fails`() async throws { + @Test + func `falls back to not recording when the capture fails`() async throws { let capturer = CapturingMock() capturer.error = ErrorMock() @@ -396,7 +449,8 @@ struct RecordingViewModelTests { @Suite("Errors") struct Errors { - @Test func `surfaces a capture failure`() async throws { + @Test + func `surfaces a capture failure`() async throws { let capturer = CapturingMock() capturer.error = ErrorMock() @@ -409,9 +463,11 @@ struct RecordingViewModelTests { #expect(model.error == .captureFailed) #expect(model.state == .notRecording) + #expect(model.textAlertMessage == .viewRecordingAlertErrorMessageCapture) } - @Test func `surfaces a denied microphone permission`() async throws { + @Test + func `surfaces a denied microphone permission`() async throws { let capturer = CapturingMock() capturer.error = AudioCapturingError.permissionNotGranted @@ -423,9 +479,11 @@ struct RecordingViewModelTests { try await Task.sleep(for: .seconds(0.1)) #expect(model.error == .permissionDenied) + #expect(model.textAlertMessage == .viewRecordingAlertErrorMessagePermission) } - @Test func `surfaces unavailable speech model assets`() async throws { + @Test + func `surfaces unavailable speech model assets`() async throws { let transcriber = TranscribingMock() transcriber.error = AudioTranscribingError.assetsNotInstalled @@ -439,10 +497,12 @@ struct RecordingViewModelTests { try await Task.sleep(for: .seconds(0.5)) #expect(model.error == .assetsUnavailable) + #expect(model.textAlertMessage == .viewRecordingAlertErrorMessageAssets) #expect(model.transcription == nil) } - @Test func `surfaces a transcription failure`() async throws { + @Test + func `surfaces a transcription failure`() async throws { let transcriber = TranscribingMock() transcriber.error = ErrorMock() @@ -456,10 +516,12 @@ struct RecordingViewModelTests { try await Task.sleep(for: .seconds(0.5)) #expect(model.error == .transcriptionFailed) + #expect(model.textAlertMessage == .viewRecordingAlertErrorMessageTranscription) #expect(model.transcription == nil) } - @Test func `clears the error when dismissed`() async throws { + @Test + func `clears the error when dismissed`() async throws { let capturer = CapturingMock() capturer.error = ErrorMock() @@ -475,9 +537,11 @@ struct RecordingViewModelTests { model.dismissedError() #expect(model.error == nil) + #expect(model.textAlertMessage == nil) } - @Test func `clears the error when a new recording starts`() async throws { + @Test + func `clears the error when a new recording starts`() async throws { let capturer = CapturingMock() capturer.error = ErrorMock() @@ -506,7 +570,8 @@ struct RecordingViewModelTests { @Suite("Interruptions") struct Interruptions { - @Test func `pauses an ongoing recording when the capture is interrupted`() async throws { + @Test + func `pauses an ongoing recording when the capture is interrupted`() async throws { let capturer = CapturingMock() let model = Model(capturer: capturer) @@ -522,7 +587,8 @@ struct RecordingViewModelTests { #expect(capturer.calls == ["start", "pause"]) } - @Test func `changes nothing when the capture is interrupted while paused`() async throws { + @Test + func `changes nothing when the capture is interrupted while paused`() async throws { let capturer = CapturingMock() let model = Model(capturer: capturer) @@ -539,6 +605,68 @@ struct RecordingViewModelTests { #expect(capturer.calls == ["start", "pause"]) } + @Test + func `resumes an interrupted recording when the interruption ends with the resume hint`() async throws { + let capturer = CapturingMock() + let model = Model(capturer: capturer) + + model.pressedMain() + + try await Task.sleep(for: .seconds(0.1)) + + capturer.interrupt() + + try await Task.sleep(for: .seconds(0.1)) + + capturer.endInterruption(shouldResume: true) + + try await Task.sleep(for: .seconds(0.1)) + + #expect(model.state == .recording) + #expect(capturer.calls == ["start", "pause", "resume"]) + + model.pressedMain() + } + + @Test + func `stays paused when the interruption ends without the resume hint`() async throws { + let capturer = CapturingMock() + let model = Model(capturer: capturer) + + model.pressedMain() + + try await Task.sleep(for: .seconds(0.1)) + + capturer.interrupt() + + try await Task.sleep(for: .seconds(0.1)) + + capturer.endInterruption(shouldResume: false) + + try await Task.sleep(for: .seconds(0.1)) + + #expect(model.state == .paused) + #expect(capturer.calls == ["start", "pause"]) + } + + @Test + func `never resumes a recording the user paused`() async throws { + let capturer = CapturingMock() + let model = Model(capturer: capturer) + + model.pressedMain() + model.pressedMain() + + try await Task.sleep(for: .seconds(0.1)) + + capturer.endInterruption(shouldResume: true) + + try await Task.sleep(for: .seconds(0.1)) + + #expect(model.state == .paused) + #expect(capturer.calls == ["start", "pause"]) + } + } // MARK: Processing @@ -547,7 +675,8 @@ struct RecordingViewModelTests { @Suite("Processing") struct Processing { - @Test func `returns to not recording with a reset timer and a transcription`() async throws { + @Test + func `returns to not recording with a reset timer and a transcription`() async throws { let model = Model( transcribe: TranscribingMock() ) @@ -569,7 +698,8 @@ struct RecordingViewModelTests { #expect(model.transcription != nil) } - @Test func `clears the transcription when the transcriber fails`() async throws { + @Test + func `clears the transcription when the transcriber fails`() async throws { let transcriber = TranscribingMock() transcriber.error = ErrorMock() @@ -587,7 +717,34 @@ struct RecordingViewModelTests { #expect(model.transcription == nil) } - @Test func `clears the transcription when a new recording starts`() async throws { + @Test + func `discarding while processing cancels the transcription`() async throws { + let capturer = CapturingMock() + let transcriber = TranscribingMock() + + transcriber.delay = .seconds(0.5) + + let model = Model( + capturer: capturer, + transcribe: transcriber + ) + + model.drive(to: .processing) + + try await Task.sleep(for: .seconds(0.2)) + + model.pressedDiscard() + + try await Task.sleep(for: .seconds(0.6)) + + #expect(model.state == .notRecording) + #expect(model.elapsedSeconds == 0) + #expect(model.error == nil) + #expect(model.transcription == nil) + } + + @Test + func `clears the transcription when a new recording starts`() async throws { let model = Model( transcribe: TranscribingMock() )