diff --git a/Signal/src/ViewControllers/GifPicker/GifPickerCell.swift b/Signal/src/ViewControllers/GifPicker/GifPickerCell.swift index ca8c5503f..a5934f875 100644 --- a/Signal/src/ViewControllers/GifPicker/GifPickerCell.swift +++ b/Signal/src/ViewControllers/GifPicker/GifPickerCell.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import Foundation @@ -20,7 +20,7 @@ class GifPickerCell: UICollectionViewCell { } } - // Loading and playing GIFs is quite expensive (network, memory, cpu). + // Loading and playing GIFs is quite expensive (network, memory, cpu). // Here's a bit of logic to not preload offscreen cells that are prefetched. var isCellVisible = false { didSet { @@ -32,10 +32,10 @@ class GifPickerCell: UICollectionViewCell { // We do "progressive" loading by loading stills (jpg or gif) and "animated" gifs. // This is critical on cellular connections. - var stillAssetRequest: GiphyAssetRequest? - var stillAsset: GiphyAsset? - var animatedAssetRequest: GiphyAssetRequest? - var animatedAsset: GiphyAsset? + var stillAssetRequest: ProxiedContentAssetRequest? + var stillAsset: ProxiedContentAsset? + var animatedAssetRequest: ProxiedContentAssetRequest? + var animatedAsset: ProxiedContentAsset? var imageView: YYAnimatedImageView? var activityIndicator: UIActivityIndicatorView? @@ -119,7 +119,7 @@ class GifPickerCell: UICollectionViewCell { } self.renditionForSending = highQualityAnimatedRendition - // The Giphy API returns a slew of "renditions" for a given image. + // The Giphy API returns a slew of "renditions" for a given image. // It's critical that we carefully "pick" the best rendition to use. guard let animatedRendition = imageInfo.pickPreviewRendition() else { Logger.warn("could not pick gif rendition: \(imageInfo.giphyId)") @@ -136,52 +136,52 @@ class GifPickerCell: UICollectionViewCell { if stillAsset != nil || animatedAsset != nil { clearStillAssetRequest() } else if stillAssetRequest == nil { - stillAssetRequest = GiphyDownloader.sharedInstance.requestAsset(rendition: stillRendition, - priority: .high, + stillAssetRequest = GiphyDownloader.giphyDownloader.requestAsset(assetDescription: stillRendition, + priority: .high, + success: { [weak self] assetRequest, asset in + guard let strongSelf = self else { return } + if assetRequest != nil && assetRequest != strongSelf.stillAssetRequest { + owsFailDebug("Obsolete request callback.") + return + } + strongSelf.clearStillAssetRequest() + strongSelf.stillAsset = asset + strongSelf.ensureViewState() + }, + failure: { [weak self] assetRequest in + guard let strongSelf = self else { return } + if assetRequest != strongSelf.stillAssetRequest { + owsFailDebug("Obsolete request callback.") + return + } + strongSelf.clearStillAssetRequest() + }) + } + + // Start animated asset request if necessary. + if animatedAsset != nil { + clearAnimatedAssetRequest() + } else if animatedAssetRequest == nil { + animatedAssetRequest = GiphyDownloader.giphyDownloader.requestAsset(assetDescription: animatedRendition, + priority: .low, success: { [weak self] assetRequest, asset in guard let strongSelf = self else { return } - if assetRequest != nil && assetRequest != strongSelf.stillAssetRequest { + if assetRequest != nil && assetRequest != strongSelf.animatedAssetRequest { owsFailDebug("Obsolete request callback.") return } - strongSelf.clearStillAssetRequest() - strongSelf.stillAsset = asset + // If we have the animated asset, we don't need the still asset. + strongSelf.clearAssetRequests() + strongSelf.animatedAsset = asset strongSelf.ensureViewState() }, failure: { [weak self] assetRequest in guard let strongSelf = self else { return } - if assetRequest != strongSelf.stillAssetRequest { + if assetRequest != strongSelf.animatedAssetRequest { owsFailDebug("Obsolete request callback.") return } - strongSelf.clearStillAssetRequest() - }) - } - - // Start animated asset request if necessary. - if animatedAsset != nil { - clearAnimatedAssetRequest() - } else if animatedAssetRequest == nil { - animatedAssetRequest = GiphyDownloader.sharedInstance.requestAsset(rendition: animatedRendition, - priority: .low, - success: { [weak self] assetRequest, asset in - guard let strongSelf = self else { return } - if assetRequest != nil && assetRequest != strongSelf.animatedAssetRequest { - owsFailDebug("Obsolete request callback.") - return - } - // If we have the animated asset, we don't need the still asset. - strongSelf.clearAssetRequests() - strongSelf.animatedAsset = asset - strongSelf.ensureViewState() - }, - failure: { [weak self] assetRequest in - guard let strongSelf = self else { return } - if assetRequest != strongSelf.animatedAssetRequest { - owsFailDebug("Obsolete request callback.") - return - } - strongSelf.clearAnimatedAssetRequest() + strongSelf.clearAnimatedAssetRequest() }) } } @@ -243,23 +243,22 @@ class GifPickerCell: UICollectionViewCell { } } - public func requestRenditionForSending() -> Promise { + public func requestRenditionForSending() -> Promise { guard let renditionForSending = self.renditionForSending else { owsFailDebug("renditionForSending was unexpectedly nil") return Promise(error: GiphyError.assertionError(description: "renditionForSending was unexpectedly nil")) } - let (promise, resolver) = Promise.pending() + let (promise, resolver) = Promise.pending() // We don't retain a handle on the asset request, since there will only ever // be one selected asset, and we never want to cancel it. - _ = GiphyDownloader - .sharedInstance.requestAsset(rendition: renditionForSending, - priority: .high, - success: { _, asset in + _ = GiphyDownloader.giphyDownloader.requestAsset(assetDescription: renditionForSending, + priority: .high, + success: { _, asset in resolver.fulfill(asset) }, - failure: { _ in + failure: { _ in // TODO GiphyDownloader API should pass through a useful failing error // so we can pass it through here Logger.error("request failed") @@ -276,7 +275,7 @@ class GifPickerCell: UICollectionViewCell { : UIColor(white: 0.95, alpha: 1.0)) } - private func pickBestAsset() -> GiphyAsset? { + private func pickBestAsset() -> ProxiedContentAsset? { return animatedAsset ?? stillAsset } } diff --git a/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift b/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift index 34a595745..8918893ce 100644 --- a/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift +++ b/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift @@ -362,14 +362,19 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect } public func getFileForCell(_ cell: GifPickerCell) { - GiphyDownloader.sharedInstance.cancelAllRequests() + GiphyDownloader.giphyDownloader.cancelAllRequests() + firstly { cell.requestRenditionForSending() - }.done { [weak self] (asset: GiphyAsset) in + }.done { [weak self] (asset: ProxiedContentAsset) in guard let strongSelf = self else { Logger.info("ignoring send, since VC was dismissed before fetching finished.") return } + guard let rendition = asset.assetDescription as? GiphyRendition else { + owsFailDebug("Invalid asset description.") + return + } let filePath = asset.filePath guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath, @@ -377,7 +382,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect owsFailDebug("couldn't load asset.") return } - let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: asset.rendition.utiType, imageQuality: .original) + let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: rendition.utiType, imageQuality: .original) strongSelf.dismiss(animated: true) { // Delegate presents view controllers, so it's important that *this* controller be dismissed before that occurs. diff --git a/Signal/src/network/GiphyAPI.swift b/Signal/src/network/GiphyAPI.swift index e91441d2b..03e082949 100644 --- a/Signal/src/network/GiphyAPI.swift +++ b/Signal/src/network/GiphyAPI.swift @@ -29,15 +29,14 @@ extension GiphyError: LocalizedError { // They vary in content size (i.e. width, height), // format (.jpg, .gif, .mp4, webp, etc.), // quality, etc. -@objc class GiphyRendition: NSObject { +@objc class GiphyRendition: ProxiedContentAssetDescription { let format: GiphyFormat let name: String let width: UInt let height: UInt let fileSize: UInt - let url: NSURL - init(format: GiphyFormat, + init?(format: GiphyFormat, name: String, width: UInt, height: UInt, @@ -48,10 +47,12 @@ extension GiphyError: LocalizedError { self.width = width self.height = height self.fileSize = fileSize - self.url = url + + let fileExtension = GiphyRendition.fileExtension(forFormat: format) + super.init(url: url, fileExtension: fileExtension) } - public var fileExtension: String { + private class func fileExtension(forFormat format: GiphyFormat) -> String { switch format { case .gif: return "gif" diff --git a/Signal/src/network/GiphyDownloader.swift b/Signal/src/network/GiphyDownloader.swift index 71c329957..4f7cf35e9 100644 --- a/Signal/src/network/GiphyDownloader.swift +++ b/Signal/src/network/GiphyDownloader.swift @@ -3,806 +3,11 @@ // import Foundation -import ObjectiveC import SignalServiceKit -import SignalMessaging -// Stills should be loaded before full GIFs. -enum GiphyRequestPriority { - case low, high -} - -enum GiphyAssetSegmentState: UInt { - case waiting - case downloading - case complete - case failed -} - -class GiphyAssetSegment: NSObject { - - public let index: UInt - public let segmentStart: UInt - public let segmentLength: UInt - // The amount of the segment that is overlap. - // The overlap lies in the _first_ n bytes of the segment data. - public let redundantLength: UInt - - // This state should only be accessed on the main thread. - public var state: GiphyAssetSegmentState = .waiting { - didSet { - AssertIsOnMainThread() - } - } - - // This state is accessed off the main thread. - // - // * During downloads it will be accessed on the task delegate queue. - // * After downloads it will be accessed on a worker queue. - private var segmentData = Data() - - // This state should only be accessed on the main thread. - public weak var task: URLSessionDataTask? - - init(index: UInt, - segmentStart: UInt, - segmentLength: UInt, - redundantLength: UInt) { - self.index = index - self.segmentStart = segmentStart - self.segmentLength = segmentLength - self.redundantLength = redundantLength - } - - public func totalDataSize() -> UInt { - return UInt(segmentData.count) - } - - public func append(data: Data) { - guard state == .downloading else { - owsFailDebug("appending data in invalid state: \(state)") - return - } - - segmentData.append(data) - } - - public func mergeData(assetData: inout Data) -> Bool { - guard state == .complete else { - owsFailDebug("merging data in invalid state: \(state)") - return false - } - guard UInt(segmentData.count) == segmentLength else { - owsFailDebug("segment data length: \(segmentData.count) doesn't match expected length: \(segmentLength)") - return false - } - - // In some cases the last two segments will overlap. - // In that case, we only want to append the non-overlapping - // tail of the segment data. - let bytesToIgnore = Int(redundantLength) - if bytesToIgnore > 0 { - let subdata = segmentData.subdata(in: bytesToIgnore.. Void)? - private var failure: ((GiphyAssetRequest) -> Void)? - - var wasCancelled = false - // This property is an internal implementation detail of the download process. - var assetFilePath: String? - - // This state should only be accessed on the main thread. - private var segments = [GiphyAssetSegment]() - public var state: GiphyAssetRequestState = .waiting - public var contentLength: Int = 0 { - didSet { - AssertIsOnMainThread() - assert(oldValue == 0) - assert(contentLength > 0) - - createSegments() - } - } - public weak var contentLengthTask: URLSessionDataTask? - - init(rendition: GiphyRendition, - priority: GiphyRequestPriority, - success:@escaping ((GiphyAssetRequest?, GiphyAsset) -> Void), - failure:@escaping ((GiphyAssetRequest) -> Void)) { - self.rendition = rendition - self.priority = priority - self.success = success - self.failure = failure - - super.init() - } - - private func segmentSize() -> UInt { - AssertIsOnMainThread() - - let contentLength = UInt(self.contentLength) - guard contentLength > 0 else { - owsFailDebug("rendition missing contentLength") - requestDidFail() - return 0 - } - - let k1MB: UInt = 1024 * 1024 - let k500KB: UInt = 500 * 1024 - let k100KB: UInt = 100 * 1024 - let k50KB: UInt = 50 * 1024 - let k10KB: UInt = 10 * 1024 - let k1KB: UInt = 1 * 1024 - for segmentSize in [k1MB, k500KB, k100KB, k50KB, k10KB, k1KB ] { - if contentLength >= segmentSize { - return segmentSize - } - } - return contentLength - } - - private func createSegments() { - AssertIsOnMainThread() - - let segmentLength = segmentSize() - guard segmentLength > 0 else { - return - } - let contentLength = UInt(self.contentLength) - - var nextSegmentStart: UInt = 0 - var index: UInt = 0 - while nextSegmentStart < contentLength { - var segmentStart: UInt = nextSegmentStart - var redundantLength: UInt = 0 - // The last segment may overlap the penultimate segment - // in order to keep the segment sizes uniform. - if segmentStart + segmentLength > contentLength { - redundantLength = segmentStart + segmentLength - contentLength - segmentStart = contentLength - segmentLength - } - let assetSegment = GiphyAssetSegment(index: index, - segmentStart: segmentStart, - segmentLength: segmentLength, - redundantLength: redundantLength) - segments.append(assetSegment) - nextSegmentStart = segmentStart + segmentLength - index += 1 - } - } - - private func firstSegmentWithState(state: GiphyAssetSegmentState) -> GiphyAssetSegment? { - AssertIsOnMainThread() - - for segment in segments { - guard segment.state != .failed else { - owsFailDebug("unexpected failed segment.") - continue - } - if segment.state == state { - return segment - } - } - return nil - } - - public func firstWaitingSegment() -> GiphyAssetSegment? { - AssertIsOnMainThread() - - return firstSegmentWithState(state: .waiting) - } - - public func downloadingSegmentsCount() -> UInt { - AssertIsOnMainThread() - - var result: UInt = 0 - for segment in segments { - guard segment.state != .failed else { - owsFailDebug("unexpected failed segment.") - continue - } - if segment.state == .downloading { - result += 1 - } - } - return result - } - - public func areAllSegmentsComplete() -> Bool { - AssertIsOnMainThread() - - for segment in segments { - guard segment.state == .complete else { - return false - } - } - return true - } - - public func writeAssetToFile(gifFolderPath: String) -> GiphyAsset? { - - var assetData = Data() - for segment in segments { - guard segment.state == .complete else { - owsFailDebug("unexpected incomplete segment.") - return nil - } - guard segment.totalDataSize() > 0 else { - owsFailDebug("could not merge empty segment.") - return nil - } - guard segment.mergeData(assetData: &assetData) else { - owsFailDebug("failed to merge segment data.") - return nil - } - } - - guard assetData.count == contentLength else { - owsFailDebug("asset data has unexpected length.") - return nil - } - - guard assetData.count > 0 else { - owsFailDebug("could not write empty asset to disk.") - return nil - } - - let fileExtension = rendition.fileExtension - let fileName = (NSUUID().uuidString as NSString).appendingPathExtension(fileExtension)! - let filePath = (gifFolderPath as NSString).appendingPathComponent(fileName) - - Logger.verbose("filePath: \(filePath).") - - do { - try assetData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite) - let asset = GiphyAsset(rendition: rendition, filePath: filePath) - return asset - } catch let error as NSError { - owsFailDebug("file write failed: \(filePath), \(error)") - return nil - } - } - - public func cancel() { - AssertIsOnMainThread() - - wasCancelled = true - contentLengthTask?.cancel() - contentLengthTask = nil - for segment in segments { - segment.task?.cancel() - segment.task = nil - } - - // Don't call the callbacks if the request is cancelled. - clearCallbacks() - } - - private func clearCallbacks() { - AssertIsOnMainThread() - - success = nil - failure = nil - } - - public func requestDidSucceed(asset: GiphyAsset) { - AssertIsOnMainThread() - - success?(self, asset) - - // Only one of the callbacks should be called, and only once. - clearCallbacks() - } - - public func requestDidFail() { - AssertIsOnMainThread() - - failure?(self) - - // Only one of the callbacks should be called, and only once. - clearCallbacks() - } -} - -// Represents a downloaded gif asset. -// -// The blob on disk is cleaned up when this instance is deallocated, -// so consumers of this resource should retain a strong reference to -// this instance as long as they are using the asset. -@objc class GiphyAsset: NSObject { - - let rendition: GiphyRendition - let filePath: String - - init(rendition: GiphyRendition, - filePath: String) { - self.rendition = rendition - self.filePath = filePath - } - - deinit { - // Clean up on the asset on disk. - let filePathCopy = filePath - DispatchQueue.global().async { - do { - let fileManager = FileManager.default - try fileManager.removeItem(atPath: filePathCopy) - } catch let error as NSError { - owsFailDebug("file cleanup failed: \(filePathCopy), \(error)") - } - } - } -} - -private var URLSessionTaskGiphyAssetRequest: UInt8 = 0 -private var URLSessionTaskGiphyAssetSegment: UInt8 = 0 - -// This extension is used to punch an asset request onto a download task. -extension URLSessionTask { - var assetRequest: GiphyAssetRequest { - get { - return objc_getAssociatedObject(self, &URLSessionTaskGiphyAssetRequest) as! GiphyAssetRequest - } - set { - objc_setAssociatedObject(self, &URLSessionTaskGiphyAssetRequest, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } - var assetSegment: GiphyAssetSegment { - get { - return objc_getAssociatedObject(self, &URLSessionTaskGiphyAssetSegment) as! GiphyAssetSegment - } - set { - objc_setAssociatedObject(self, &URLSessionTaskGiphyAssetSegment, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } -} - -@objc class GiphyDownloader: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate { +@objc class GiphyDownloader: ProxiedContentDownloader { // MARK: - Properties - static let sharedInstance = GiphyDownloader() - - var gifFolderPath = "" - - // Force usage as a singleton - override private init() { - AssertIsOnMainThread() - - super.init() - - SwiftSingletons.register(self) - - ensureGifFolder() - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - private let kGiphyBaseURL = "https://api.giphy.com/" - - private lazy var giphyDownloadSession: URLSession = { - AssertIsOnMainThread() - - let configuration = ContentProxy.sessionConfiguration() - configuration.urlCache = nil - configuration.requestCachePolicy = .reloadIgnoringCacheData - configuration.httpMaximumConnectionsPerHost = 10 - let session = URLSession(configuration: configuration, - delegate: self, - delegateQueue: nil) - return session - }() - - // 100 entries of which at least half will probably be stills. - // Actual animated GIFs will usually be less than 3 MB so the - // max size of the cache on disk should be ~150 MB. Bear in mind - // that assets are not always deleted on disk as soon as they are - // evacuated from the cache; if a cache consumer (e.g. view) is - // still using the asset, the asset won't be deleted on disk until - // it is no longer in use. - private var assetMap = LRUCache(maxSize: 100) - // TODO: We could use a proper queue, e.g. implemented with a linked - // list. - private var assetRequestQueue = [GiphyAssetRequest]() - - // The success and failure callbacks are always called on main queue. - // - // The success callbacks may be called synchronously on cache hit, in - // which case the GiphyAssetRequest parameter will be nil. - public func requestAsset(rendition: GiphyRendition, - priority: GiphyRequestPriority, - success:@escaping ((GiphyAssetRequest?, GiphyAsset) -> Void), - failure:@escaping ((GiphyAssetRequest) -> Void)) -> GiphyAssetRequest? { - AssertIsOnMainThread() - - if let asset = assetMap.get(key: rendition.url) { - // Synchronous cache hit. - Logger.verbose("asset cache hit: \(rendition.url)") - success(nil, asset) - return nil - } - - // Cache miss. - // - // Asset requests are done queued and performed asynchronously. - Logger.verbose("asset cache miss: \(rendition.url)") - let assetRequest = GiphyAssetRequest(rendition: rendition, - priority: priority, - success: success, - failure: failure) - assetRequestQueue.append(assetRequest) - // Process the queue (which may start this request) - // asynchronously so that the caller has time to store - // a reference to the asset request returned by this - // method before its success/failure handler is called. - processRequestQueueAsync() - return assetRequest - } - - public func cancelAllRequests() { - AssertIsOnMainThread() - - Logger.verbose("cancelAllRequests") - - self.assetRequestQueue.forEach { $0.cancel() } - self.assetRequestQueue = [] - } - - private func segmentRequestDidSucceed(assetRequest: GiphyAssetRequest, assetSegment: GiphyAssetSegment) { - DispatchQueue.main.async { - assetSegment.state = .complete - - if assetRequest.areAllSegmentsComplete() { - // If the asset request has completed all of its segments, - // try to write the asset to file. - assetRequest.state = .complete - - // Move write off main thread. - DispatchQueue.global().async { - guard let asset = assetRequest.writeAssetToFile(gifFolderPath: self.gifFolderPath) else { - self.segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment) - return - } - self.assetRequestDidSucceed(assetRequest: assetRequest, asset: asset) - } - } else { - self.processRequestQueueSync() - } - } - } - - private func assetRequestDidSucceed(assetRequest: GiphyAssetRequest, asset: GiphyAsset) { - - DispatchQueue.main.async { - self.assetMap.set(key: assetRequest.rendition.url, value: asset) - self.removeAssetRequestFromQueue(assetRequest: assetRequest) - assetRequest.requestDidSucceed(asset: asset) - } - } - - // TODO: If we wanted to implement segment retry, we'll need to add - // a segmentRequestDidFail() method. - private func segmentRequestDidFail(assetRequest: GiphyAssetRequest, assetSegment: GiphyAssetSegment) { - DispatchQueue.main.async { - assetSegment.state = .failed - assetRequest.state = .failed - self.assetRequestDidFail(assetRequest: assetRequest) - } - } - - private func assetRequestDidFail(assetRequest: GiphyAssetRequest) { - - DispatchQueue.main.async { - self.removeAssetRequestFromQueue(assetRequest: assetRequest) - assetRequest.requestDidFail() - } - } - - private func removeAssetRequestFromQueue(assetRequest: GiphyAssetRequest) { - AssertIsOnMainThread() - - guard assetRequestQueue.contains(assetRequest) else { - Logger.warn("could not remove asset request from queue: \(assetRequest.rendition.url)") - return - } - - assetRequestQueue = assetRequestQueue.filter { $0 != assetRequest } - // Process the queue async to ensure that state in the downloader - // classes is consistent before we try to start a new request. - processRequestQueueAsync() - } - - private func processRequestQueueAsync() { - DispatchQueue.main.async { - self.processRequestQueueSync() - } - } - - // * Start a segment request or content length request if possible. - // * Complete/cancel asset requests if possible. - // - private func processRequestQueueSync() { - AssertIsOnMainThread() - - guard let assetRequest = popNextAssetRequest() else { - return - } - guard !assetRequest.wasCancelled else { - // Discard the cancelled asset request and try again. - removeAssetRequestFromQueue(assetRequest: assetRequest) - return - } - guard UIApplication.shared.applicationState == .active else { - // If app is not active, fail the asset request. - assetRequest.state = .failed - assetRequestDidFail(assetRequest: assetRequest) - processRequestQueueSync() - return - } - - if let asset = assetMap.get(key: assetRequest.rendition.url) { - // Deferred cache hit, avoids re-downloading assets that were - // downloaded while this request was queued. - - assetRequest.state = .complete - assetRequestDidSucceed(assetRequest: assetRequest, asset: asset) - return - } - - if assetRequest.state == .waiting { - // If asset request hasn't yet determined the resource size, - // try to do so now. - assetRequest.state = .requestingSize - - var request = URLRequest(url: assetRequest.rendition.url as URL) - request.httpMethod = "HEAD" - request.httpShouldUsePipelining = true - - let task = giphyDownloadSession.dataTask(with: request, completionHandler: { data, response, error -> Void in - if let data = data, data.count > 0 { - owsFailDebug("HEAD request has unexpected body: \(data.count).") - } - self.handleAssetSizeResponse(assetRequest: assetRequest, response: response, error: error) - }) - assetRequest.contentLengthTask = task - task.resume() - } else { - // Start a download task. - - guard let assetSegment = assetRequest.firstWaitingSegment() else { - owsFailDebug("queued asset request does not have a waiting segment.") - return - } - assetSegment.state = .downloading - - var request = URLRequest(url: assetRequest.rendition.url as URL) - request.httpShouldUsePipelining = true - let rangeHeaderValue = "bytes=\(assetSegment.segmentStart)-\(assetSegment.segmentStart + assetSegment.segmentLength - 1)" - request.addValue(rangeHeaderValue, forHTTPHeaderField: "Range") - let task: URLSessionDataTask = giphyDownloadSession.dataTask(with: request) - task.assetRequest = assetRequest - task.assetSegment = assetSegment - assetSegment.task = task - task.resume() - } - - // Recurse; we may be able to start multiple downloads. - processRequestQueueSync() - } - - private func handleAssetSizeResponse(assetRequest: GiphyAssetRequest, response: URLResponse?, error: Error?) { - guard error == nil else { - assetRequest.state = .failed - self.assetRequestDidFail(assetRequest: assetRequest) - return - } - guard let httpResponse = response as? HTTPURLResponse else { - owsFailDebug("Asset size response is invalid.") - assetRequest.state = .failed - self.assetRequestDidFail(assetRequest: assetRequest) - return - } - guard let contentLengthString = httpResponse.allHeaderFields["Content-Length"] as? String else { - owsFailDebug("Asset size response is missing content length.") - assetRequest.state = .failed - self.assetRequestDidFail(assetRequest: assetRequest) - return - } - guard let contentLength = Int(contentLengthString) else { - owsFailDebug("Asset size response has unparsable content length.") - assetRequest.state = .failed - self.assetRequestDidFail(assetRequest: assetRequest) - return - } - guard contentLength > 0 else { - owsFailDebug("Asset size response has invalid content length.") - assetRequest.state = .failed - self.assetRequestDidFail(assetRequest: assetRequest) - return - } - - DispatchQueue.main.async { - assetRequest.contentLength = contentLength - assetRequest.state = .active - self.processRequestQueueSync() - } - } - - // Return the first asset request for which we either: - // - // * Need to download the content length. - // * Need to download at least one of its segments. - private func popNextAssetRequest() -> GiphyAssetRequest? { - AssertIsOnMainThread() - - let kMaxAssetRequestCount: UInt = 3 - let kMaxAssetRequestsPerAssetCount: UInt = kMaxAssetRequestCount - 1 - - // Prefer the first "high" priority request; - // fall back to the first "low" priority request. - var activeAssetRequestsCount: UInt = 0 - for priority in [GiphyRequestPriority.high, GiphyRequestPriority.low] { - for assetRequest in assetRequestQueue where assetRequest.priority == priority { - switch assetRequest.state { - case .waiting: - // This asset request needs its content length. - return assetRequest - case .requestingSize: - activeAssetRequestsCount += 1 - // Ensure that only N requests are active at a time. - guard activeAssetRequestsCount < kMaxAssetRequestCount else { - return nil - } - continue - case .active: - break - case .complete: - continue - case .failed: - continue - } - - let downloadingSegmentsCount = assetRequest.downloadingSegmentsCount() - activeAssetRequestsCount += downloadingSegmentsCount - // Ensure that only N segment requests are active per asset at a time. - guard downloadingSegmentsCount < kMaxAssetRequestsPerAssetCount else { - continue - } - // Ensure that only N requests are active at a time. - guard activeAssetRequestsCount < kMaxAssetRequestCount else { - return nil - } - guard assetRequest.firstWaitingSegment() != nil else { - /// Asset request does not have a waiting segment. - continue - } - return assetRequest - } - } - - return nil - } - - // MARK: URLSessionDataDelegate - - @nonobjc - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - - completionHandler(.allow) - } - - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - let assetRequest = dataTask.assetRequest - let assetSegment = dataTask.assetSegment - guard !assetRequest.wasCancelled else { - dataTask.cancel() - segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment) - return - } - assetSegment.append(data: data) - } - - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Void) { - completionHandler(nil) - } - - // MARK: URLSessionTaskDelegate - - public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - - let assetRequest = task.assetRequest - let assetSegment = task.assetSegment - guard !assetRequest.wasCancelled else { - task.cancel() - segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment) - return - } - if let error = error { - Logger.error("download failed with error: \(error)") - segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment) - return - } - guard let httpResponse = task.response as? HTTPURLResponse else { - Logger.error("missing or unexpected response: \(String(describing: task.response))") - segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment) - return - } - let statusCode = httpResponse.statusCode - guard statusCode >= 200 && statusCode < 400 else { - Logger.error("response has invalid status code: \(statusCode)") - segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment) - return - } - guard assetSegment.totalDataSize() == assetSegment.segmentLength else { - Logger.error("segment is missing data: \(statusCode)") - segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment) - return - } - - segmentRequestDidSucceed(assetRequest: assetRequest, assetSegment: assetSegment) - } - - // MARK: Temp Directory - - public func ensureGifFolder() { - // We write assets to the temporary directory so that iOS can clean them up. - // We try to eagerly clean up these assets when they are no longer in use. - - let tempDirPath = OWSTemporaryDirectory() - let dirPath = (tempDirPath as NSString).appendingPathComponent("GIFs") - do { - let fileManager = FileManager.default - - // Try to delete existing folder if necessary. - if fileManager.fileExists(atPath: dirPath) { - try fileManager.removeItem(atPath: dirPath) - gifFolderPath = dirPath - } - // Try to create folder if necessary. - if !fileManager.fileExists(atPath: dirPath) { - try fileManager.createDirectory(atPath: dirPath, - withIntermediateDirectories: true, - attributes: nil) - gifFolderPath = dirPath - } - - // Don't back up Giphy downloads. - OWSFileSystem.protectFileOrFolder(atPath: dirPath) - } catch let error as NSError { - owsFailDebug("ensureTempFolder failed: \(dirPath), \(error)") - gifFolderPath = tempDirPath - } - } + public static let giphyDownloader = GiphyDownloader(downloadFolderName: "GIFs") } diff --git a/SignalServiceKit/src/Network/ProxiedContentDownloader.swift b/SignalServiceKit/src/Network/ProxiedContentDownloader.swift index 671f7c49c..496e8150b 100644 --- a/SignalServiceKit/src/Network/ProxiedContentDownloader.swift +++ b/SignalServiceKit/src/Network/ProxiedContentDownloader.swift @@ -6,72 +6,39 @@ import Foundation import ObjectiveC // Stills should be loaded before full GIFs. -enum ProxiedContentRequestPriority { +public enum ProxiedContentRequestPriority { case low, high } // MARK: - -@objc class ProxiedContentDescription: NSObject { -// let format: GiphyFormat -// let name: String -// let width: UInt -// let height: UInt -// let fileSize: UInt -// let url: NSURL -// -// init(format: GiphyFormat, -// name: String, -// width: UInt, -// height: UInt, -// fileSize: UInt, -// url: NSURL) { -// self.format = format -// self.name = name -// self.width = width -// self.height = height -// self.fileSize = fileSize -// self.url = url -// } -// -// public var fileExtension: String { -// switch format { -// case .gif: -// return "gif" -// case .mp4: -// return "mp4" -// case .jpg: -// return "jpg" -// } -// } -// -// public var utiType: String { -// switch format { -// case .gif: -// return kUTTypeGIF as String -// case .mp4: -// return kUTTypeMPEG4 as String -// case .jpg: -// return kUTTypeJPEG as String -// } -// } -// -// public var isStill: Bool { -// return name.hasSuffix("_still") -// } -// -// public var isDownsampled: Bool { -// return name.hasSuffix("_downsampled") -// } -// -// public func log() { -// Logger.verbose("\t \(format), \(name), \(width), \(height), \(fileSize)") -// } +@objc +open class ProxiedContentAssetDescription: NSObject { + @objc + public let url: NSURL + + @objc + public let fileExtension: String + + public init?(url: NSURL, + fileExtension: String? = nil) { + self.url = url + + if let fileExtension = fileExtension { + self.fileExtension = fileExtension + } else { + guard let pathExtension = url.pathExtension else { + owsFailDebug("URL has not path extension.") + return nil + } + self.fileExtension = pathExtension + } + } } // MARK: - -enum ProxiedContentAssetSegmentState: UInt { +public enum ProxiedContentAssetSegmentState: UInt { case waiting case downloading case complete @@ -80,7 +47,7 @@ enum ProxiedContentAssetSegmentState: UInt { // MARK: - -class ProxiedContentAssetSegment: NSObject { +public class ProxiedContentAssetSegment: NSObject { public let index: UInt public let segmentStart: UInt @@ -154,7 +121,7 @@ class ProxiedContentAssetSegment: NSObject { // MARK: - -enum ProxiedContentAssetRequestState: UInt { +public enum ProxiedContentAssetRequestState: UInt { // Does not yet have content length. case waiting // Getting content length. @@ -172,9 +139,10 @@ enum ProxiedContentAssetRequestState: UInt { // Represents a request to download an asset. // // Should be cancelled if no longer necessary. -@objc class ProxiedContentAssetRequest: NSObject { +@objc +public class ProxiedContentAssetRequest: NSObject { - let rendition: ProxiedContentRendition + let assetDescription: ProxiedContentAssetDescription let priority: ProxiedContentRequestPriority // Exactly one of success or failure should be called once, // on the main thread _unless_ this request is cancelled before @@ -200,11 +168,11 @@ enum ProxiedContentAssetRequestState: UInt { } public weak var contentLengthTask: URLSessionDataTask? - init(rendition: ProxiedContentRendition, + init(assetDescription: ProxiedContentAssetDescription, priority: ProxiedContentRequestPriority, success:@escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), failure:@escaping ((ProxiedContentAssetRequest) -> Void)) { - self.rendition = rendition + self.assetDescription = assetDescription self.priority = priority self.success = success self.failure = failure @@ -342,7 +310,7 @@ enum ProxiedContentAssetRequestState: UInt { return nil } - let fileExtension = rendition.fileExtension + let fileExtension = assetDescription.fileExtension let fileName = (NSUUID().uuidString as NSString).appendingPathExtension(fileExtension)! let filePath = (downloadFolderPath as NSString).appendingPathComponent(fileName) @@ -350,7 +318,7 @@ enum ProxiedContentAssetRequestState: UInt { do { try assetData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite) - let asset = ProxiedContentAsset(rendition: rendition, filePath: filePath) + let asset = ProxiedContentAsset(assetDescription: assetDescription, filePath: filePath) return asset } catch let error as NSError { owsFailDebug("file write failed: \(filePath), \(error)") @@ -406,14 +374,18 @@ enum ProxiedContentAssetRequestState: UInt { // The blob on disk is cleaned up when this instance is deallocated, // so consumers of this resource should retain a strong reference to // this instance as long as they are using the asset. -@objc class ProxiedContentAsset: NSObject { +@objc +public class ProxiedContentAsset: NSObject { + + @objc + public let assetDescription: ProxiedContentAssetDescription - let rendition: ProxiedContentRendition - let filePath: String + @objc + public let filePath: String - init(rendition: ProxiedContentRendition, + init(assetDescription: ProxiedContentAssetDescription, filePath: String) { - self.rendition = rendition + self.assetDescription = assetDescription self.filePath = filePath } @@ -458,18 +430,23 @@ extension URLSessionTask { // MARK: - -@objc class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate { +@objc +open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate { // MARK: - Properties - static let sharedInstance = ProxiedContentDownloader() + public static let defaultDownloader = ProxiedContentDownloader(downloadFolderName: "proxiedContent") - var downloadFolderPath = "" + private let downloadFolderName: String + + private var downloadFolderPath: String? // Force usage as a singleton - override private init() { + public required init(downloadFolderName: String) { AssertIsOnMainThread() + self.downloadFolderName = downloadFolderName + super.init() SwiftSingletons.register(self) @@ -510,15 +487,15 @@ extension URLSessionTask { // // The success callbacks may be called synchronously on cache hit, in // which case the ProxiedContentAssetRequest parameter will be nil. - public func requestAsset(rendition: ProxiedContentRendition, + public func requestAsset(assetDescription: ProxiedContentAssetDescription, priority: ProxiedContentRequestPriority, success:@escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), failure:@escaping ((ProxiedContentAssetRequest) -> Void)) -> ProxiedContentAssetRequest? { AssertIsOnMainThread() - if let asset = assetMap.get(key: rendition.url) { + if let asset = assetMap.get(key: assetDescription.url) { // Synchronous cache hit. - Logger.verbose("asset cache hit: \(rendition.url)") + Logger.verbose("asset cache hit: \(assetDescription.url)") success(nil, asset) return nil } @@ -526,8 +503,8 @@ extension URLSessionTask { // Cache miss. // // Asset requests are done queued and performed asynchronously. - Logger.verbose("asset cache miss: \(rendition.url)") - let assetRequest = ProxiedContentAssetRequest(rendition: rendition, + Logger.verbose("asset cache miss: \(assetDescription.url)") + let assetRequest = ProxiedContentAssetRequest(assetDescription: assetDescription, priority: priority, success: success, failure: failure) @@ -560,7 +537,11 @@ extension URLSessionTask { // Move write off main thread. DispatchQueue.global().async { - guard let asset = assetRequest.writeAssetToFile(downloadFolderPath: self.downloadFolderPath) else { + guard let downloadFolderPath = self.downloadFolderPath else { + owsFailDebug("Missing downloadFolderPath") + return + } + guard let asset = assetRequest.writeAssetToFile(downloadFolderPath: downloadFolderPath) else { self.segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment) return } @@ -575,7 +556,7 @@ extension URLSessionTask { private func assetRequestDidSucceed(assetRequest: ProxiedContentAssetRequest, asset: ProxiedContentAsset) { DispatchQueue.main.async { - self.assetMap.set(key: assetRequest.rendition.url, value: asset) + self.assetMap.set(key: assetRequest.assetDescription.url, value: asset) self.removeAssetRequestFromQueue(assetRequest: assetRequest) assetRequest.requestDidSucceed(asset: asset) } @@ -603,7 +584,7 @@ extension URLSessionTask { AssertIsOnMainThread() guard assetRequestQueue.contains(assetRequest) else { - Logger.warn("could not remove asset request from queue: \(assetRequest.rendition.url)") + Logger.warn("could not remove asset request from queue: \(assetRequest.assetDescription.url)") return } @@ -641,7 +622,7 @@ extension URLSessionTask { return } - if let asset = assetMap.get(key: assetRequest.rendition.url) { + if let asset = assetMap.get(key: assetRequest.assetDescription.url) { // Deferred cache hit, avoids re-downloading assets that were // downloaded while this request was queued. @@ -655,7 +636,7 @@ extension URLSessionTask { // try to do so now. assetRequest.state = .requestingSize - var request = URLRequest(url: assetRequest.rendition.url as URL) + var request = URLRequest(url: assetRequest.assetDescription.url as URL) request.httpMethod = "HEAD" request.httpShouldUsePipelining = true @@ -676,7 +657,7 @@ extension URLSessionTask { } assetSegment.state = .downloading - var request = URLRequest(url: assetRequest.rendition.url as URL) + var request = URLRequest(url: assetRequest.assetDescription.url as URL) request.httpShouldUsePipelining = true let rangeHeaderValue = "bytes=\(assetSegment.segmentStart)-\(assetSegment.segmentStart + assetSegment.segmentLength - 1)" request.addValue(rangeHeaderValue, forHTTPHeaderField: "Range") @@ -850,7 +831,7 @@ extension URLSessionTask { // We try to eagerly clean up these assets when they are no longer in use. let tempDirPath = OWSTemporaryDirectory() - let dirPath = (tempDirPath as NSString).appendingPathComponent("GIFs") + let dirPath = (tempDirPath as NSString).appendingPathComponent(downloadFolderName) do { let fileManager = FileManager.default