Files
firebase-ios-sdk/FirebaseMLModelDownloader/Sources/ModelDownloader.swift
T
2023-07-11 15:54:51 -07:00

650 lines
27 KiB
Swift

// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
import FirebaseCore
import FirebaseInstallations
/// Possible ways to get a custom model.
public enum ModelDownloadType {
/// Get local model stored on device if available. If no local model on device, this is the same
/// as `latestModel`.
case localModel
/// Get local model on device if available and update to latest model from server in the
/// background. If no local model on device, this is the same as `latestModel`.
case localModelUpdateInBackground
/// Get latest model from server. Does not make a network call for model file download if local
/// model matches the latest version on server.
case latestModel
}
/// Downloader to manage custom model downloads.
public class ModelDownloader {
/// Name of the app associated with this instance of ModelDownloader.
private let appName: String
/// Current Firebase app options.
private let options: FirebaseOptions
/// Installations instance for current Firebase app.
private let installations: Installations
/// User defaults for model info.
private let userDefaults: UserDefaults
/// Telemetry logger tied to this instance of model downloader.
let telemetryLogger: TelemetryLogger?
/// Number of retries in case of model download URL expiry.
var numberOfRetries: Int = 1
/// Shared dictionary mapping app name to a specific instance of model downloader.
// TODO: Switch to using Firebase components.
private static var modelDownloaderDictionary: [String: ModelDownloader] = [:]
/// Download task associated with the model currently being downloaded.
private var currentDownloadTask: [String: ModelDownloadTask] = [:]
/// DispatchQueue to manage download task dictionary.
let taskSerialQueue = DispatchQueue(label: "downloadtask.serial.queue")
/// Re-dispatch a function on the main queue.
func asyncOnMainQueue(_ work: @autoclosure @escaping () -> Void) {
DispatchQueue.main.async {
work()
}
}
/// Private init for model downloader.
private init(app: FirebaseApp, defaults: UserDefaults = .firebaseMLDefaults) {
appName = app.name
options = app.options
installations = Installations.installations(app: app)
userDefaults = defaults
// Respect Firebase-wide data collection setting.
telemetryLogger = TelemetryLogger(app: app)
// Notification of app deletion.
let notificationName = "FIRAppDeleteNotification"
NotificationCenter.default.addObserver(
self,
selector: #selector(deleteModelDownloader),
name: Notification.Name(notificationName),
object: nil
)
}
/// Handles app deletion notification.
@objc private func deleteModelDownloader(notification: Notification) {
let userInfoKey = "FIRAppNameKey"
if let userInfo = notification.userInfo,
let appName = userInfo[userInfoKey] as? String {
ModelDownloader.modelDownloaderDictionary.removeValue(forKey: appName)
// TODO: Clean up user defaults.
// TODO: Clean up local instances of app.
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.DebugDescription.deleteModelDownloader,
messageCode: .downloaderInstanceDeleted)
}
}
/// Model downloader with default app.
public static func modelDownloader() -> ModelDownloader {
guard let defaultApp = FirebaseApp.app() else {
fatalError(ModelDownloader.ErrorDescription.defaultAppNotConfigured)
}
return modelDownloader(app: defaultApp)
}
/// Model Downloader with custom app.
public static func modelDownloader(app: FirebaseApp) -> ModelDownloader {
if let downloader = modelDownloaderDictionary[app.name] {
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.DebugDescription.retrieveModelDownloader,
messageCode: .downloaderInstanceRetrieved)
return downloader
} else {
let downloader = ModelDownloader(app: app)
modelDownloaderDictionary[app.name] = downloader
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.DebugDescription.createModelDownloader,
messageCode: .downloaderInstanceCreated)
return downloader
}
}
/// Downloads a custom model to device or gets a custom model already on device, with an optional
/// handler for progress.
/// - Parameters:
/// - modelName: The name of the model, matching Firebase console.
/// - downloadType: ModelDownloadType used to get the model.
/// - conditions: Conditions needed to perform a model download.
/// - progressHandler: Optional. Returns a float in [0.0, 1.0] that can be used to monitor model
/// download progress.
/// - completion: Returns either a `CustomModel` on success, or a `DownloadError` on failure, at
/// the end of a model download.
public func getModel(name modelName: String,
downloadType: ModelDownloadType,
conditions: ModelDownloadConditions,
progressHandler: ((Float) -> Void)? = nil,
completion: @escaping (Result<CustomModel, DownloadError>) -> Void) {
guard !modelName.isEmpty else {
asyncOnMainQueue(completion(.failure(.emptyModelName)))
return
}
switch downloadType {
case .localModel:
if let localModel = getLocalModel(modelName: modelName) {
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.DebugDescription.localModelFound,
messageCode: .localModelFound)
asyncOnMainQueue(completion(.success(localModel)))
} else {
getRemoteModel(
modelName: modelName,
conditions: conditions,
progressHandler: progressHandler,
completion: completion
)
}
case .localModelUpdateInBackground:
if let localModel = getLocalModel(modelName: modelName) {
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.DebugDescription.localModelFound,
messageCode: .localModelFound)
asyncOnMainQueue(completion(.success(localModel)))
telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
status: .scheduled,
model: CustomModel(name: modelName, size: 0, path: "", hash: ""),
downloadErrorCode: .noError
)
// Update local model in the background.
DispatchQueue.global(qos: .utility).async { [weak self] in
self?.getRemoteModel(
modelName: modelName,
conditions: conditions,
progressHandler: nil,
completion: { result in
switch result {
case let .success(model):
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.DebugDescription
.backgroundModelDownloaded,
messageCode: .backgroundModelDownloaded)
self?.telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
status: .succeeded,
model: model,
downloadErrorCode: .noError
)
case .failure:
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.ErrorDescription
.backgroundModelDownload,
messageCode: .backgroundDownloadError)
self?.telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
status: .failed,
model: CustomModel(name: modelName, size: 0, path: "", hash: ""),
downloadErrorCode: .downloadFailed
)
}
}
)
}
} else {
getRemoteModel(
modelName: modelName,
conditions: conditions,
progressHandler: progressHandler,
completion: completion
)
}
case .latestModel:
getRemoteModel(
modelName: modelName,
conditions: conditions,
progressHandler: progressHandler,
completion: completion
)
}
}
/// Gets the set of all downloaded models saved on device.
/// - Parameter completion: Returns either a set of `CustomModel` models on success, or a
/// `DownloadedModelError` on failure.
public func listDownloadedModels(completion: @escaping (Result<Set<CustomModel>,
DownloadedModelError>) -> Void) {
do {
let modelURLs = try ModelFileManager.contentsOfModelsDirectory()
var customModels = Set<CustomModel>()
// Retrieve model name from URL.
for url in modelURLs {
guard let modelName = ModelFileManager.getModelNameFromFilePath(url) else {
let description = ModelDownloader.ErrorDescription.parseModelName(url.path)
DeviceLogger.logEvent(level: .debug,
message: description,
messageCode: .modelNameParseError)
asyncOnMainQueue(completion(.failure(.internalError(description: description))))
return
}
// Check if model information corresponding to model is stored in UserDefaults.
guard let modelInfo = getLocalModelInfo(modelName: modelName) else {
let description = ModelDownloader.ErrorDescription.noLocalModelInfo(modelName)
DeviceLogger.logEvent(level: .debug,
message: description,
messageCode: .noLocalModelInfo)
asyncOnMainQueue(completion(.failure(.internalError(description: description))))
return
}
// Ensure that local model path is as expected, and reachable.
guard let modelURL = ModelFileManager.getDownloadedModelFileURL(
appName: appName,
modelName: modelName
),
ModelFileManager.isFileReachable(at: modelURL) else {
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.ErrorDescription.outdatedModelPath,
messageCode: .outdatedModelPathError)
asyncOnMainQueue(completion(.failure(.internalError(description: ModelDownloader
.ErrorDescription.outdatedModelPath))))
return
}
let model = CustomModel(localModelInfo: modelInfo, path: modelURL.path)
// Add model to result set.
customModels.insert(model)
}
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.DebugDescription.allLocalModelsFound,
messageCode: .allLocalModelsFound)
completion(.success(customModels))
} catch let error as DownloadedModelError {
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.ErrorDescription.listModelsFailed(error),
messageCode: .listModelsError)
asyncOnMainQueue(completion(.failure(error)))
} catch {
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.ErrorDescription.listModelsFailed(error),
messageCode: .listModelsError)
asyncOnMainQueue(completion(.failure(.internalError(description: error
.localizedDescription))))
}
}
/// Deletes a custom model file from device as well as corresponding model information saved in
/// UserDefaults.
/// - Parameters:
/// - modelName: The name of the model, matching Firebase console and already downloaded to
/// device.
/// - completion: Returns a `DownloadedModelError` on failure.
public func deleteDownloadedModel(name modelName: String,
completion: @escaping (Result<Void, DownloadedModelError>)
-> Void) {
// Ensure that there is a matching model file on device, with corresponding model information in
// UserDefaults.
guard let modelURL = ModelFileManager.getDownloadedModelFileURL(
appName: appName,
modelName: modelName
),
let localModelInfo = getLocalModelInfo(modelName: modelName),
ModelFileManager.isFileReachable(at: modelURL)
else {
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.ErrorDescription.modelNotFound(modelName),
messageCode: .modelNotFound)
asyncOnMainQueue(completion(.failure(.notFound)))
return
}
do {
// Remove model file from device.
try ModelFileManager.removeFile(at: modelURL)
// Clear out corresponding local model info.
localModelInfo.removeFromDefaults(userDefaults, appName: appName)
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.DebugDescription.modelDeleted,
messageCode: .modelDeleted)
telemetryLogger?.logModelDeletedEvent(
eventName: .remoteModelDeleteOnDevice,
isSuccessful: true
)
asyncOnMainQueue(completion(.success(())))
} catch let error as DownloadedModelError {
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.ErrorDescription.modelDeletionFailed(error),
messageCode: .modelDeletionFailed)
telemetryLogger?.logModelDeletedEvent(
eventName: .remoteModelDeleteOnDevice,
isSuccessful: false
)
asyncOnMainQueue(completion(.failure(error)))
} catch {
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.ErrorDescription.modelDeletionFailed(error),
messageCode: .modelDeletionFailed)
telemetryLogger?.logModelDeletedEvent(
eventName: .remoteModelDeleteOnDevice,
isSuccessful: false
)
asyncOnMainQueue(completion(.failure(.internalError(description: error
.localizedDescription))))
}
}
}
extension ModelDownloader {
/// Get model information for model saved on device, if available.
private func getLocalModelInfo(modelName: String) -> LocalModelInfo? {
guard let localModelInfo = LocalModelInfo(
fromDefaults: userDefaults,
name: modelName,
appName: appName
) else {
let description = ModelDownloader.DebugDescription.noLocalModelInfo(modelName)
DeviceLogger.logEvent(level: .debug,
message: description,
messageCode: .noLocalModelInfo)
return nil
}
/// Local model info is only considered valid if there is a corresponding model file on device.
guard let modelURL = ModelFileManager.getDownloadedModelFileURL(
appName: appName,
modelName: modelName
), ModelFileManager.isFileReachable(at: modelURL) else {
let description = ModelDownloader.DebugDescription.noLocalModelFile(modelName)
DeviceLogger.logEvent(level: .debug,
message: description,
messageCode: .noLocalModelFile)
return nil
}
return localModelInfo
}
/// Get model saved on device, if available.
private func getLocalModel(modelName: String) -> CustomModel? {
guard let modelURL = ModelFileManager.getDownloadedModelFileURL(
appName: appName,
modelName: modelName
), let localModelInfo = getLocalModelInfo(modelName: modelName) else { return nil }
let model = CustomModel(localModelInfo: localModelInfo, path: modelURL.path)
return model
}
/// Download and get model from server, unless the latest model is already available on device.
private func getRemoteModel(modelName: String,
conditions: ModelDownloadConditions,
progressHandler: ((Float) -> Void)? = nil,
completion: @escaping (Result<CustomModel, DownloadError>) -> Void) {
let localModelInfo = getLocalModelInfo(modelName: modelName)
guard let projectID = options.projectID, let apiKey = options.apiKey else {
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.ErrorDescription.invalidOptions,
messageCode: .invalidOptions)
completion(.failure(.internalError(description: ModelDownloader.ErrorDescription
.invalidOptions)))
return
}
let modelInfoRetriever = ModelInfoRetriever(
modelName: modelName,
projectID: projectID,
apiKey: apiKey,
appName: appName, installations: installations,
localModelInfo: localModelInfo,
telemetryLogger: telemetryLogger
)
let downloader = ModelFileDownloader(conditions: conditions)
downloadInfoAndModel(
modelName: modelName,
modelInfoRetriever: modelInfoRetriever,
downloader: downloader,
conditions: conditions,
progressHandler: progressHandler,
completion: completion
)
}
/// Get model info and model file from server.
func downloadInfoAndModel(modelName: String,
modelInfoRetriever: ModelInfoRetriever,
downloader: FileDownloader,
conditions: ModelDownloadConditions,
progressHandler: ((Float) -> Void)? = nil,
completion: @escaping (Result<CustomModel, DownloadError>)
-> Void) {
modelInfoRetriever.downloadModelInfo { result in
switch result {
case let .success(downloadModelInfoResult):
switch downloadModelInfoResult {
// New model info was downloaded from server.
case let .modelInfo(remoteModelInfo):
// Progress handler for model file download.
let taskProgressHandler: ModelDownloadTask.ProgressHandler = { progress in
if let progressHandler = progressHandler {
self.asyncOnMainQueue(progressHandler(progress))
}
}
// Completion handler for model file download.
let taskCompletion: ModelDownloadTask.Completion = { result in
switch result {
case let .success(model):
self.asyncOnMainQueue(completion(.success(model)))
case let .failure(error):
switch error {
case .notFound:
self.asyncOnMainQueue(completion(.failure(.notFound)))
case .invalidArgument:
self.asyncOnMainQueue(completion(.failure(.invalidArgument)))
case .permissionDenied:
self.asyncOnMainQueue(completion(.failure(.permissionDenied)))
// This is the error returned when model download URL has expired.
case .expiredDownloadURL:
// Retry model info and model file download, if allowed.
guard self.numberOfRetries > 0 else {
self
.asyncOnMainQueue(
completion(.failure(.internalError(description: ModelDownloader
.ErrorDescription
.expiredModelInfo)))
)
return
}
self.numberOfRetries -= 1
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.DebugDescription.retryDownload,
messageCode: .retryDownload)
self.downloadInfoAndModel(
modelName: modelName,
modelInfoRetriever: modelInfoRetriever,
downloader: downloader,
conditions: conditions,
progressHandler: progressHandler,
completion: completion
)
default:
self.asyncOnMainQueue(completion(.failure(error)))
}
}
self.taskSerialQueue.async {
// Stop keeping track of current download task.
self.currentDownloadTask.removeValue(forKey: modelName)
}
}
self.taskSerialQueue.sync {
// Merge duplicate requests if there is already a download in progress for the same
// model.
if let downloadTask = self.currentDownloadTask[modelName],
downloadTask.canMergeRequests() {
downloadTask.merge(
newProgressHandler: taskProgressHandler,
newCompletion: taskCompletion
)
DeviceLogger.logEvent(level: .debug,
message: ModelDownloader.DebugDescription.mergingRequests,
messageCode: .mergeRequests)
if downloadTask.canResume() {
downloadTask.resume()
}
// TODO: Handle else.
} else {
// Create download task for model file download.
let downloadTask = ModelDownloadTask(
remoteModelInfo: remoteModelInfo,
appName: self.appName,
defaults: self.userDefaults,
downloader: downloader,
progressHandler: taskProgressHandler,
completion: taskCompletion,
telemetryLogger: self.telemetryLogger
)
// Keep track of current download task to allow for merging duplicate requests.
self.currentDownloadTask[modelName] = downloadTask
downloadTask.resume()
}
}
/// Local model info is the latest model info.
case .notModified:
guard let localModel = self.getLocalModel(modelName: modelName) else {
// This can only happen if either local model info or the model file was wiped out after
// model info request but before server response.
self
.asyncOnMainQueue(completion(.failure(.internalError(description: ModelDownloader
.ErrorDescription.deletedLocalModelInfoOrFile))))
return
}
self.asyncOnMainQueue(completion(.success(localModel)))
}
// Error retrieving model info.
case let .failure(error):
self.asyncOnMainQueue(completion(.failure(error)))
}
}
}
}
/// Possible errors with model downloading.
public enum DownloadError: Error, Equatable {
/// No model with this name exists on server.
case notFound
/// Invalid, incomplete, or missing permissions for model download.
case permissionDenied
/// Conditions not met to perform download.
case failedPrecondition
/// Requests quota exhausted.
case resourceExhausted
/// Not enough space for model on device.
case notEnoughSpace
/// Malformed model name or Firebase app options.
case invalidArgument
/// Model name is empty.
case emptyModelName
/// Other errors with description.
case internalError(description: String)
}
/// Possible errors with locating a model file on device.
public enum DownloadedModelError: Error {
/// No model with this name exists on device.
case notFound
/// File system error.
case fileIOError(description: String)
/// Other errors with description.
case internalError(description: String)
}
/// Extension to handle internally meaningful errors.
extension DownloadError {
/// Model download URL expired before model download.
// Model info retrieval and download is retried `numberOfRetries` times before failing.
static let expiredDownloadURL: DownloadError =
.internalError(description: "Expired model download URL.")
}
/// Possible debug and error messages while using model downloader.
extension ModelDownloader {
/// Debug descriptions.
private enum DebugDescription {
static let createModelDownloader =
"Initialized with new downloader instance associated with this app."
static let retrieveModelDownloader =
"Initialized with existing downloader instance associated with this app."
static let deleteModelDownloader = "Model downloader instance deleted due to app deletion."
static let localModelFound = "Found local model on device."
static let allLocalModelsFound = "Found and listed all local models."
static let noLocalModelInfo = { (name: String) in
"No local model info for model named: \(name)."
}
static let noLocalModelFile = { (name: String) in
"No local model file for model named: \(name)."
}
static let backgroundModelDownloaded = "Downloaded latest model in the background."
static let modelDeleted = "Model deleted successfully."
static let mergingRequests = "Merging duplicate download requests."
static let retryDownload = "Retrying download."
}
/// Error descriptions.
private enum ErrorDescription {
static let defaultAppNotConfigured = "Default Firebase app not configured."
static let invalidOptions = "Unable to retrieve project ID and/or API key for Firebase app."
static let modelDownloadFailed = { (error: Error) in
"Model download failed with error: \(error)"
}
static let modelNotFound = { (name: String) in
"Model deletion failed due to no model found with name: \(name)"
}
static let modelInfoRetrievalFailed = { (error: Error) in
"Model info retrieval failed with error: \(error)"
}
static let backgroundModelDownload = "Failed to update model in background."
static let expiredModelInfo = "Unable to update expired model info."
static let listModelsFailed = { (error: Error) in
"Unable to list models, failed with error: \(error)"
}
static let parseModelName = { (path: String) in
"List models failed due to unexpected model file name at \(path)."
}
static let noLocalModelInfo = { (name: String) in
"List models failed due to no local model info for model file named: \(name)."
}
static let deletedLocalModelInfoOrFile =
"Model unavailable due to deleted local model info or model file."
static let outdatedModelPath =
"List models failed due to outdated model paths in local storage."
static let modelDeletionFailed = { (error: Error) in
"Model deletion failed with error: \(error)"
}
}
}
/// Model downloader extension for testing.
extension ModelDownloader {
/// Model downloader instance for testing.
static func modelDownloaderWithDefaults(_ defaults: UserDefaults,
app: FirebaseApp) -> ModelDownloader {
let downloader = ModelDownloader(app: app, defaults: defaults)
return downloader
}
}