Files
firebase-ios-sdk/FirebaseMLModelDownloader/Sources/ModelDownloadTask.swift
T

427 lines
19 KiB
Swift
Raw Normal View History

// 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
#if SWIFT_PACKAGE
@_implementationOnly import GoogleUtilities_UserDefaults
#else
@_implementationOnly import GoogleUtilities
#endif // SWIFT_PACKAGE
2021-02-10 09:47:07 -08:00
/// Task to download model file to device.
class ModelDownloadTask {
/// Name of the app associated with this instance of ModelDownloadTask.
private let appName: String
2021-02-10 09:47:07 -08:00
/// Model info downloaded from server.
private(set) var remoteModelInfo: RemoteModelInfo
2021-02-10 09:47:07 -08:00
/// User defaults to which local model info should ultimately be written.
private let defaults: GULUserDefaults
2021-02-10 09:47:07 -08:00
/// Keeps track of download associated with this model download task.
2021-02-03 15:44:40 -08:00
private(set) var downloadStatus: ModelDownloadStatus = .ready
2021-02-10 09:47:07 -08:00
2021-01-22 18:28:17 -08:00
/// Downloader instance.
private let downloader: FileDownloader
2021-02-10 09:47:07 -08:00
2021-01-14 15:29:04 -08:00
/// Telemetry logger.
private let telemetryLogger: TelemetryLogger?
2021-02-10 09:47:07 -08:00
/// Download progress handler.
typealias ProgressHandler = (Float) -> Void
2021-02-03 15:44:40 -08:00
private var progressHandler: ProgressHandler?
2021-02-10 09:47:07 -08:00
/// Download completion handler.
typealias Completion = (Result<CustomModel, DownloadError>) -> Void
2021-02-03 15:44:40 -08:00
private var completion: Completion
2021-01-22 18:28:17 -08:00
init(remoteModelInfo: RemoteModelInfo,
appName: String,
defaults: GULUserDefaults,
2021-01-22 18:28:17 -08:00
downloader: FileDownloader,
2021-02-03 15:44:40 -08:00
progressHandler: ProgressHandler? = nil,
completion: @escaping Completion,
telemetryLogger: TelemetryLogger? = nil) {
self.remoteModelInfo = remoteModelInfo
self.appName = appName
2021-02-10 09:47:07 -08:00
self.defaults = defaults
2021-01-22 18:28:17 -08:00
self.downloader = downloader
2021-02-03 15:44:40 -08:00
self.progressHandler = progressHandler
self.completion = completion
2021-01-14 15:29:04 -08:00
self.telemetryLogger = telemetryLogger
}
}
extension ModelDownloadTask {
2021-02-10 09:47:07 -08:00
/// Check if requests can be merged.
2021-02-03 15:44:40 -08:00
func canMergeRequests() -> Bool {
return downloadStatus != .complete
}
/// Merge duplicate requests. This method is not thread-safe.
func merge(newProgressHandler: ProgressHandler? = nil, newCompletion: @escaping Completion) {
let originalProgressHandler = progressHandler
progressHandler = { progress in
originalProgressHandler?(progress)
newProgressHandler?(progress)
}
let originalCompletion = completion
completion = { result in
originalCompletion(result)
newCompletion(result)
}
}
2021-02-10 09:47:07 -08:00
/// Check if download task can be resumed.
func canResume() -> Bool {
return downloadStatus == .ready
}
/// Download model file.
2021-02-03 15:44:40 -08:00
func resume() {
2021-02-10 09:47:07 -08:00
// Prevent multiple concurrent downloads.
2021-02-03 15:44:40 -08:00
guard downloadStatus != .downloading else {
2021-01-27 13:22:05 -08:00
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.ErrorDescription.anotherDownloadInProgress,
messageCode: .anotherDownloadInProgressError)
2021-01-29 12:57:38 -08:00
telemetryLogger?.logModelDownloadEvent(eventName: .modelDownload,
status: .failed,
model: CustomModel(name: remoteModelInfo.name,
size: remoteModelInfo.size,
path: "",
hash: remoteModelInfo.modelHash),
2021-01-29 12:57:38 -08:00
downloadErrorCode: .downloadFailed)
2021-01-27 13:22:05 -08:00
return
}
2021-02-03 15:44:40 -08:00
downloadStatus = .downloading
2021-01-29 12:57:38 -08:00
telemetryLogger?.logModelDownloadEvent(eventName: .modelDownload,
status: .downloading,
model: CustomModel(name: remoteModelInfo.name,
size: remoteModelInfo.size,
path: "",
hash: remoteModelInfo.modelHash),
2021-01-29 12:57:38 -08:00
downloadErrorCode: .noError)
2021-01-22 18:28:17 -08:00
downloader.downloadFile(with: remoteModelInfo.downloadURL,
progressHandler: { downloadedBytes, totalBytes in
/// Fraction of model file downloaded.
let calculatedProgress = Float(downloadedBytes) / Float(totalBytes)
2021-02-03 15:44:40 -08:00
self.progressHandler?(calculatedProgress)
2021-01-22 18:28:17 -08:00
}) { result in
2021-02-01 13:48:42 -08:00
self.downloadStatus = .complete
2021-01-22 18:28:17 -08:00
switch result {
case let .success(response):
2021-01-27 13:22:05 -08:00
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.DebugDescription
.receivedServerResponse,
2021-01-29 12:57:38 -08:00
messageCode: .validHTTPResponse)
self.handleResponse(
response: response.urlResponse,
tempURL: response.fileURL,
2021-02-03 15:44:40 -08:00
completion: self.completion
)
2021-01-22 18:28:17 -08:00
case let .failure(error):
var downloadError: DownloadError
switch error {
case let FileDownloaderError.networkError(error):
2021-01-27 13:22:05 -08:00
let description = ModelDownloadTask.ErrorDescription
.invalidHostName(error.localizedDescription)
2021-02-10 09:47:07 -08:00
downloadError = .failedPrecondition
2021-01-27 13:22:05 -08:00
DeviceLogger.logEvent(level: .debug,
message: description,
messageCode: .hostnameError)
2021-01-29 12:57:38 -08:00
self.telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
status: .failed,
model: CustomModel(name: self.remoteModelInfo.name,
size: self.remoteModelInfo.size,
path: "",
hash: self.remoteModelInfo.modelHash),
2021-01-29 12:57:38 -08:00
downloadErrorCode: .noConnection
)
2021-01-22 18:28:17 -08:00
case FileDownloaderError.unexpectedResponseType:
2021-01-29 12:57:38 -08:00
let description = ModelDownloadTask.ErrorDescription.invalidHTTPResponse
2021-01-27 13:22:05 -08:00
downloadError = .internalError(description: description)
DeviceLogger.logEvent(level: .debug,
message: description,
2021-01-29 12:57:38 -08:00
messageCode: .invalidHTTPResponse)
self.telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
status: .failed,
model: CustomModel(name: self.remoteModelInfo.name,
size: self.remoteModelInfo.size,
path: "",
hash: self.remoteModelInfo.modelHash),
2021-01-29 12:57:38 -08:00
downloadErrorCode: .downloadFailed
)
2021-01-22 18:28:17 -08:00
default:
2021-01-27 13:22:05 -08:00
let description = ModelDownloadTask.ErrorDescription.unknownDownloadError
downloadError = .internalError(description: description)
DeviceLogger.logEvent(level: .debug,
message: description,
messageCode: .modelDownloadError)
2021-01-29 12:57:38 -08:00
self.telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
status: .failed,
model: CustomModel(name: self.remoteModelInfo.name,
size: self.remoteModelInfo.size,
path: "",
hash: self.remoteModelInfo.modelHash),
2021-01-29 12:57:38 -08:00
downloadErrorCode: .downloadFailed
)
2021-01-22 18:28:17 -08:00
}
2021-02-03 15:44:40 -08:00
self.completion(.failure(downloadError))
2021-01-22 18:28:17 -08:00
}
}
}
2021-01-22 18:28:17 -08:00
/// Handle model download response.
func handleResponse(response: HTTPURLResponse, tempURL: URL, completion: @escaping Completion) {
guard (200 ..< 299).contains(response.statusCode) else {
switch response.statusCode {
case 400:
2021-02-10 09:47:07 -08:00
// Possible failure due to download URL expiry. Check if download URL has expired.
guard remoteModelInfo.urlExpiryTime < Date() else {
2021-01-29 12:57:38 -08:00
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.ErrorDescription
2021-02-10 09:47:07 -08:00
.invalidArgument(remoteModelInfo.name),
messageCode: .invalidArgument)
2021-01-29 12:57:38 -08:00
telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
status: .failed,
model: CustomModel(name: remoteModelInfo.name,
size: remoteModelInfo.size,
path: "",
hash: remoteModelInfo.modelHash),
2021-01-29 12:57:38 -08:00
downloadErrorCode: .httpError(code: response.statusCode)
)
completion(.failure(.invalidArgument))
return
}
2021-01-29 12:57:38 -08:00
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.ErrorDescription.expiredModelInfo,
messageCode: .expiredModelInfo)
telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
status: .failed,
model: CustomModel(name: remoteModelInfo.name,
size: remoteModelInfo.size,
path: "",
hash: remoteModelInfo.modelHash),
2021-01-29 12:57:38 -08:00
downloadErrorCode: .urlExpired
)
2021-01-27 13:22:05 -08:00
completion(.failure(.expiredDownloadURL))
2021-01-29 12:57:38 -08:00
case 401, 403:
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.ErrorDescription.permissionDenied,
messageCode: .permissionDenied)
telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
status: .failed,
model: CustomModel(name: remoteModelInfo.name,
size: remoteModelInfo.size,
path: "",
hash: remoteModelInfo.modelHash),
2021-01-29 12:57:38 -08:00
downloadErrorCode: .httpError(code: response.statusCode)
)
completion(.failure(.permissionDenied))
case 404:
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.ErrorDescription
.modelNotFound(remoteModelInfo.name),
messageCode: .modelNotFound)
telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
status: .failed,
model: CustomModel(name: remoteModelInfo.name,
size: remoteModelInfo.size,
path: "",
hash: remoteModelInfo.modelHash),
2021-01-29 12:57:38 -08:00
downloadErrorCode: .httpError(code: response.statusCode)
)
completion(.failure(.notFound))
default:
let description = ModelDownloadTask.ErrorDescription
.modelDownloadFailed(response.statusCode)
2021-01-29 12:57:38 -08:00
DeviceLogger.logEvent(level: .debug,
message: description,
messageCode: .modelDownloadError)
telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
status: .failed,
model: CustomModel(name: remoteModelInfo.name,
size: remoteModelInfo.size,
path: "",
hash: remoteModelInfo.modelHash),
2021-01-29 12:57:38 -08:00
downloadErrorCode: .httpError(code: response.statusCode)
)
completion(.failure(.internalError(description: description)))
}
return
}
2021-02-10 09:47:07 -08:00
/// Construct local model file URL.
guard var modelFileURL = ModelFileManager.getDownloadedModelFileURL(
appName: appName,
modelName: remoteModelInfo.name
2021-02-10 09:47:07 -08:00
) else {
// Could not create Application Support directory to store model files.
let description = ModelDownloadTask.ErrorDescription.noModelsDirectory
DeviceLogger.logEvent(level: .debug,
message: description,
messageCode: .downloadedModelSaveError)
// Downloading the file succeeding but saving failed.
telemetryLogger?.logModelDownloadEvent(eventName: .modelDownload,
status: .succeeded,
model: CustomModel(name: remoteModelInfo.name,
size: remoteModelInfo.size,
path: "",
hash: remoteModelInfo.modelHash),
2021-02-10 09:47:07 -08:00
downloadErrorCode: .downloadFailed)
completion(.failure(.internalError(description: description)))
return
}
2021-01-22 18:28:17 -08:00
do {
2021-02-10 09:47:07 -08:00
// Try disabling iCloud backup for model files, because UserDefaults is not backed up.
var resourceValue = URLResourceValues()
resourceValue.isExcludedFromBackup = true
do {
try modelFileURL.setResourceValues(resourceValue)
} catch {
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.ErrorDescription.disableBackupError,
messageCode: .disableBackupError)
}
// Save model file to device.
try ModelFileManager.moveFile(
at: tempURL,
to: modelFileURL,
size: Int64(remoteModelInfo.size)
)
2021-01-27 13:22:05 -08:00
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.DebugDescription.savedModelFile,
messageCode: .downloadedModelFileSaved)
2021-02-10 09:47:07 -08:00
// Generate local model info.
let localModelInfo = LocalModelInfo(from: remoteModelInfo)
// Write model info to user defaults.
2021-01-22 18:28:17 -08:00
localModelInfo.writeToDefaults(defaults, appName: appName)
2021-01-27 13:22:05 -08:00
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.DebugDescription.savedLocalModelInfo,
messageCode: .downloadedModelInfoSaved)
2021-02-10 09:47:07 -08:00
// Build model from model info and local path.
let model = CustomModel(localModelInfo: localModelInfo, path: modelFileURL.path)
2021-01-29 12:57:38 -08:00
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.DebugDescription.modelDownloaded,
messageCode: .modelDownloaded)
2021-01-22 18:28:17 -08:00
telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
2021-01-29 12:57:38 -08:00
status: .succeeded,
model: model,
downloadErrorCode: .noError
2021-01-22 18:28:17 -08:00
)
completion(.success(model))
} catch let error as DownloadError {
if error == .notEnoughSpace {
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.ErrorDescription.notEnoughSpace,
messageCode: .notEnoughSpace)
} else {
DeviceLogger.logEvent(level: .debug,
2021-02-10 09:47:07 -08:00
message: ModelDownloadTask.ErrorDescription.modelSaveError,
messageCode: .downloadedModelSaveError)
}
2021-02-10 09:47:07 -08:00
// Downloading the file succeeding but saving failed.
2021-01-29 12:57:38 -08:00
telemetryLogger?.logModelDownloadEvent(eventName: .modelDownload,
status: .succeeded,
model: CustomModel(name: remoteModelInfo.name,
size: remoteModelInfo.size,
path: "",
hash: remoteModelInfo.modelHash),
2021-01-29 12:57:38 -08:00
downloadErrorCode: .downloadFailed)
completion(.failure(error))
2021-01-22 18:28:17 -08:00
return
} catch {
2021-01-25 17:15:15 -08:00
DeviceLogger.logEvent(level: .debug,
2021-02-10 09:47:07 -08:00
message: ModelDownloadTask.ErrorDescription.modelSaveError,
2021-01-25 17:15:15 -08:00
messageCode: .downloadedModelSaveError)
2021-02-10 09:47:07 -08:00
// Downloading the file succeeding but saving failed.
2021-01-29 12:57:38 -08:00
telemetryLogger?.logModelDownloadEvent(eventName: .modelDownload,
status: .succeeded,
model: CustomModel(name: remoteModelInfo.name,
size: remoteModelInfo.size,
path: "",
hash: remoteModelInfo.modelHash),
2021-01-29 12:57:38 -08:00
downloadErrorCode: .downloadFailed)
completion(.failure(.internalError(description: error.localizedDescription)))
2021-01-22 18:28:17 -08:00
return
}
}
}
2021-02-10 09:47:07 -08:00
/// Possible states of model downloading.
enum ModelDownloadStatus {
case ready
case downloading
case complete
}
/// Download error codes.
enum ModelDownloadErrorCode {
case noError
case urlExpired
case noConnection
case downloadFailed
case httpError(code: Int)
}
/// Possible debug and error messages for model downloading.
extension ModelDownloadTask {
2021-01-25 17:15:15 -08:00
/// Debug descriptions.
private enum DebugDescription {
2021-02-10 09:47:07 -08:00
static let receivedServerResponse = "Received a valid response from download server."
2021-01-27 13:22:05 -08:00
static let savedModelFile = "Model file saved successfully to device."
static let savedLocalModelInfo = "Downloaded model info saved successfully to user defaults."
2021-01-29 12:57:38 -08:00
static let modelDownloaded = "Model download completed successfully."
2021-01-25 17:15:15 -08:00
}
/// Error descriptions.
private enum ErrorDescription {
2021-01-22 18:28:17 -08:00
static let invalidHostName = { (error: String) in
"Unable to resolve hostname or connect to host: \(error)"
}
2021-02-10 09:47:07 -08:00
static let sessionInvalidated = "Session invalidated due to failed pre-conditions."
static let invalidHTTPResponse = "Could not get valid HTTP response for model downloading."
static let anotherDownloadInProgress = "Download already in progress."
2021-01-29 12:57:38 -08:00
static let modelNotFound = { (name: String) in
"No model found with name: \(name)"
}
2021-02-10 09:47:07 -08:00
static let invalidArgument = { (name: String) in
"Invalid argument for model name: \(name)"
2021-01-29 12:57:38 -08:00
}
static let expiredModelInfo = "Unable to update expired model info."
2021-01-29 12:57:38 -08:00
static let permissionDenied = "Invalid or missing permissions to download model."
2021-02-10 09:47:07 -08:00
static let notEnoughSpace = "Not enough space on device."
static let disableBackupError = "Unable to disable model file backup."
static let noModelsDirectory = "Could not create directory for model storage."
static let modelSaveError = "Unable to save downloaded remote model file."
static let unknownDownloadError = "Unable to download model due to unknown error."
static let modelDownloadFailed = { (code: Int) in
"Model download failed with HTTP error code: \(code)"
}
}
}