// Copyright 2022 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 FirebaseAppCheckInterop import FirebaseAuthInterop import FirebaseCore // Avoids exposing internal FirebaseCore APIs to Swift users. internal import FirebaseCoreExtension /// Firebase Storage is a service that supports uploading and downloading binary objects, /// such as images, videos, and other files to Google Cloud Storage. Instances of `Storage` /// are not thread-safe, but can be accessed from any thread. /// /// If you call `Storage.storage()`, the instance will initialize with the default `FirebaseApp`, /// `FirebaseApp.app()`, and the storage location will come from the provided /// `GoogleService-Info.plist`. /// /// If you provide a custom instance of `FirebaseApp`, /// the storage location will be specified via the `FirebaseOptions.storageBucket` property. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @objc(FIRStorage) open class Storage: NSObject { // MARK: - Public APIs /// The default `Storage` instance. /// - Returns: An instance of `Storage`, configured with the default `FirebaseApp`. @objc(storage) open class func storage() -> Storage { return storage(app: FirebaseApp.app()!) } /// A method used to create `Storage` instances initialized with a custom storage bucket URL. /// /// Any `StorageReferences` generated from this instance of `Storage` will reference files /// and directories within the specified bucket. /// - Parameter url: The `gs://` URL to your Firebase Storage bucket. /// - Returns: A `Storage` instance, configured with the custom storage bucket. @objc(storageWithURL:) open class func storage(url: String) -> Storage { return storage(app: FirebaseApp.app()!, url: url) } /// Creates an instance of `Storage`, configured with a custom `FirebaseApp`. `StorageReference`s /// generated from a resulting instance will reference files in the Firebase project /// associated with custom `FirebaseApp`. /// - Parameter app: The custom `FirebaseApp` used for initialization. /// - Returns: A `Storage` instance, configured with the custom `FirebaseApp`. @objc(storageForApp:) open class func storage(app: FirebaseApp) -> Storage { return storage(app: app, bucket: Storage.bucket(for: app)) } /// Creates an instance of `Storage`, configured with a custom `FirebaseApp` and a custom storage /// bucket URL. /// - Parameters: /// - app: The custom `FirebaseApp` used for initialization. /// - url: The `gs://` url to your Firebase Storage bucket. /// - Returns: The `Storage` instance, configured with the custom `FirebaseApp` and storage bucket /// URL. @objc(storageForApp:URL:) open class func storage(app: FirebaseApp, url: String) -> Storage { return storage(app: app, bucket: Storage.bucket(for: app, urlString: url)) } private class func storage(app: FirebaseApp, bucket: String) -> Storage { return InstanceCache.shared.storage(app: app, bucket: bucket) } /// The `FirebaseApp` associated with this Storage instance. @objc public let app: FirebaseApp /// The maximum time in seconds to retry an upload if a failure occurs. /// Defaults to 10 minutes (600 seconds). @objc public var maxUploadRetryTime: TimeInterval { didSet { maxUploadRetryInterval = Storage.computeRetryInterval(fromRetryTime: maxUploadRetryTime) } } /// The maximum time in seconds to retry a download if a failure occurs. /// Defaults to 10 minutes (600 seconds). @objc public var maxDownloadRetryTime: TimeInterval { didSet { maxDownloadRetryInterval = Storage.computeRetryInterval(fromRetryTime: maxDownloadRetryTime) } } /// The maximum time in seconds to retry operations other than upload and download if a failure /// occurs. /// Defaults to 2 minutes (120 seconds). @objc public var maxOperationRetryTime: TimeInterval { didSet { maxOperationRetryInterval = Storage.computeRetryInterval(fromRetryTime: maxOperationRetryTime) } } /// Specify the maximum upload chunk size. Values less than 256K (262144) will be rounded up to /// 256K. Values /// above 256K will be rounded down to the nearest 256K multiple. The default is no maximum. @objc public var uploadChunkSizeBytes: Int64 = .max /// A `DispatchQueue` that all developer callbacks are fired on. Defaults to the main queue. @objc public var callbackQueue: DispatchQueue = .main /// Creates a `StorageReference` initialized at the root Firebase Storage location. /// - Returns: An instance of `StorageReference` referencing the root of the storage bucket. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @objc open func reference() -> StorageReference { configured = true let path = StoragePath(with: storageBucket) return StorageReference(storage: self, path: path) } /// Creates a StorageReference given a `gs://`, `http://`, or `https://` URL pointing to a /// Firebase Storage location. /// /// For example, you can pass in an `https://` download URL retrieved from /// `StorageReference.downloadURL(completion:)` or the `gs://` URL from /// `StorageReference.description`. /// - Parameter url: A gs:// or https:// URL to initialize the reference with. /// - Returns: An instance of StorageReference at the given child path. /// - Throws: Throws a fatal error if `url` is not associated with the `FirebaseApp` used to /// initialize this Storage instance. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @objc open func reference(forURL url: String) -> StorageReference { configured = true do { let path = try StoragePath.path(string: url) // If no default bucket exists (empty string), accept anything. if storageBucket == "" { return StorageReference(storage: self, path: path) } // If there exists a default bucket, throw if provided a different bucket. if path.bucket != storageBucket { fatalError("Provided bucket: `\(path.bucket)` does not match the Storage bucket of the current " + "instance: `\(storageBucket)`") } return StorageReference(storage: self, path: path) } catch let StoragePathError.storagePathError(message) { fatalError(message) } catch { fatalError("Internal error finding StoragePath: \(error)") } } /// Creates a StorageReference given a `gs://`, `http://`, or `https://` URL pointing to a /// Firebase Storage location. /// /// For example, you can pass in an `https://` download URL retrieved from /// `StorageReference.downloadURL(completion:)` or the `gs://` URL from /// `StorageReference.description`. /// - Parameter url: A gs:// or https:// URL to initialize the reference with. /// - Returns: An instance of StorageReference at the given child path. /// - Throws: Throws an Error if `url` is not associated with the `FirebaseApp` used to initialize /// this Storage instance. open func reference(for url: URL) throws -> StorageReference { configured = true var path: StoragePath do { path = try StoragePath.path(string: url.absoluteString) } catch let StoragePathError.storagePathError(message) { throw StorageError.pathError(message: message) } catch { throw StorageError.pathError(message: "Internal error finding StoragePath: \(error)") } // If no default bucket exists (empty string), accept anything. if storageBucket == "" { return StorageReference(storage: self, path: path) } // If there exists a default bucket, throw if provided a different bucket. if path.bucket != storageBucket { throw StorageError .bucketMismatch(message: "Provided bucket: `\(path.bucket)` does not match the Storage " + "bucket of the current instance: `\(storageBucket)`") } return StorageReference(storage: self, path: path) } /// Creates a `StorageReference` initialized at a location specified by the `path` parameter. /// - Parameter path: A relative path from the root of the storage bucket, /// for instance @"path/to/object". /// - Returns: An instance of `StorageReference` pointing to the given path. @objc(referenceWithPath:) open func reference(withPath path: String) -> StorageReference { return reference().child(path) } /// Configures the Storage SDK to use an emulated backend instead of the default remote backend. /// /// This method should be called before invoking any other methods on a new instance of `Storage`. /// - Parameter host: A string specifying the host. /// - Parameter port: The port specified as an `Int`. @objc open func useEmulator(withHost host: String, port: Int) { guard host.count > 0 else { fatalError("Invalid host argument: Cannot connect to empty host.") } guard port >= 0 else { fatalError("Invalid port argument: Port must be greater or equal to zero.") } guard configured == false else { fatalError("Cannot connect to emulator after Storage SDK initialization. " + "Call useEmulator(host:port:) before creating a Storage " + "reference or trying to load data.") } usesEmulator = true scheme = "http" self.host = host self.port = port } // MARK: - NSObject overrides @objc override open func copy() -> Any { let storage = Storage(app: app, bucket: storageBucket) storage.callbackQueue = callbackQueue return storage } @objc override open func isEqual(_ object: Any?) -> Bool { guard let ref = object as? Storage else { return false } return app == ref.app && storageBucket == ref.storageBucket } @objc override public var hash: Int { return app.hash ^ callbackQueue.hashValue } // MARK: - Internal and Private APIs private final class InstanceCache: @unchecked Sendable { static let shared = InstanceCache() /// A map of active instances, grouped by app. Keys are FirebaseApp names and values are /// instances of Storage associated with the given app. private var instances: [String: Storage] = [:] /// Lock to manage access to the instances array to avoid race conditions. private var instancesLock: os_unfair_lock = .init() private init() {} func storage(app: FirebaseApp, bucket: String) -> Storage { os_unfair_lock_lock(&instancesLock) defer { os_unfair_lock_unlock(&instancesLock) } if let instance = instances[bucket] { return instance } let newInstance = FirebaseStorage.Storage(app: app, bucket: bucket) instances[bucket] = newInstance return newInstance } } let dispatchQueue: DispatchQueue init(app: FirebaseApp, bucket: String) { self.app = app auth = ComponentType.instance(for: AuthInterop.self, in: app.container) appCheck = ComponentType.instance(for: AppCheckInterop.self, in: app.container) storageBucket = bucket host = "firebasestorage.googleapis.com" scheme = "https" port = 443 // Must be a serial queue. dispatchQueue = DispatchQueue(label: "com.google.firebase.storage") maxDownloadRetryTime = 600.0 maxDownloadRetryInterval = Storage.computeRetryInterval(fromRetryTime: maxDownloadRetryTime) maxOperationRetryTime = 120.0 maxOperationRetryInterval = Storage.computeRetryInterval(fromRetryTime: maxOperationRetryTime) maxUploadRetryTime = 600.0 maxUploadRetryInterval = Storage.computeRetryInterval(fromRetryTime: maxUploadRetryTime) } let auth: AuthInterop? let appCheck: AppCheckInterop? let storageBucket: String var usesEmulator = false /// Once `configured` is true, the emulator can no longer be enabled. var configured = false var host: String var scheme: String var port: Int var maxDownloadRetryInterval: TimeInterval var maxOperationRetryInterval: TimeInterval var maxUploadRetryInterval: TimeInterval /// Performs a crude translation of the user provided timeouts to the retry intervals that /// GTMSessionFetcher accepts. GTMSessionFetcher times out operations if the time between /// individual retry attempts exceed a certain threshold, while our API contract looks at the /// total /// observed time of the operation (i.e. the sum of all retries). /// @param retryTime A timeout that caps the sum of all retry attempts /// @return A timeout that caps the timeout of the last retry attempt static func computeRetryInterval(fromRetryTime retryTime: TimeInterval) -> TimeInterval { // GTMSessionFetcher's retry starts at 1 second and then doubles every time. We use this // information to compute a best-effort estimate of what to translate the user provided retry // time into. // Note that this is the same as 2 << (log2(retryTime) - 1), but deemed more readable. var lastInterval = 1.0 var sumOfAllIntervals = 1.0 while sumOfAllIntervals < retryTime { lastInterval *= 2 sumOfAllIntervals += lastInterval } return lastInterval } private static func bucket(for app: FirebaseApp) -> String { guard let bucket = app.options.storageBucket else { fatalError("No default Storage bucket found. Did you configure Firebase Storage properly?") } if bucket == "" { return Storage.bucket(for: app, urlString: "") } else { return Storage.bucket(for: app, urlString: "gs://\(bucket)/") } } private static func bucket(for app: FirebaseApp, urlString: String) -> String { if urlString == "" { return "" } else { guard let path = try? StoragePath.path(GSURI: urlString), path.object == nil || path.object == "" else { fatalError("Internal Error: Storage bucket cannot be initialized with a path") } return path.bucket } } }