diff --git a/Pods b/Pods index 14052188a..db7ec4663 160000 --- a/Pods +++ b/Pods @@ -1 +1 @@ -Subproject commit 14052188a536b7a26e2398e39708e359e83b3ce2 +Subproject commit db7ec4663cdd5d9615f182dcc51731f82df4b297 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/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..69a6c958f 100644 --- a/Signal/src/network/GiphyDownloader.swift +++ b/Signal/src/network/GiphyDownloader.swift @@ -3,806 +3,13 @@ // 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 +public 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 - } - } + @objc + public static let giphyDownloader = GiphyDownloader(downloadFolderName: "GIFs") } diff --git a/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift b/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift index b73725c86..29722b73a 100644 --- a/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift +++ b/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift @@ -345,7 +345,7 @@ public class OWSLinkPreview: MTLModel { return false } return whitelistedDomain(forUrl: url, - domainWhitelist: OWSLinkPreview.linkDomainWhitelist + OWSLinkPreview.mediaDomainWhitelist) != nil + domainWhitelist: OWSLinkPreview.linkDomainWhitelist + OWSLinkPreview.mediaDomainWhitelist) != nil } private class func whitelistedDomain(forUrl url: URL, domainWhitelist: [String]) -> String? { @@ -462,7 +462,7 @@ public class OWSLinkPreview: MTLModel { completion(cachedInfo) return } - downloadContents(ofUrl: previewUrl, completion: { (data) in + downloadLink(url: previewUrl, completion: { (data) in DispatchQueue.global().async { guard let data = data else { completion(nil) @@ -488,9 +488,9 @@ public class OWSLinkPreview: MTLModel { } } - private class func downloadContents(ofUrl url: String, - completion: @escaping (Data?) -> Void, - remainingRetries: UInt = 3) { + private class func downloadLink(url: String, + completion: @escaping (Data?) -> Void, + remainingRetries: UInt = 3) { Logger.verbose("url: \(url)") @@ -536,9 +536,46 @@ public class OWSLinkPreview: MTLModel { completion(nil) return } - OWSLinkPreview.downloadContents(ofUrl: url, completion: completion, remainingRetries: remainingRetries - 1) + OWSLinkPreview.downloadLink(url: url, completion: completion, remainingRetries: remainingRetries - 1) }) + } + + private class func downloadImage(url urlString: String, + completion: @escaping (Data?) -> Void) { + + Logger.verbose("url: \(urlString)") + guard let url = URL(string: urlString) else { + Logger.error("Could not parse URL.") + return completion(nil) + } + + guard let assetDescription = ProxiedContentAssetDescription(url: url as NSURL) else { + Logger.error("Could not create asset description.") + return completion(nil) + } + DispatchQueue.main.async { + _ = ProxiedContentDownloader.defaultDownloader.requestAsset(assetDescription: assetDescription, + priority: .high, + success: { (_, asset) in + DispatchQueue.global().async { + do { + let data = try Data(contentsOf: URL(fileURLWithPath: asset.filePath)) + completion(data) + } catch { + owsFailDebug("Could not load asset data: \(type(of: asset.filePath)).") + completion(nil) + } + } + + }, failure: { (_) in + DispatchQueue.global().async { + Logger.verbose("Error downloading asset") + + completion(nil) + } + }) + } } private class func isRetryable(error: Error) -> Bool { @@ -599,38 +636,38 @@ public class OWSLinkPreview: MTLModel { let kValidMimeTypes = [ OWSMimeTypeImagePng, OWSMimeTypeImageJpeg - ] + ] guard kValidMimeTypes.contains(imageMimeType) else { Logger.error("Image URL has invalid content type: \(imageMimeType).") return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) } - downloadContents(ofUrl: imageUrlString, - completion: { (imageData) in - guard let imageData = imageData else { - Logger.error("Could not download image.") - return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) - } - let imageFilePath = OWSFileSystem.temporaryFilePath(withFileExtension: imageFileExtension) - do { - try imageData.write(to: NSURL.fileURL(withPath: imageFilePath), options: .atomicWrite) - } catch let error as NSError { - owsFailDebug("file write failed: \(imageFilePath), \(error)") + downloadImage(url: imageUrlString, + completion: { (imageData) in + guard let imageData = imageData else { + Logger.error("Could not download image.") + return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) + } + let imageFilePath = OWSFileSystem.temporaryFilePath(withFileExtension: imageFileExtension) + do { + try imageData.write(to: NSURL.fileURL(withPath: imageFilePath), options: .atomicWrite) + } catch let error as NSError { + owsFailDebug("file write failed: \(imageFilePath), \(error)") + return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) + } + // NOTE: imageSize(forFilePath:...) will call ows_isValidImage(...). + let imageSize = NSData.imageSize(forFilePath: imageFilePath, mimeType: imageMimeType) + let kMaxImageSize: CGFloat = 2048 + guard imageSize.width > 0, + imageSize.height > 0, + imageSize.width < kMaxImageSize, + imageSize.height < kMaxImageSize else { + Logger.error("Image has invalid size: \(imageSize).") return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) - } - // NOTE: imageSize(forFilePath:...) will call ows_isValidImage(...). - let imageSize = NSData.imageSize(forFilePath: imageFilePath, mimeType: imageMimeType) - let kMaxImageSize: CGFloat = 2048 - guard imageSize.width > 0, - imageSize.height > 0, - imageSize.width < kMaxImageSize, - imageSize.height < kMaxImageSize else { - Logger.error("Image has invalid size: \(imageSize).") - return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) - } + } - let linkPreviewDraft = OWSLinkPreviewDraft(urlString: linkUrlString, title: title, imageFilePath: imageFilePath) - completion(linkPreviewDraft) + let linkPreviewDraft = OWSLinkPreviewDraft(urlString: linkUrlString, title: title, imageFilePath: imageFilePath) + completion(linkPreviewDraft) }) } diff --git a/SignalServiceKit/src/Network/ProxiedContentDownloader.swift b/SignalServiceKit/src/Network/ProxiedContentDownloader.swift new file mode 100644 index 000000000..1dbc3a79c --- /dev/null +++ b/SignalServiceKit/src/Network/ProxiedContentDownloader.swift @@ -0,0 +1,862 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import Foundation +import ObjectiveC + +// Stills should be loaded before full GIFs. +public enum ProxiedContentRequestPriority { + case low, high +} + +// MARK: - + +@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: - + +public enum ProxiedContentAssetSegmentState: UInt { + case waiting + case downloading + case complete + case failed +} + +// MARK: - + +public 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(assetDescription: ProxiedContentAssetDescription, + priority: ProxiedContentRequestPriority, + success:@escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), + failure:@escaping ((ProxiedContentAssetRequest) -> Void)) { + self.assetDescription = assetDescription + 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 = assetDescription.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(assetDescription: assetDescription, 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 +public class ProxiedContentAsset: NSObject { + + @objc + public let assetDescription: ProxiedContentAssetDescription + + @objc + public let filePath: String + + init(assetDescription: ProxiedContentAssetDescription, + filePath: String) { + self.assetDescription = assetDescription + 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 +open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate { + + // MARK: - Properties + + @objc + public static let defaultDownloader = ProxiedContentDownloader(downloadFolderName: "proxiedContent") + + private let downloadFolderName: String + + private var downloadFolderPath: String? + + // Force usage as a singleton + public required init(downloadFolderName: String) { + AssertIsOnMainThread() + + self.downloadFolderName = downloadFolderName + + super.init() + + SwiftSingletons.register(self) + + ensureDownloadFolder() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private lazy var downloadSession: URLSession = { + AssertIsOnMainThread() + + let configuration = ContentProxy.sessionConfiguration() + + // Don't use any caching to protect privacy of these requests. + 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(assetDescription: ProxiedContentAssetDescription, + priority: ProxiedContentRequestPriority, + success:@escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), + failure:@escaping ((ProxiedContentAssetRequest) -> Void)) -> ProxiedContentAssetRequest? { + AssertIsOnMainThread() + + if let asset = assetMap.get(key: assetDescription.url) { + // Synchronous cache hit. + Logger.verbose("asset cache hit: \(assetDescription.url)") + success(nil, asset) + return nil + } + + // Cache miss. + // + // Asset requests are done queued and performed asynchronously. + Logger.verbose("asset cache miss: \(assetDescription.url)") + let assetRequest = ProxiedContentAssetRequest(assetDescription: assetDescription, + 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 downloadFolderPath = self.downloadFolderPath else { + owsFailDebug("Missing downloadFolderPath") + return + } + guard let asset = assetRequest.writeAssetToFile(downloadFolderPath: 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.assetDescription.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.assetDescription.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.assetDescription.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.assetDescription.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.assetDescription.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(downloadFolderName) + 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