diff --git a/Packages/Features/Sources/Recording/Protocols/Capturing.swift b/Packages/Features/Sources/Recording/Protocols/Capturing.swift index 48b29ed..e238875 100644 --- a/Packages/Features/Sources/Recording/Protocols/Capturing.swift +++ b/Packages/Features/Sources/Recording/Protocols/Capturing.swift @@ -6,6 +6,13 @@ import Foundation /// state machine — for example, with a real microphone backend in the app, or with a mock in unit tests. public protocol Capturing: Sendable { + // MARK: Properties + + /// The stream of events the service emits outside its method calls. + /// + /// Defaults to an empty stream that finishes immediately, for services that emit no events. + var events: AsyncStream { get } + // MARK: Methods /// Starts a new audio recording from the microphone. @@ -23,3 +30,24 @@ public protocol Capturing: Sendable { func stop() async throws -> URL } + +// MARK: - Implementations + +public extension Capturing { + + /// The empty stream of events, which finishes immediately, for services that emit no events. + var events: AsyncStream { + AsyncStream { continuation in + continuation.finish() + } + } + +} + +// MARK: - Events + +/// An event emitted by a ``Capturing`` service outside its method calls. +public enum CapturingEvent: Sendable { + /// The system interrupted the ongoing capture, pausing it. + case interrupted +} diff --git a/Packages/Features/Sources/Recording/Services/AudioCapturing.swift b/Packages/Features/Sources/Recording/Services/AudioCapturing.swift index 399d8aa..21bdba8 100644 --- a/Packages/Features/Sources/Recording/Services/AudioCapturing.swift +++ b/Packages/Features/Sources/Recording/Services/AudioCapturing.swift @@ -4,24 +4,66 @@ 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. +/// 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``. @MainActor public final class AudioCapturing: Capturing { // MARK: Properties + /// The stream of events the service emits outside its method calls. + public let events: AsyncStream + + /// The continuation that feeds ``events``. + private let continuation: AsyncStream.Continuation + /// The recorder that captures the audio from the microphone into a temporary file. private var recorder: AVAudioRecorder? + #if os(iOS) || os(visionOS) + /// The task that observes the interruptions of the audio session while the service lives. + private var taskInterruptions: Task? + #endif + // MARK: Initializers /// Creates an audio recording service. - public init() {} + public init() { + (events, continuation) = AsyncStream.makeStream(of: CapturingEvent.self) + + #if os(iOS) || os(visionOS) + taskInterruptions = Task { [weak self] in + let notifications = NotificationCenter.default.notifications( + named: AVAudioSession.interruptionNotification, + object: AVAudioSession.sharedInstance() + ) + + for await notification in notifications { + guard let self else { + return + } + + self.handleInterruption(notification) + } + } + #endif + } + + deinit { + #if os(iOS) || os(visionOS) + taskInterruptions?.cancel() + #endif + + continuation.finish() + } // MARK: Methods /// Starts a new audio recording from the microphone. /// + /// On the platforms that require an audio session, the session is deactivated again when the recorder fails to start after + /// the session was activated. + /// /// - Throws: ``AudioCapturingError/permissionNotGranted`` when the user denies the app access to the microphone, /// ``AudioCapturingError/captureNotStarted`` when the recorder fails to start, or any error thrown while configuring /// the audio session or creating the recorder. @@ -37,16 +79,24 @@ public final class AudioCapturing: Capturing { try session.setActive(true) #endif - let recorder = try AVAudioRecorder( - url: Constant.File.url, - settings: Constant.Audio.settings - ) + do { + let recorder = try AVAudioRecorder( + url: Constant.File.url, + settings: Constant.Audio.settings + ) - guard recorder.record() else { - throw AudioCapturingError.captureNotStarted + guard recorder.record() else { + throw AudioCapturingError.captureNotStarted + } + + self.recorder = recorder + } catch { + #if os(iOS) || os(visionOS) + try? AVAudioSession.sharedInstance().setActive(false) + #endif + + throw error } - - self.recorder = recorder } /// Pauses the ongoing recording. @@ -95,6 +145,33 @@ public final class AudioCapturing: Capturing { } +#if os(iOS) || os(visionOS) +// MARK: - Helpers + +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 + } + + continuation.yield(.interrupted) + } + +} +#endif + // MARK: - Errors /// The errors thrown by ``AudioCapturing``. diff --git a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift index 26efe84..bdfc0bf 100644 --- a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift +++ b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift @@ -47,6 +47,10 @@ extension RecordingView { @ObservationIgnored private var taskCapturer: Task? + /// The task that listens to the events emitted by the capturing service, or `nil` until the first recording starts. + @ObservationIgnored + private var taskEvents: Task? + /// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording. @ObservationIgnored private var taskTimer: Task? @@ -130,16 +134,11 @@ extension RecordingView { case .notRecording: state = .recording + listenToEvents() startTimer(true) startCapturer(true) case .recording: - state = .paused - - stopTimer() - - enqueueCapturer { - try? await self.capturer.pause() - } + pauseRecording() case .paused: state = .recording @@ -224,6 +223,40 @@ 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. + func listenToEvents() { + guard taskEvents == nil else { + return + } + + taskEvents = Task { [weak self, events = capturer.events] in + for await event in events { + guard let self else { + return + } + + switch event { + case .interrupted: + if state == .recording { + pauseRecording() + } + } + } + } + } + + /// Pauses the ongoing recording: it stops the timer and pauses the audio capture. + func pauseRecording() { + state = .paused + + stopTimer() + + enqueueCapturer { + try? await self.capturer.pause() + } + } + /// Starts the audio capture through the attached ``Capturing``, falling back to the not-recording state when the service fails. /// /// - Parameter isNewRecording: Whether a new recording should be started, as opposed to a paused one being resumed. diff --git a/Packages/Features/Tests/Recording/Mocks/CapturingMock.swift b/Packages/Features/Tests/Recording/Mocks/CapturingMock.swift index feec7d7..4c640ea 100644 --- a/Packages/Features/Tests/Recording/Mocks/CapturingMock.swift +++ b/Packages/Features/Tests/Recording/Mocks/CapturingMock.swift @@ -6,6 +6,9 @@ import Recording @MainActor final class CapturingMock: Capturing { + /// The stream of events the service emits outside its method calls. + let events: AsyncStream + /// The duration every method takes before returning, simulating slow capture work. var delay: Duration = .zero @@ -15,6 +18,18 @@ final class CapturingMock: Capturing { /// The names of the methods called on the service, in call order. private(set) var calls: [String] = [] + /// The continuation that feeds ``events``. + private let continuation: AsyncStream.Continuation + + init() { + (events, continuation) = AsyncStream.makeStream(of: CapturingEvent.self) + } + + /// Emits an interruption of the ongoing capture through ``events``. + func interrupt() { + continuation.yield(.interrupted) + } + func start() async throws { try await called("start") } diff --git a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift index 53cfb97..011e42e 100644 --- a/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift +++ b/Packages/Features/Tests/Recording/View Models/RecordingViewModelTests.swift @@ -342,6 +342,47 @@ struct RecordingViewModelTests { } + // MARK: Interruptions + + @MainActor + @Suite("Interruptions") + struct Interruptions { + + @Test func `pauses an ongoing recording when the capture is interrupted`() 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)) + + #expect(model.state == .paused) + #expect(capturer.calls == ["start", "pause"]) + } + + @Test func `changes nothing when the capture is interrupted while paused`() async throws { + let capturer = CapturingMock() + let model = Model(capturer: capturer) + + model.pressedMain() + model.pressedMain() + + try await Task.sleep(for: .seconds(0.1)) + + capturer.interrupt() + + try await Task.sleep(for: .seconds(0.1)) + + #expect(model.state == .paused) + #expect(capturer.calls == ["start", "pause"]) + } + + } + // MARK: Processing @MainActor