Files
firebase-ios-sdk/FirebaseMLModelDownloader/Sources/ModelDownloadTask.swift
T
manjanac 2c4c19be42 Logging on-device + Firelog (#7386)
* Restores ML Pods after M77.

* Fix Package.swift

* Re-add catalyst to GHA workflow.

* Add new error type for expired url + fix tests.

* On-device logging + updates to tests.

* Add new error type for expired url + fix tests.

* On-device logging + updates to tests.

* Revert to enum for ModelFileManager.swift

* Remove GoogleService-Info.plist

* Remove GoogleService-Info.plist
2021-01-27 13:22:05 -08:00

216 lines
9.0 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
/// Possible states of model downloading.
enum ModelDownloadStatus {
case notStarted
case inProgress
case successful
case failed
}
/// Manager to handle model downloading device and storing downloaded model info to persistent storage.
class ModelDownloadTask: NSObject {
typealias ProgressHandler = (Float) -> Void
typealias Completion = (Result<CustomModel, DownloadError>) -> Void
/// Name of the app associated with this instance of ModelDownloadTask.
private let appName: String
/// Model info downloaded from server.
private(set) var remoteModelInfo: RemoteModelInfo
/// User defaults to which local model info should ultimately be written.
private let defaults: UserDefaults
/// Keeps track of download associated with this model download task.
private(set) var downloadStatus: ModelDownloadStatus
/// Downloader instance.
private let downloader: FileDownloader
/// Telemetry logger.
private let telemetryLogger: TelemetryLogger?
init(remoteModelInfo: RemoteModelInfo,
appName: String,
defaults: UserDefaults,
downloader: FileDownloader,
telemetryLogger: TelemetryLogger? = nil) {
self.remoteModelInfo = remoteModelInfo
self.appName = appName
self.downloader = downloader
self.telemetryLogger = telemetryLogger
self.defaults = defaults
downloadStatus = .notStarted
}
}
extension ModelDownloadTask {
/// Name for model file stored on device.
var downloadedModelFileName: String {
return "fbml_model__\(appName)__\(remoteModelInfo.name)"
}
func download(progressHandler: ProgressHandler?, completion: @escaping Completion) {
/// Prevent multiple concurrent downloads.
guard downloadStatus == .notStarted else {
completion(.failure(.internalError(description: ModelDownloadTask.ErrorDescription
.anotherDownloadInProgress)))
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.ErrorDescription.anotherDownloadInProgress,
messageCode: .anotherDownloadInProgressError)
return
}
downloader.downloadFile(with: remoteModelInfo.downloadURL,
progressHandler: { downloadedBytes, totalBytes in
/// Fraction of model file downloaded.
let calculatedProgress = Float(downloadedBytes) / Float(totalBytes)
progressHandler?(calculatedProgress)
}) { result in
switch result {
case let .success(response):
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.DebugDescription
.receivedServerResponse,
messageCode: .validModelDownloadResponse)
self.handleResponse(
response: response.urlResponse,
tempURL: response.fileURL,
completion: completion
)
case let .failure(error):
var downloadError: DownloadError
switch error {
case let FileDownloaderError.networkError(error):
let description = ModelDownloadTask.ErrorDescription
.invalidHostName(error.localizedDescription)
downloadError = .internalError(description: description)
DeviceLogger.logEvent(level: .debug,
message: description,
messageCode: .hostnameError)
// TODO: Handle this case better.
case FileDownloaderError.sessionInvalidated:
downloadError = .failedPrecondition
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.ErrorDescription.sessionInvalidated,
messageCode: .invalidDownloadSessionError)
case FileDownloaderError.unexpectedResponseType:
let description = ModelDownloadTask.ErrorDescription.invalidServerResponse
downloadError = .internalError(description: description)
DeviceLogger.logEvent(level: .debug,
message: description,
messageCode: .invalidResponseError)
default:
let description = ModelDownloadTask.ErrorDescription.unknownDownloadError
downloadError = .internalError(description: description)
DeviceLogger.logEvent(level: .debug,
message: description,
messageCode: .modelDownloadError)
}
completion(.failure(downloadError))
}
}
}
/// Handle model download response.
func handleResponse(response: HTTPURLResponse, tempURL: URL, completion: @escaping Completion) {
guard (200 ..< 299).contains(response.statusCode) else {
/// Possible failure due to download URL expiry.
if response.statusCode == 400 {
completion(.failure(.expiredDownloadURL))
return
}
let description = ModelDownloadTask.ErrorDescription.modelDownloadFailed(response.statusCode)
DeviceLogger.logEvent(level: .debug,
message: description,
messageCode: .modelDownloadError)
completion(.failure(.internalError(description: description)))
return
}
let modelFileURL = ModelFileManager.getDownloadedModelFilePath(
appName: appName,
modelName: remoteModelInfo.name
)
do {
try ModelFileManager.moveFile(at: tempURL, to: modelFileURL)
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.DebugDescription.savedModelFile,
messageCode: .downloadedModelFileSaved)
/// Generate local model info.
let localModelInfo = LocalModelInfo(from: remoteModelInfo, path: modelFileURL.absoluteString)
/// Write model to user defaults.
localModelInfo.writeToDefaults(defaults, appName: appName)
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.DebugDescription.savedLocalModelInfo,
messageCode: .downloadedModelInfoSaved)
/// Build model from model info.
let model = CustomModel(localModelInfo: localModelInfo)
downloadStatus = .successful
telemetryLogger?.logModelDownloadEvent(
eventName: .modelDownload,
status: downloadStatus,
model: model
)
completion(.success(model))
} catch let error as DownloadError {
downloadStatus = .failed
telemetryLogger?.logModelDownloadEvent(eventName: .modelDownload, status: downloadStatus)
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.ErrorDescription.saveModel,
messageCode: .downloadedModelSaveError)
completion(.failure(error))
return
} catch {
downloadStatus = .failed
telemetryLogger?.logModelDownloadEvent(eventName: .modelDownload, status: downloadStatus)
DeviceLogger.logEvent(level: .debug,
message: ModelDownloadTask.ErrorDescription.saveModel,
messageCode: .downloadedModelSaveError)
completion(.failure(.internalError(description: error.localizedDescription)))
return
}
}
}
/// Possible error messages for model downloading.
extension ModelDownloadTask {
/// Debug descriptions.
private enum DebugDescription {
static let savedModelFile = "Model file saved successfully to device."
static let savedLocalModelInfo = "Downloaded model info saved successfully to user defaults."
static let receivedServerResponse = "Received a valid response from server."
}
/// Error descriptions.
private enum ErrorDescription {
static let invalidHostName = { (error: String) in
"Unable to resolve hostname or connect to host: \(error)"
}
static let modelDownloadFailed = { (code: Int) in
"Model download failed with HTTP error code: \(code)"
}
static let sessionInvalidated = "Session invalidated due to failed pre-conditions."
static let invalidServerResponse =
"Could not get valid server response for model downloading."
static let unknownDownloadError = "Unable to download model due to unknown error."
static let saveModel = "Unable to save downloaded remote model file."
static let expiredModelInfo = "Unable to update expired model info."
static let anotherDownloadInProgress = "Download already in progress."
}
}