diff --git a/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift b/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift index 07da5294d..c9a3f9664 100644 --- a/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift +++ b/Signal/src/ViewControllers/GifPicker/GifPickerViewController.swift @@ -148,6 +148,10 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect view.backgroundColor = UIColor.white + // Block UIKit from adjust insets of collection view which screws up + // min/max scroll positions. + self.automaticallyAdjustsScrollViewInsets = false + // Search searchBar.searchBarStyle = .minimal searchBar.delegate = self diff --git a/Signal/src/network/GiphyDownloader.swift b/Signal/src/network/GiphyDownloader.swift index 2a624eb07..a252d2b6e 100644 --- a/Signal/src/network/GiphyDownloader.swift +++ b/Signal/src/network/GiphyDownloader.swift @@ -10,11 +10,106 @@ enum GiphyRequestPriority { case low, high } +enum GiphyAssetSegmentState: UInt { + case waiting + case downloading + case complete + case failed +} + +class GiphyAssetSegment { + let TAG = "[GiphyAssetSegment]" + + 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 { + owsFail("\(TAG) appending data in invalid state: \(state)") + return + } + + segmentData.append(data) + } + + public func mergeData(assetData: inout Data) -> Bool { + guard state == .complete else { + owsFail("\(TAG) merging data in invalid state: \(state)") + return false + } + guard UInt(segmentData.count) == segmentLength else { + owsFail("\(TAG) 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 range = NSMakeRange(bytesToIgnore, segmentData.count - bytesToIgnore) + let subdata = segmentData.subdata(in: range.location.. 0) + + createSegments() + } + } + public weak var contentLengthTask: URLSessionDataTask? + init(rendition: GiphyRendition, priority: GiphyRequestPriority, success:@escaping ((GiphyAssetRequest?, GiphyAsset) -> Void), - failure:@escaping ((GiphyAssetRequest) -> 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 { + owsFail("\(TAG) 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 { + owsFail("\(TAG) 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 { + owsFail("\(TAG) 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 { + owsFail("\(TAG) unexpected incomplete segment.") + return nil + } + guard segment.totalDataSize() > 0 else { + owsFail("\(TAG) could not merge empty segment.") + return nil + } + guard segment.mergeData(assetData: &assetData) else { + owsFail("\(TAG) failed to merge segment data.") + return nil + } + } + + guard assetData.count == contentLength else { + owsFail("\(TAG) asset data has unexpected length.") + return nil + } + + guard assetData.count > 0 else { + owsFail("\(TAG) 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("\(TAG) 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 { + owsFail("\(GiphyAsset.TAG) 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() @@ -147,6 +409,7 @@ class LRUCache { } 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 { @@ -158,9 +421,17 @@ extension URLSessionTask { 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, URLSessionDownloadDelegate { +@objc class GiphyDownloader: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate { // MARK: - Properties @@ -168,11 +439,16 @@ extension URLSessionTask { static let sharedInstance = GiphyDownloader() - // A private queue used for download task callbacks. - private let operationQueue = OperationQueue() + var gifFolderPath = "" // Force usage as a singleton - override private init() {} + override private init() { + AssertIsOnMainThread() + + super.init() + + ensureGifFolder() + } deinit { NotificationCenter.default.removeObserver(self) @@ -180,14 +456,18 @@ extension URLSessionTask { private let kGiphyBaseURL = "https://api.giphy.com/" - private func giphyDownloadSession() -> URLSession? { + private lazy var giphyDownloadSession: URLSession = { + AssertIsOnMainThread() + let configuration = GiphyAPI.giphySessionConfiguration() configuration.urlCache = nil configuration.requestCachePolicy = .reloadIgnoringCacheData + configuration.httpMaximumConnectionsPerHost = 10 let session = URLSession(configuration:configuration, - delegate:self, delegateQueue:operationQueue) + 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 @@ -200,12 +480,10 @@ extension URLSessionTask { // TODO: We could use a proper queue, e.g. implemented with a linked // list. private var assetRequestQueue = [GiphyAssetRequest]() - private let kMaxAssetRequestCount = 3 - private var activeAssetRequests = Set() // The success and failure callbacks are always called on main queue. // - // The success callbacks may be called synchronously on cache hit, in + // 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, @@ -215,6 +493,7 @@ extension URLSessionTask { if let asset = assetMap.get(key:rendition.url) { // Synchronous cache hit. + Logger.verbose("\(self.TAG) asset cache hit: \(rendition.url)") success(nil, asset) return nil } @@ -222,98 +501,253 @@ extension URLSessionTask { // Cache miss. // // Asset requests are done queued and performed asynchronously. + Logger.verbose("\(self.TAG) asset cache miss: \(rendition.url)") let assetRequest = GiphyAssetRequest(rendition:rendition, priority:priority, success:success, failure:failure) assetRequestQueue.append(assetRequest) - startRequestIfNecessary() + // 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. + DispatchQueue.main.async { + self.processRequestQueue() + } return assetRequest } public func cancelAllRequests() { + AssertIsOnMainThread() + + Logger.verbose("\(self.TAG) 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.processRequestQueue() + } + } } private func assetRequestDidSucceed(assetRequest: GiphyAssetRequest, asset: GiphyAsset) { + DispatchQueue.main.async { self.assetMap.set(key:assetRequest.rendition.url, value:asset) - self.activeAssetRequests.remove(assetRequest) + self.removeAssetRequestFromQueue(assetRequest:assetRequest) assetRequest.requestDidSucceed(asset:asset) - self.startRequestIfNecessary() + } + } + + // 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.activeAssetRequests.remove(assetRequest) + self.removeAssetRequestFromQueue(assetRequest:assetRequest) assetRequest.requestDidFail() - self.startRequestIfNecessary() } } - private func startRequestIfNecessary() { + private func removeAssetRequestFromQueue(assetRequest: GiphyAssetRequest) { AssertIsOnMainThread() - DispatchQueue.main.async { - guard self.activeAssetRequests.count < self.kMaxAssetRequestCount else { - return - } - guard let assetRequest = self.popNextAssetRequest() else { - return - } - guard !assetRequest.wasCancelled else { - // Discard the cancelled asset request and try again. - self.startRequestIfNecessary() - return - } - guard UIApplication.shared.applicationState == .active else { - // If app is not active, fail the asset request. - self.assetRequestDidFail(assetRequest:assetRequest) - self.startRequestIfNecessary() - return - } + guard assetRequestQueue.contains(assetRequest) else { + Logger.warn("\(TAG) could not remove asset request from queue: \(assetRequest.rendition.url)") + return + } - self.activeAssetRequests.insert(assetRequest) + assetRequestQueue = assetRequestQueue.filter { $0 != assetRequest } + processRequestQueue() + } - if let asset = self.assetMap.get(key:assetRequest.rendition.url) { - // Deferred cache hit, avoids re-downloading assets that were - // downloaded while this request was queued. + // * Start a segment request or content length request if possible. + // * Complete/cancel asset requests if possible. + // + private func processRequestQueue() { + AssertIsOnMainThread() - self.assetRequestDidSucceed(assetRequest : assetRequest, asset: asset) - return - } + 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) + processRequestQueue() + 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 - guard let downloadSession = self.giphyDownloadSession() else { - owsFail("\(self.TAG) Couldn't create session manager.") - self.assetRequestDidFail(assetRequest:assetRequest) + let task = giphyDownloadSession.dataTask(with:request, completionHandler: { [weak self] data, response, error -> Void in + if let data = data, data.count > 0 { + owsFail("\(self?.TAG) 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 { + owsFail("\(TAG) queued asset request does not have a waiting segment.") return } + assetSegment.state = .downloading - // Start a download task. - let task = downloadSession.downloadTask(with:assetRequest.rendition.url as URL) + 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 = giphyDownloadSession.dataTask(with:request) task.assetRequest = assetRequest + task.assetSegment = assetSegment + assetSegment.task = task task.resume() } + + // Recurse; we may be able to start multiple downloads. + processRequestQueue() } + 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 { + owsFail("\(self.TAG) Asset size response is invalid.") + assetRequest.state = .failed + self.assetRequestDidFail(assetRequest:assetRequest) + return + } + guard let contentLengthString = httpResponse.allHeaderFields["Content-Length"] as? String else { + owsFail("\(self.TAG) Asset size response is missing content length.") + assetRequest.state = .failed + self.assetRequestDidFail(assetRequest:assetRequest) + return + } + guard let contentLength = Int(contentLengthString) else { + owsFail("\(self.TAG) Asset size response has unparsable content length.") + assetRequest.state = .failed + self.assetRequestDidFail(assetRequest:assetRequest) + return + } + guard contentLength > 0 else { + owsFail("\(self.TAG) 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.processRequestQueue() + } + } + + // 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() - var activeAssetRequestURLs = Set() - for assetRequest in activeAssetRequests { - activeAssetRequestURLs.insert(assetRequest.rendition.url) - } + 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 (assetRequestIndex, assetRequest) in assetRequestQueue.enumerated() where assetRequest.priority == priority { - guard !activeAssetRequestURLs.contains(assetRequest.rendition.url) else { - // Defer requests if there is already an active asset request with the same URL. + 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 } - assetRequestQueue.remove(at:assetRequestIndex) return assetRequest } } @@ -329,105 +763,83 @@ extension URLSessionTask { 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?) -> Swift.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() - assetRequestDidFail(assetRequest:assetRequest) + segmentRequestDidFail(assetRequest:assetRequest, assetSegment:assetSegment) return } if let error = error { Logger.error("\(TAG) download failed with error: \(error)") - assetRequestDidFail(assetRequest:assetRequest) + segmentRequestDidFail(assetRequest:assetRequest, assetSegment:assetSegment) return } guard let httpResponse = task.response as? HTTPURLResponse else { Logger.error("\(TAG) missing or unexpected response: \(task.response)") - assetRequestDidFail(assetRequest:assetRequest) + segmentRequestDidFail(assetRequest:assetRequest, assetSegment:assetSegment) return } let statusCode = httpResponse.statusCode guard statusCode >= 200 && statusCode < 400 else { Logger.error("\(TAG) response has invalid status code: \(statusCode)") - assetRequestDidFail(assetRequest:assetRequest) + segmentRequestDidFail(assetRequest:assetRequest, assetSegment:assetSegment) return } - guard let assetFilePath = assetRequest.assetFilePath else { - Logger.error("\(TAG) task is missing asset file") - assetRequestDidFail(assetRequest:assetRequest) + guard assetSegment.totalDataSize() == assetSegment.segmentLength else { + Logger.error("\(TAG) segment is missing data: \(statusCode)") + segmentRequestDidFail(assetRequest:assetRequest, assetSegment:assetSegment) return } - let asset = GiphyAsset(rendition: assetRequest.rendition, filePath : assetFilePath) - assetRequestDidSucceed(assetRequest : assetRequest, asset: asset) - } - // MARK: URLSessionDownloadDelegate + segmentRequestDidSucceed(assetRequest : assetRequest, assetSegment: assetSegment) + } - public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - let assetRequest = downloadTask.assetRequest - guard !assetRequest.wasCancelled else { - downloadTask.cancel() - assetRequestDidFail(assetRequest:assetRequest) - return - } + // 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 dirPath = NSTemporaryDirectory() - let fileExtension = assetRequest.rendition.fileExtension - let fileName = (NSUUID().uuidString as NSString).appendingPathExtension(fileExtension)! - let filePath = (dirPath as NSString).appendingPathComponent(fileName) + let tempDirPath = NSTemporaryDirectory() + let dirPath = (tempDirPath as NSString).appendingPathComponent("GIFs") do { - try FileManager.default.moveItem(at: location, to: URL(fileURLWithPath:filePath)) - assetRequest.assetFilePath = filePath - } catch let error as NSError { - owsFail("\(GiphyAsset.TAG) file move failed from: \(location), to: \(filePath), \(error)") - } - } - - var animatedDataCount = [URLSessionDownloadTask: Int64]() - var stillDataCount = [URLSessionDownloadTask: Int64]() - var totalDataCount = [URLSessionDownloadTask: Int64]() - public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - - // Log accumulated data usage in debug - if _isDebugAssertConfiguration() { - let assetRequest = downloadTask.assetRequest + let fileManager = FileManager.default - totalDataCount[downloadTask] = totalBytesWritten - if assetRequest.rendition.isStill { - stillDataCount[downloadTask] = totalBytesWritten - } else { - animatedDataCount[downloadTask] = totalBytesWritten + // Try to delete existing folder if necessary. + if fileManager.fileExists(atPath:dirPath) { + try fileManager.removeItem(atPath:dirPath) + gifFolderPath = dirPath } - - let megabyteCount = { (dataCountMap: [URLSessionDownloadTask: Int64]) -> String in - let sum = dataCountMap.values.reduce(0, +) - let megabyteCount = Float(sum) / 1000 / 1000 - return String(format: "%06.2f MB", megabyteCount) + // Try to create folder if necessary. + if !fileManager.fileExists(atPath:dirPath) { + try fileManager.createDirectory(atPath:dirPath, + withIntermediateDirectories:true, + attributes:nil) + gifFolderPath = dirPath } - Logger.info("\(TAG) Still bytes written: \(megabyteCount(stillDataCount))") - Logger.info("\(TAG) Animated bytes written: \(megabyteCount(animatedDataCount))") - Logger.info("\(TAG) Total bytes written: \(megabyteCount(totalDataCount))") - } - - let assetRequest = downloadTask.assetRequest - guard !assetRequest.wasCancelled else { - downloadTask.cancel() - assetRequestDidFail(assetRequest:assetRequest) - return - } - } - - public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) { - let assetRequest = downloadTask.assetRequest - guard !assetRequest.wasCancelled else { - downloadTask.cancel() - assetRequestDidFail(assetRequest:assetRequest) - return + } catch let error as NSError { + owsFail("\(GiphyAsset.TAG) ensureTempFolder failed: \(dirPath), \(error)") + gifFolderPath = tempDirPath } } }