From d22a8dda13e5e80a2004e0bf8792dcdd2b589669 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 5 Jul 2026 13:34:16 +0200 Subject: [PATCH] Implemented the AssetPreinstalling service in the Recording package target. --- .../Recording/Protocols/Preinstalling.swift | 46 +++++++ .../Services/AssetPreinstalling.swift | 127 ++++++++++++++++++ .../Services/AudioTranscribing.swift | 13 +- .../Models/AppNotificationTests.swift | 88 ++++++++++++ 4 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 Packages/Features/Sources/Recording/Protocols/Preinstalling.swift create mode 100644 Packages/Features/Sources/Recording/Services/AssetPreinstalling.swift create mode 100644 Packages/Features/Tests/Notifying/Models/AppNotificationTests.swift diff --git a/Packages/Features/Sources/Recording/Protocols/Preinstalling.swift b/Packages/Features/Sources/Recording/Protocols/Preinstalling.swift new file mode 100644 index 0000000..ce660ab --- /dev/null +++ b/Packages/Features/Sources/Recording/Protocols/Preinstalling.swift @@ -0,0 +1,46 @@ +import Foundation + +/// A service that preinstalls the speech model assets a transcription needs, for the Recording feature. +/// +/// The service resolves the locales the transcriber supports and downloads the speech model assets of a given locale ahead of its +/// first transcription. The outcomes of a preinstallation are emitted through ``events`` rather than thrown: a host can surface the +/// started, cancelled, and failed downloads however it sees fit, while a download that finishes — or assets that are already +/// installed — emit nothing. +public protocol Preinstalling: Sendable { + + // MARK: Properties + + /// The stream of events the service emits while preinstalling. + var events: AsyncStream { get } + + // MARK: Methods + + /// Preinstalls the speech model assets for the supported equivalent of the given locale. + /// + /// - Parameter locale: The locale to preinstall the speech model assets for. + func preinstall(for locale: Locale) async + + /// Returns the supported locale equivalent to the given locale. + /// + /// - Parameter locale: The locale to find a supported equivalent for. + /// - Returns: The supported equivalent, or `nil` when the transcriber supports none. + func supportedLocale(equivalentTo locale: Locale) async -> Locale? + + /// Returns the locales the transcriber supports. + /// + /// - Returns: The supported locales, including ones whose assets are not installed yet. + func supportedLocales() async -> [Locale] + +} + +// MARK: - Events + +/// An event emitted by a ``Preinstalling`` service while preinstalling speech model assets. +public enum PreinstallingEvent: Sendable { + /// The download of the locale's speech model assets was cancelled before it finished. + case cancelled(Locale) + /// The download of the locale's speech model assets failed. + case failed(Locale) + /// The download of the locale's speech model assets started. + case started(Locale) +} diff --git a/Packages/Features/Sources/Recording/Services/AssetPreinstalling.swift b/Packages/Features/Sources/Recording/Services/AssetPreinstalling.swift new file mode 100644 index 0000000..6e5f303 --- /dev/null +++ b/Packages/Features/Sources/Recording/Services/AssetPreinstalling.swift @@ -0,0 +1,127 @@ +import Foundation +import OSLog +import Speech + +/// The preinstalling service that downloads speech model assets through the system's asset inventory — the sole owner of the +/// asset inventory knowledge in the feature. +/// +/// The service offers two installation paths over the same core. ``preinstall(for:)`` installs the assets for the supported equivalent +/// of a given locale ahead of its first transcription, logging and emitting its outcomes through ``events`` rather than throwing. +/// ``install(for:onDownloadStart:)`` installs the assets for an already supported locale and throws on failure, for callers that +/// need the assets right away — like the transcribing service, which uses it as its fallback when a transcription starts. +/// +/// Either path releases the locale reservations of any other locales beforehand: the feature transcribes a single locale at a time, +/// and the system only permits a limited number of reservations per app — exceeding it would make the installation request throw. +public final class AssetPreinstalling: Preinstalling { + + // MARK: Properties + + /// The stream of events the service emits while preinstalling. + public let events: AsyncStream + + /// The continuation that feeds ``events``. + private let continuation: AsyncStream.Continuation + + // MARK: Initializers + + /// Creates an asset preinstalling service. + public init() { + (events, continuation) = AsyncStream.makeStream(of: PreinstallingEvent.self) + } + + deinit { + continuation.finish() + } + + // MARK: Methods + + /// Installs the speech model assets for the given supported locale, waiting for any download to finish. + /// + /// The locale reservations of any other locales are released beforehand. Unlike ``preinstall(for:)``, failures are thrown rather + /// than emitted, so a caller that needs the assets right away — like the transcribing service — can react to them directly. + /// + /// - Parameters: + /// - locale: The supported locale to install the speech model assets for. + /// - onDownloadStart: The closure invoked when the assets are missing and their download starts. Defaults to `nil`. + /// - Throws: Any error thrown while reserving, downloading, or installing the assets, including a `CancellationError` + /// when the surrounding task is cancelled mid-download. + public func install( + for locale: Locale, + onDownloadStart: (@Sendable () -> Void)? = nil + ) async throws { + for reserved in await AssetInventory.reservedLocales where reserved != locale { + _ = await AssetInventory.release(reservedLocale: reserved) + } + + let transcriber = SpeechTranscriber( + locale: locale, + preset: .transcription + ) + + guard let request = try await AssetInventory.assetInstallationRequest( + supporting: [transcriber] + ) else { + return + } + + onDownloadStart?() + + try await withTaskCancellationHandler { + try await request.downloadAndInstall() + } onCancel: { [progress = request.progress] in + progress.cancel() + } + } + + /// 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. + /// + /// - 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 { + return + } + + do { + try await install(for: locale) { [continuation] in + continuation.yield(.started(locale)) + } + } catch { + if error is CancellationError || Task.isCancelled { + continuation.yield(.cancelled(locale)) + } 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)") + } + } + } + + /// Returns the supported locale equivalent to the given locale. + /// + /// - Parameter locale: The locale to find a supported equivalent for. + /// - Returns: The supported equivalent, or `nil` when the transcriber supports none. + public func supportedLocale( + equivalentTo locale: Locale + ) async -> Locale? { + await SpeechTranscriber.supportedLocale(equivalentTo: locale) + } + + /// Returns the locales the transcriber supports. + /// + /// - Returns: The supported locales, including ones whose assets are not installed yet. + public func supportedLocales() async -> [Locale] { + await SpeechTranscriber.supportedLocales + } + +} + +// MARK: - Constants + +/// The logger that records the failures of the asset preinstalling service. +private let logger = Logger( + subsystem: "Features.Recording", + category: "AssetPreinstalling" +) diff --git a/Packages/Features/Sources/Recording/Services/AudioTranscribing.swift b/Packages/Features/Sources/Recording/Services/AudioTranscribing.swift index ff6d428..ce42239 100644 --- a/Packages/Features/Sources/Recording/Services/AudioTranscribing.swift +++ b/Packages/Features/Sources/Recording/Services/AudioTranscribing.swift @@ -6,10 +6,15 @@ import Speech /// /// The service runs the given recorded audio file through a `SpeechAnalyzer` with a `SpeechTranscriber` module, joining the finalized /// results into the returned transcription. The transcription happens in the locale given at each call — or rather in the closest equivalent -/// the transcriber supports. The speech model assets for that locale are downloaded and installed on first use; every transcription after -/// that happens entirely offline. +/// the transcriber supports. Any speech model assets still missing for that locale are installed first through ``AssetPreinstalling``; +/// every transcription after that happens entirely offline. public struct AudioTranscribing: Transcribing { + // MARK: Properties + + /// The preinstalling service that installs any speech model assets still missing when a transcription starts. + private let preinstaller = AssetPreinstalling() + // MARK: Initializers /// Creates an audio transcribing service. @@ -46,9 +51,7 @@ public struct AudioTranscribing: Transcribing { ) do { - if let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) { - try await request.downloadAndInstall() - } + try await preinstaller.install(for: locale) } catch { logger.error("The speech model assets for the \"\(locale.identifier, privacy: .public)\" locale failed to install: \(String(describing: error), privacy: .public)") diff --git a/Packages/Features/Tests/Notifying/Models/AppNotificationTests.swift b/Packages/Features/Tests/Notifying/Models/AppNotificationTests.swift new file mode 100644 index 0000000..794346e --- /dev/null +++ b/Packages/Features/Tests/Notifying/Models/AppNotificationTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing + +@testable import Notifying + +@Suite("AppNotification model") +struct AppNotificationTests { + + // MARK: Initializers + + @Suite("Initializers") + struct Initializers { + + @Test(arguments: zip( + [AppNotification.Kind.error, .info, .warning], + ["An error occurred.", "Something happened.", "Something needs attention."] + )) + func `initializer stores the given kind, message, and symbol`( + kind: AppNotification.Kind, + message: String + ) { + let notification = AppNotification( + kind: kind, + message: message, + symbol: "bell" + ) + + #expect(notification.kind == kind) + #expect(notification.message == message) + #expect(notification.symbol == "bell") + } + + @Test func `initializer stores the given identifier`() { + let id = UUID() + let notification = AppNotification( + id: id, + kind: .info, + message: "Something happened.", + symbol: "bell" + ) + + #expect(notification.id == id) + } + + } + + // MARK: Equatable + + @Suite("Equatable") + struct EquatableConformance { + + @Test func `notifications with the same identifier and values are equal`() { + let id = UUID() + + let first = AppNotification( + id: id, + kind: .info, + message: "Something happened.", + symbol: "bell" + ) + let second = AppNotification( + id: id, + kind: .info, + message: "Something happened.", + symbol: "bell" + ) + + #expect(first == second) + } + + @Test func `notifications with generated identifiers are never equal`() { + let first = AppNotification( + kind: .info, + message: "Something happened.", + symbol: "bell" + ) + let second = AppNotification( + kind: .info, + message: "Something happened.", + symbol: "bell" + ) + + #expect(first != second) + } + + } + +}