// 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 /** * Class which represents the metadata on an object in Firebase Storage. This metadata is * returned on successful operations, and can be used to retrieve download URLs, content types, * and a Storage reference to the object in question. Full documentation can be found at the GCS * Objects#resource docs. * @see https://cloud.google.com/storage/docs/json_api/v1/objects#resource */ @objc(FIRStorageMetadata) open class StorageMetadata: NSObject { // MARK: - Public APIs /** * The name of the bucket containing this object. */ @objc public let bucket: String /** * Cache-Control directive for the object data. */ @objc public var cacheControl: String? /** * Content-Disposition of the object data. */ @objc public var contentDisposition: String? /** * Content-Encoding of the object data. */ @objc public var contentEncoding: String? /** * Content-Language of the object data. */ @objc public var contentLanguage: String? /** * Content-Type of the object data. */ @objc public var contentType: String? /** * MD5 hash of the data; encoded using base64. */ @objc public let md5Hash: String? /** * The content generation of this object. Used for object versioning. */ @objc public let generation: Int64 /** * User-provided metadata, in key/value pairs. */ @objc public var customMetadata: [String: String]? /** * The version of the metadata for this object at this generation. Used * for preconditions and for detecting changes in metadata. A metageneration number is only * meaningful in the context of a particular generation of a particular object. */ @objc public let metageneration: Int64 /** * The name of this object, in gs://bucket/path/to/object.txt, this is object.txt. */ @objc public internal(set) var name: String? /** * The full path of this object, in gs://bucket/path/to/object.txt, this is path/to/object.txt. */ @objc public internal(set) var path: String? /** * Content-Length of the data in bytes. */ @objc public let size: Int64 /** * The creation time of the object in RFC 3339 format. */ @objc public let timeCreated: Date? /** * The modification time of the object metadata in RFC 3339 format. */ @objc public let updated: Date? /** * Never used API */ @available(*, deprecated) @objc public let storageReference: StorageReference? = nil /** * Creates a Dictionary from the contents of the metadata. * @return A Dictionary that represents the contents of the metadata. */ @objc open func dictionaryRepresentation() -> [String: AnyHashable] { let stringFromDate = StorageMetadata.RFC3339StringFromDate return [ "bucket": bucket, "cacheControl": cacheControl, "contentDisposition": contentDisposition, "contentEncoding": contentEncoding, "contentLanguage": contentLanguage, "contentType": contentType, "md5Hash": md5Hash, "size": size != 0 ? size : nil, "generation": generation != 0 ? "\(generation)" : nil, "metageneration": metageneration != 0 ? "\(metageneration)" : nil, "timeCreated": timeCreated.map(stringFromDate), "updated": updated.map(stringFromDate), "name": path, ].compactMapValues { $0 }.merging(["metadata": customMetadata]) { current, _ in current } } /** * Determines if the current metadata represents a "file". */ @objc public var isFile: Bool { return fileType == .file } // TODO: Nothing in the implementation ever sets `.folder`. /** * Determines if the current metadata represents a "folder". */ @objc public var isFolder: Bool { return fileType == .folder } // MARK: - Public Initializers @objc override public convenience init() { self.init(dictionary: [:]) } /** * Creates an instance of StorageMetadata from the contents of a dictionary. * @return An instance of StorageMetadata that represents the contents of a dictionary. */ @objc public init(dictionary: [String: AnyHashable]) { initialMetadata = dictionary bucket = dictionary["bucket"] as? String ?? "" cacheControl = dictionary["cacheControl"] as? String ?? nil contentDisposition = dictionary["contentDisposition"] as? String ?? nil contentEncoding = dictionary["contentEncoding"] as? String ?? nil contentLanguage = dictionary["contentLanguage"] as? String ?? nil contentType = dictionary["contentType"] as? String ?? nil customMetadata = dictionary["metadata"] as? [String: String] ?? nil size = StorageMetadata.intFromDictionaryValue(dictionary["size"]) generation = StorageMetadata.intFromDictionaryValue(dictionary["generation"]) metageneration = StorageMetadata.intFromDictionaryValue(dictionary["metageneration"]) timeCreated = StorageMetadata.dateFromRFC3339String(dictionary["timeCreated"]) updated = StorageMetadata.dateFromRFC3339String(dictionary["updated"]) md5Hash = dictionary["md5Hash"] as? String ?? nil // GCS "name" is our path, our "name" is just the last path component of the path if let name = dictionary["name"] as? String { path = name self.name = (name as NSString).lastPathComponent } fileType = .unknown } // MARK: - NSObject overrides @objc override open func copy() -> Any { let clone = StorageMetadata(dictionary: dictionaryRepresentation()) clone.initialMetadata = initialMetadata return clone } @objc override open func isEqual(_ object: Any?) -> Bool { guard let ref = object as? StorageMetadata else { return false } return ref.dictionaryRepresentation() == dictionaryRepresentation() } @objc override public var hash: Int { return (dictionaryRepresentation() as NSDictionary).hash } @objc override public var description: String { return "\(type(of: self)) \(dictionaryRepresentation())" } // MARK: - Internal APIs internal func updatedMetadata() -> [String: AnyHashable] { return remove(matchingMetadata: dictionaryRepresentation(), oldMetadata: initialMetadata) } internal enum StorageMetadataType { case unknown case file case folder } internal var fileType: StorageMetadataType // MARK: - Private APIs and data private var initialMetadata: [String: AnyHashable] private static func intFromDictionaryValue(_ value: Any?) -> Int64 { if let value = value as? Int { return Int64(value) } if let value = value as? Int64 { return value } if let value = value as? String { return Int64(value) ?? 0 } return 0 } private static var dateFormatter: DateFormatter = { let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") // set locale to reliable US_POSIX dateFormatter.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.SSSZZZZZ" dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) return dateFormatter }() private static func dateFromRFC3339String(_ value: Any?) -> Date? { if let stringValue = value as? String { return dateFormatter.date(from: stringValue) ?? nil } return nil } internal static func RFC3339StringFromDate(_ date: Date) -> String { return dateFormatter.string(from: date) } private func remove(matchingMetadata: [String: AnyHashable], oldMetadata: [String: AnyHashable]) -> [String: AnyHashable] { var newMetadata = matchingMetadata for (key, oldValue) in oldMetadata { guard let newValue = newMetadata[key] else { // Adds 'NSNull' for entries that only exist in oldMetadata. newMetadata[key] = NSNull() continue } if let oldString = oldValue as? String, let newString = newValue as? String { if oldString == newString { newMetadata[key] = nil } } else if let oldDictionary = oldValue as? [String: AnyHashable], let newDictionary = newValue as? [String: AnyHashable] { let outDictionary = remove(matchingMetadata: newDictionary, oldMetadata: oldDictionary) if outDictionary.count == 0 { newMetadata[key] = nil } else { newMetadata[key] = outDictionary } } } return newMetadata } }