From 4e7dbc486d5b1bfbe683215038c460a4dd22dc86 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Wed, 23 Jan 2019 13:30:55 -0500 Subject: [PATCH] Segment proxied content downloads. --- Signal.xcodeproj/project.pbxproj | 4 - .../Network/ProxiedContentDownloader.swift | 877 ++++++++++++++++++ .../src/Util}/LRUCache.swift | 2 +- 3 files changed, 878 insertions(+), 5 deletions(-) create mode 100644 SignalServiceKit/src/Network/ProxiedContentDownloader.swift rename {SignalMessaging/utils => SignalServiceKit/src/Util}/LRUCache.swift (97%) diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index df66288fe..0b624001c 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -337,7 +337,6 @@ 452037D11EE84975004E4CDF /* DebugUISessionState.m in Sources */ = {isa = PBXBuildFile; fileRef = 452037D01EE84975004E4CDF /* DebugUISessionState.m */; }; 4520D8D51D417D8E00123472 /* Photos.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4520D8D41D417D8E00123472 /* Photos.framework */; }; 4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */; }; - 4523D016206EDC2B00A2AB51 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4523D015206EDC2B00A2AB51 /* LRUCache.swift */; }; 452B999020A34B6B006F2F9E /* AddContactShareToExistingContactViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452B998F20A34B6B006F2F9E /* AddContactShareToExistingContactViewController.swift */; }; 452C468F1E427E200087B011 /* OutboundCallInitiator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452C468E1E427E200087B011 /* OutboundCallInitiator.swift */; }; 452C7CA72037628B003D51A5 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45F170D51E315310003FC1F2 /* Weak.swift */; }; @@ -1035,7 +1034,6 @@ 4520D8D41D417D8E00123472 /* Photos.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Photos.framework; path = System/Library/Frameworks/Photos.framework; sourceTree = SDKROOT; }; 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldHelper.swift; sourceTree = ""; }; 4523149F1F7E9E18003A428C /* DirectionalPanGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DirectionalPanGestureRecognizer.swift; sourceTree = ""; }; - 4523D015206EDC2B00A2AB51 /* LRUCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; 452B998F20A34B6B006F2F9E /* AddContactShareToExistingContactViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactShareToExistingContactViewController.swift; sourceTree = ""; }; 452C468E1E427E200087B011 /* OutboundCallInitiator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutboundCallInitiator.swift; sourceTree = ""; }; 452D1AF02081059C00A67F7F /* StringAdditionsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringAdditionsTest.swift; sourceTree = ""; }; @@ -1554,7 +1552,6 @@ 348F2EAD1F0D21BC00D4ECE0 /* DeviceSleepManager.swift */, 344F248C2007CCD600CFB4F4 /* DisplayableText.swift */, 346129AC1FD1F34E00532771 /* ImageCache.swift */, - 4523D015206EDC2B00A2AB51 /* LRUCache.swift */, 34BEDB1421C80BC9007B0EAE /* OWSAnyTouchGestureRecognizer.h */, 34BEDB1521C80BCA007B0EAE /* OWSAnyTouchGestureRecognizer.m */, 34C3C7902040B0DC0000134C /* OWSAudioPlayer.h */, @@ -3305,7 +3302,6 @@ 34AC09EE211B39B100997B47 /* EditContactShareNameViewController.swift in Sources */, 346129F71FD5F31400532771 /* OWS105AttachmentFilePaths.m in Sources */, 45194F931FD7215C00333B2C /* OWSContactOffersInteraction.m in Sources */, - 4523D016206EDC2B00A2AB51 /* LRUCache.swift in Sources */, 450998681FD8C0FF00D89EB3 /* AttachmentSharing.m in Sources */, 347850711FDAEB17007B8332 /* OWSUserProfile.m in Sources */, 34BEDB1921C82AC5007B0EAE /* ImageEditorGestureRecognizer.swift in Sources */, diff --git a/SignalServiceKit/src/Network/ProxiedContentDownloader.swift b/SignalServiceKit/src/Network/ProxiedContentDownloader.swift new file mode 100644 index 000000000..671f7c49c --- /dev/null +++ b/SignalServiceKit/src/Network/ProxiedContentDownloader.swift @@ -0,0 +1,877 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import Foundation +import ObjectiveC + +// Stills should be loaded before full GIFs. +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)") +// } +} + +// MARK: - + +enum ProxiedContentAssetSegmentState: UInt { + case waiting + case downloading + case complete + case failed +} + +// MARK: - + +class ProxiedContentAssetSegment: 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: ProxiedContentAssetSegmentState = .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: ((ProxiedContentAssetRequest) -> 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 = [ProxiedContentAssetSegment]() + public var state: ProxiedContentAssetRequestState = .waiting + public var contentLength: Int = 0 { + didSet { + AssertIsOnMainThread() + assert(oldValue == 0) + assert(contentLength > 0) + + createSegments() + } + } + public weak var contentLengthTask: URLSessionDataTask? + + init(rendition: ProxiedContentRendition, + priority: ProxiedContentRequestPriority, + success:@escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), + failure:@escaping ((ProxiedContentAssetRequest) -> 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("asset 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 = ProxiedContentAssetSegment(index: index, + segmentStart: segmentStart, + segmentLength: segmentLength, + redundantLength: redundantLength) + segments.append(assetSegment) + nextSegmentStart = segmentStart + segmentLength + index += 1 + } + } + + private func firstSegmentWithState(state: ProxiedContentAssetSegmentState) -> ProxiedContentAssetSegment? { + 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() -> ProxiedContentAssetSegment? { + 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(downloadFolderPath: String) -> ProxiedContentAsset? { + + 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 = (downloadFolderPath as NSString).appendingPathComponent(fileName) + + Logger.verbose("filePath: \(filePath).") + + do { + try assetData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite) + let asset = ProxiedContentAsset(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: ProxiedContentAsset) { + 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() + } +} + +// MARK: - + +// Represents a downloaded 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 ProxiedContentAsset: NSObject { + + let rendition: ProxiedContentRendition + let filePath: String + + init(rendition: ProxiedContentRendition, + 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)") + } + } + } +} + +// MARK: - + +private var URLSessionTaskProxiedContentAssetRequest: UInt8 = 0 +private var URLSessionTaskProxiedContentAssetSegment: UInt8 = 0 + +// This extension is used to punch an asset request onto a download task. +extension URLSessionTask { + var assetRequest: ProxiedContentAssetRequest { + get { + return objc_getAssociatedObject(self, &URLSessionTaskProxiedContentAssetRequest) as! ProxiedContentAssetRequest + } + set { + objc_setAssociatedObject(self, &URLSessionTaskProxiedContentAssetRequest, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + var assetSegment: ProxiedContentAssetSegment { + get { + return objc_getAssociatedObject(self, &URLSessionTaskProxiedContentAssetSegment) as! ProxiedContentAssetSegment + } + set { + objc_setAssociatedObject(self, &URLSessionTaskProxiedContentAssetSegment, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +// MARK: - + +@objc class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate { + + // MARK: - Properties + + static let sharedInstance = ProxiedContentDownloader() + + var downloadFolderPath = "" + + // Force usage as a singleton + override private init() { + AssertIsOnMainThread() + + super.init() + + SwiftSingletons.register(self) + + ensureDownloadFolder() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private lazy var downloadSession: 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 = [ProxiedContentAssetRequest]() + + // 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 ProxiedContentAssetRequest parameter will be nil. + public func requestAsset(rendition: ProxiedContentRendition, + priority: ProxiedContentRequestPriority, + success:@escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), + failure:@escaping ((ProxiedContentAssetRequest) -> Void)) -> ProxiedContentAssetRequest? { + 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 = ProxiedContentAssetRequest(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: ProxiedContentAssetRequest, assetSegment: ProxiedContentAssetSegment) { + 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(downloadFolderPath: self.downloadFolderPath) else { + self.segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment) + return + } + self.assetRequestDidSucceed(assetRequest: assetRequest, asset: asset) + } + } else { + self.processRequestQueueSync() + } + } + } + + private func assetRequestDidSucceed(assetRequest: ProxiedContentAssetRequest, asset: ProxiedContentAsset) { + + 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: ProxiedContentAssetRequest, assetSegment: ProxiedContentAssetSegment) { + DispatchQueue.main.async { + assetSegment.state = .failed + assetRequest.state = .failed + self.assetRequestDidFail(assetRequest: assetRequest) + } + } + + private func assetRequestDidFail(assetRequest: ProxiedContentAssetRequest) { + + DispatchQueue.main.async { + self.removeAssetRequestFromQueue(assetRequest: assetRequest) + assetRequest.requestDidFail() + } + } + + private func removeAssetRequestFromQueue(assetRequest: ProxiedContentAssetRequest) { + 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 CurrentAppContext().isMainAppAndActive 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 = downloadSession.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 = downloadSession.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: ProxiedContentAssetRequest, 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() -> ProxiedContentAssetRequest? { + 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 [ProxiedContentRequestPriority.high, ProxiedContentRequestPriority.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 ensureDownloadFolder() { + // 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) + downloadFolderPath = dirPath + } + // Try to create folder if necessary. + if !fileManager.fileExists(atPath: dirPath) { + try fileManager.createDirectory(atPath: dirPath, + withIntermediateDirectories: true, + attributes: nil) + downloadFolderPath = dirPath + } + + // Don't back up ProxiedContent downloads. + OWSFileSystem.protectFileOrFolder(atPath: dirPath) + } catch let error as NSError { + owsFailDebug("ensureTempFolder failed: \(dirPath), \(error)") + downloadFolderPath = tempDirPath + } + } +} diff --git a/SignalMessaging/utils/LRUCache.swift b/SignalServiceKit/src/Util/LRUCache.swift similarity index 97% rename from SignalMessaging/utils/LRUCache.swift rename to SignalServiceKit/src/Util/LRUCache.swift index c57a70177..780f07142 100644 --- a/SignalMessaging/utils/LRUCache.swift +++ b/SignalServiceKit/src/Util/LRUCache.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // @objc