Segment proxied content downloads.

pull/1/head
Matthew Chen 6 years ago
parent 4e7dbc486d
commit db15ff9a26

@ -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<GiphyAsset> {
public func requestRenditionForSending() -> Promise<ProxiedContentAsset> {
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<GiphyAsset>.pending()
let (promise, resolver) = Promise<ProxiedContentAsset>.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
}
}

@ -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.

@ -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"

@ -3,806 +3,11 @@
//
import Foundation
import ObjectiveC
import SignalServiceKit
import SignalMessaging
// Stills should be loaded before full GIFs.
enum GiphyRequestPriority {
case low, high
}
enum GiphyAssetSegmentState: UInt {
case waiting
case downloading
case complete
case failed
}
class GiphyAssetSegment: NSObject {
public let index: UInt
public let segmentStart: UInt
public let segmentLength: UInt
// The amount of the segment that is overlap.
// The overlap lies in the _first_ n bytes of the segment data.
public let redundantLength: UInt
// This state should only be accessed on the main thread.
public var state: GiphyAssetSegmentState = .waiting {
didSet {
AssertIsOnMainThread()
}
}
// This state is accessed off the main thread.
//
// * During downloads it will be accessed on the task delegate queue.
// * After downloads it will be accessed on a worker queue.
private var segmentData = Data()
// This state should only be accessed on the main thread.
public weak var task: URLSessionDataTask?
init(index: UInt,
segmentStart: UInt,
segmentLength: UInt,
redundantLength: UInt) {
self.index = index
self.segmentStart = segmentStart
self.segmentLength = segmentLength
self.redundantLength = redundantLength
}
public func totalDataSize() -> UInt {
return UInt(segmentData.count)
}
public func append(data: Data) {
guard state == .downloading else {
owsFailDebug("appending data in invalid state: \(state)")
return
}
segmentData.append(data)
}
public func mergeData(assetData: inout Data) -> Bool {
guard state == .complete else {
owsFailDebug("merging data in invalid state: \(state)")
return false
}
guard UInt(segmentData.count) == segmentLength else {
owsFailDebug("segment data length: \(segmentData.count) doesn't match expected length: \(segmentLength)")
return false
}
// In some cases the last two segments will overlap.
// In that case, we only want to append the non-overlapping
// tail of the segment data.
let bytesToIgnore = Int(redundantLength)
if bytesToIgnore > 0 {
let subdata = segmentData.subdata(in: bytesToIgnore..<Int(segmentLength))
assetData.append(subdata)
} else {
assetData.append(segmentData)
}
return true
}
}
enum GiphyAssetRequestState: UInt {
// Does not yet have content length.
case waiting
// Getting content length.
case requestingSize
// Has content length, ready for downloads or downloads in flight.
case active
// Success
case complete
// Failure
case failed
}
// Represents a request to download a GIF.
//
// Should be cancelled if no longer necessary.
@objc class GiphyAssetRequest: NSObject {
let rendition: GiphyRendition
let priority: GiphyRequestPriority
// Exactly one of success or failure should be called once,
// on the main thread _unless_ this request is cancelled before
// the request succeeds or fails.
private var success: ((GiphyAssetRequest?, GiphyAsset) -> Void)?
private var failure: ((GiphyAssetRequest) -> Void)?
var wasCancelled = false
// This property is an internal implementation detail of the download process.
var assetFilePath: String?
// This state should only be accessed on the main thread.
private var segments = [GiphyAssetSegment]()
public var state: GiphyAssetRequestState = .waiting
public var contentLength: Int = 0 {
didSet {
AssertIsOnMainThread()
assert(oldValue == 0)
assert(contentLength > 0)
createSegments()
}
}
public weak var contentLengthTask: URLSessionDataTask?
init(rendition: GiphyRendition,
priority: GiphyRequestPriority,
success:@escaping ((GiphyAssetRequest?, GiphyAsset) -> Void),
failure:@escaping ((GiphyAssetRequest) -> Void)) {
self.rendition = rendition
self.priority = priority
self.success = success
self.failure = failure
super.init()
}
private func segmentSize() -> UInt {
AssertIsOnMainThread()
let contentLength = UInt(self.contentLength)
guard contentLength > 0 else {
owsFailDebug("rendition missing contentLength")
requestDidFail()
return 0
}
let k1MB: UInt = 1024 * 1024
let k500KB: UInt = 500 * 1024
let k100KB: UInt = 100 * 1024
let k50KB: UInt = 50 * 1024
let k10KB: UInt = 10 * 1024
let k1KB: UInt = 1 * 1024
for segmentSize in [k1MB, k500KB, k100KB, k50KB, k10KB, k1KB ] {
if contentLength >= segmentSize {
return segmentSize
}
}
return contentLength
}
private func createSegments() {
AssertIsOnMainThread()
let segmentLength = segmentSize()
guard segmentLength > 0 else {
return
}
let contentLength = UInt(self.contentLength)
var nextSegmentStart: UInt = 0
var index: UInt = 0
while nextSegmentStart < contentLength {
var segmentStart: UInt = nextSegmentStart
var redundantLength: UInt = 0
// The last segment may overlap the penultimate segment
// in order to keep the segment sizes uniform.
if segmentStart + segmentLength > contentLength {
redundantLength = segmentStart + segmentLength - contentLength
segmentStart = contentLength - segmentLength
}
let assetSegment = GiphyAssetSegment(index: index,
segmentStart: segmentStart,
segmentLength: segmentLength,
redundantLength: redundantLength)
segments.append(assetSegment)
nextSegmentStart = segmentStart + segmentLength
index += 1
}
}
private func firstSegmentWithState(state: GiphyAssetSegmentState) -> GiphyAssetSegment? {
AssertIsOnMainThread()
for segment in segments {
guard segment.state != .failed else {
owsFailDebug("unexpected failed segment.")
continue
}
if segment.state == state {
return segment
}
}
return nil
}
public func firstWaitingSegment() -> GiphyAssetSegment? {
AssertIsOnMainThread()
return firstSegmentWithState(state: .waiting)
}
public func downloadingSegmentsCount() -> UInt {
AssertIsOnMainThread()
var result: UInt = 0
for segment in segments {
guard segment.state != .failed else {
owsFailDebug("unexpected failed segment.")
continue
}
if segment.state == .downloading {
result += 1
}
}
return result
}
public func areAllSegmentsComplete() -> Bool {
AssertIsOnMainThread()
for segment in segments {
guard segment.state == .complete else {
return false
}
}
return true
}
public func writeAssetToFile(gifFolderPath: String) -> GiphyAsset? {
var assetData = Data()
for segment in segments {
guard segment.state == .complete else {
owsFailDebug("unexpected incomplete segment.")
return nil
}
guard segment.totalDataSize() > 0 else {
owsFailDebug("could not merge empty segment.")
return nil
}
guard segment.mergeData(assetData: &assetData) else {
owsFailDebug("failed to merge segment data.")
return nil
}
}
guard assetData.count == contentLength else {
owsFailDebug("asset data has unexpected length.")
return nil
}
guard assetData.count > 0 else {
owsFailDebug("could not write empty asset to disk.")
return nil
}
let fileExtension = rendition.fileExtension
let fileName = (NSUUID().uuidString as NSString).appendingPathExtension(fileExtension)!
let filePath = (gifFolderPath as NSString).appendingPathComponent(fileName)
Logger.verbose("filePath: \(filePath).")
do {
try assetData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite)
let asset = GiphyAsset(rendition: rendition, filePath: filePath)
return asset
} catch let error as NSError {
owsFailDebug("file write failed: \(filePath), \(error)")
return nil
}
}
public func cancel() {
AssertIsOnMainThread()
wasCancelled = true
contentLengthTask?.cancel()
contentLengthTask = nil
for segment in segments {
segment.task?.cancel()
segment.task = nil
}
// Don't call the callbacks if the request is cancelled.
clearCallbacks()
}
private func clearCallbacks() {
AssertIsOnMainThread()
success = nil
failure = nil
}
public func requestDidSucceed(asset: GiphyAsset) {
AssertIsOnMainThread()
success?(self, asset)
// Only one of the callbacks should be called, and only once.
clearCallbacks()
}
public func requestDidFail() {
AssertIsOnMainThread()
failure?(self)
// Only one of the callbacks should be called, and only once.
clearCallbacks()
}
}
// Represents a downloaded gif asset.
//
// The blob on disk is cleaned up when this instance is deallocated,
// so consumers of this resource should retain a strong reference to
// this instance as long as they are using the asset.
@objc class GiphyAsset: NSObject {
let rendition: GiphyRendition
let filePath: String
init(rendition: GiphyRendition,
filePath: String) {
self.rendition = rendition
self.filePath = filePath
}
deinit {
// Clean up on the asset on disk.
let filePathCopy = filePath
DispatchQueue.global().async {
do {
let fileManager = FileManager.default
try fileManager.removeItem(atPath: filePathCopy)
} catch let error as NSError {
owsFailDebug("file cleanup failed: \(filePathCopy), \(error)")
}
}
}
}
private var URLSessionTaskGiphyAssetRequest: UInt8 = 0
private var URLSessionTaskGiphyAssetSegment: UInt8 = 0
// This extension is used to punch an asset request onto a download task.
extension URLSessionTask {
var assetRequest: GiphyAssetRequest {
get {
return objc_getAssociatedObject(self, &URLSessionTaskGiphyAssetRequest) as! GiphyAssetRequest
}
set {
objc_setAssociatedObject(self, &URLSessionTaskGiphyAssetRequest, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
var assetSegment: GiphyAssetSegment {
get {
return objc_getAssociatedObject(self, &URLSessionTaskGiphyAssetSegment) as! GiphyAssetSegment
}
set {
objc_setAssociatedObject(self, &URLSessionTaskGiphyAssetSegment, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
}
@objc class GiphyDownloader: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate {
@objc class GiphyDownloader: ProxiedContentDownloader {
// MARK: - Properties
static let sharedInstance = GiphyDownloader()
var gifFolderPath = ""
// Force usage as a singleton
override private init() {
AssertIsOnMainThread()
super.init()
SwiftSingletons.register(self)
ensureGifFolder()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private let kGiphyBaseURL = "https://api.giphy.com/"
private lazy var giphyDownloadSession: URLSession = {
AssertIsOnMainThread()
let configuration = ContentProxy.sessionConfiguration()
configuration.urlCache = nil
configuration.requestCachePolicy = .reloadIgnoringCacheData
configuration.httpMaximumConnectionsPerHost = 10
let session = URLSession(configuration: configuration,
delegate: self,
delegateQueue: nil)
return session
}()
// 100 entries of which at least half will probably be stills.
// Actual animated GIFs will usually be less than 3 MB so the
// max size of the cache on disk should be ~150 MB. Bear in mind
// that assets are not always deleted on disk as soon as they are
// evacuated from the cache; if a cache consumer (e.g. view) is
// still using the asset, the asset won't be deleted on disk until
// it is no longer in use.
private var assetMap = LRUCache<NSURL, GiphyAsset>(maxSize: 100)
// TODO: We could use a proper queue, e.g. implemented with a linked
// list.
private var assetRequestQueue = [GiphyAssetRequest]()
// The success and failure callbacks are always called on main queue.
//
// The success callbacks may be called synchronously on cache hit, in
// which case the GiphyAssetRequest parameter will be nil.
public func requestAsset(rendition: GiphyRendition,
priority: GiphyRequestPriority,
success:@escaping ((GiphyAssetRequest?, GiphyAsset) -> Void),
failure:@escaping ((GiphyAssetRequest) -> Void)) -> GiphyAssetRequest? {
AssertIsOnMainThread()
if let asset = assetMap.get(key: rendition.url) {
// Synchronous cache hit.
Logger.verbose("asset cache hit: \(rendition.url)")
success(nil, asset)
return nil
}
// Cache miss.
//
// Asset requests are done queued and performed asynchronously.
Logger.verbose("asset cache miss: \(rendition.url)")
let assetRequest = GiphyAssetRequest(rendition: rendition,
priority: priority,
success: success,
failure: failure)
assetRequestQueue.append(assetRequest)
// Process the queue (which may start this request)
// asynchronously so that the caller has time to store
// a reference to the asset request returned by this
// method before its success/failure handler is called.
processRequestQueueAsync()
return assetRequest
}
public func cancelAllRequests() {
AssertIsOnMainThread()
Logger.verbose("cancelAllRequests")
self.assetRequestQueue.forEach { $0.cancel() }
self.assetRequestQueue = []
}
private func segmentRequestDidSucceed(assetRequest: GiphyAssetRequest, assetSegment: GiphyAssetSegment) {
DispatchQueue.main.async {
assetSegment.state = .complete
if assetRequest.areAllSegmentsComplete() {
// If the asset request has completed all of its segments,
// try to write the asset to file.
assetRequest.state = .complete
// Move write off main thread.
DispatchQueue.global().async {
guard let asset = assetRequest.writeAssetToFile(gifFolderPath: self.gifFolderPath) else {
self.segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
return
}
self.assetRequestDidSucceed(assetRequest: assetRequest, asset: asset)
}
} else {
self.processRequestQueueSync()
}
}
}
private func assetRequestDidSucceed(assetRequest: GiphyAssetRequest, asset: GiphyAsset) {
DispatchQueue.main.async {
self.assetMap.set(key: assetRequest.rendition.url, value: asset)
self.removeAssetRequestFromQueue(assetRequest: assetRequest)
assetRequest.requestDidSucceed(asset: asset)
}
}
// TODO: If we wanted to implement segment retry, we'll need to add
// a segmentRequestDidFail() method.
private func segmentRequestDidFail(assetRequest: GiphyAssetRequest, assetSegment: GiphyAssetSegment) {
DispatchQueue.main.async {
assetSegment.state = .failed
assetRequest.state = .failed
self.assetRequestDidFail(assetRequest: assetRequest)
}
}
private func assetRequestDidFail(assetRequest: GiphyAssetRequest) {
DispatchQueue.main.async {
self.removeAssetRequestFromQueue(assetRequest: assetRequest)
assetRequest.requestDidFail()
}
}
private func removeAssetRequestFromQueue(assetRequest: GiphyAssetRequest) {
AssertIsOnMainThread()
guard assetRequestQueue.contains(assetRequest) else {
Logger.warn("could not remove asset request from queue: \(assetRequest.rendition.url)")
return
}
assetRequestQueue = assetRequestQueue.filter { $0 != assetRequest }
// Process the queue async to ensure that state in the downloader
// classes is consistent before we try to start a new request.
processRequestQueueAsync()
}
private func processRequestQueueAsync() {
DispatchQueue.main.async {
self.processRequestQueueSync()
}
}
// * Start a segment request or content length request if possible.
// * Complete/cancel asset requests if possible.
//
private func processRequestQueueSync() {
AssertIsOnMainThread()
guard let assetRequest = popNextAssetRequest() else {
return
}
guard !assetRequest.wasCancelled else {
// Discard the cancelled asset request and try again.
removeAssetRequestFromQueue(assetRequest: assetRequest)
return
}
guard UIApplication.shared.applicationState == .active else {
// If app is not active, fail the asset request.
assetRequest.state = .failed
assetRequestDidFail(assetRequest: assetRequest)
processRequestQueueSync()
return
}
if let asset = assetMap.get(key: assetRequest.rendition.url) {
// Deferred cache hit, avoids re-downloading assets that were
// downloaded while this request was queued.
assetRequest.state = .complete
assetRequestDidSucceed(assetRequest: assetRequest, asset: asset)
return
}
if assetRequest.state == .waiting {
// If asset request hasn't yet determined the resource size,
// try to do so now.
assetRequest.state = .requestingSize
var request = URLRequest(url: assetRequest.rendition.url as URL)
request.httpMethod = "HEAD"
request.httpShouldUsePipelining = true
let task = giphyDownloadSession.dataTask(with: request, completionHandler: { data, response, error -> Void in
if let data = data, data.count > 0 {
owsFailDebug("HEAD request has unexpected body: \(data.count).")
}
self.handleAssetSizeResponse(assetRequest: assetRequest, response: response, error: error)
})
assetRequest.contentLengthTask = task
task.resume()
} else {
// Start a download task.
guard let assetSegment = assetRequest.firstWaitingSegment() else {
owsFailDebug("queued asset request does not have a waiting segment.")
return
}
assetSegment.state = .downloading
var request = URLRequest(url: assetRequest.rendition.url as URL)
request.httpShouldUsePipelining = true
let rangeHeaderValue = "bytes=\(assetSegment.segmentStart)-\(assetSegment.segmentStart + assetSegment.segmentLength - 1)"
request.addValue(rangeHeaderValue, forHTTPHeaderField: "Range")
let task: URLSessionDataTask = giphyDownloadSession.dataTask(with: request)
task.assetRequest = assetRequest
task.assetSegment = assetSegment
assetSegment.task = task
task.resume()
}
// Recurse; we may be able to start multiple downloads.
processRequestQueueSync()
}
private func handleAssetSizeResponse(assetRequest: GiphyAssetRequest, response: URLResponse?, error: Error?) {
guard error == nil else {
assetRequest.state = .failed
self.assetRequestDidFail(assetRequest: assetRequest)
return
}
guard let httpResponse = response as? HTTPURLResponse else {
owsFailDebug("Asset size response is invalid.")
assetRequest.state = .failed
self.assetRequestDidFail(assetRequest: assetRequest)
return
}
guard let contentLengthString = httpResponse.allHeaderFields["Content-Length"] as? String else {
owsFailDebug("Asset size response is missing content length.")
assetRequest.state = .failed
self.assetRequestDidFail(assetRequest: assetRequest)
return
}
guard let contentLength = Int(contentLengthString) else {
owsFailDebug("Asset size response has unparsable content length.")
assetRequest.state = .failed
self.assetRequestDidFail(assetRequest: assetRequest)
return
}
guard contentLength > 0 else {
owsFailDebug("Asset size response has invalid content length.")
assetRequest.state = .failed
self.assetRequestDidFail(assetRequest: assetRequest)
return
}
DispatchQueue.main.async {
assetRequest.contentLength = contentLength
assetRequest.state = .active
self.processRequestQueueSync()
}
}
// Return the first asset request for which we either:
//
// * Need to download the content length.
// * Need to download at least one of its segments.
private func popNextAssetRequest() -> GiphyAssetRequest? {
AssertIsOnMainThread()
let kMaxAssetRequestCount: UInt = 3
let kMaxAssetRequestsPerAssetCount: UInt = kMaxAssetRequestCount - 1
// Prefer the first "high" priority request;
// fall back to the first "low" priority request.
var activeAssetRequestsCount: UInt = 0
for priority in [GiphyRequestPriority.high, GiphyRequestPriority.low] {
for assetRequest in assetRequestQueue where assetRequest.priority == priority {
switch assetRequest.state {
case .waiting:
// This asset request needs its content length.
return assetRequest
case .requestingSize:
activeAssetRequestsCount += 1
// Ensure that only N requests are active at a time.
guard activeAssetRequestsCount < kMaxAssetRequestCount else {
return nil
}
continue
case .active:
break
case .complete:
continue
case .failed:
continue
}
let downloadingSegmentsCount = assetRequest.downloadingSegmentsCount()
activeAssetRequestsCount += downloadingSegmentsCount
// Ensure that only N segment requests are active per asset at a time.
guard downloadingSegmentsCount < kMaxAssetRequestsPerAssetCount else {
continue
}
// Ensure that only N requests are active at a time.
guard activeAssetRequestsCount < kMaxAssetRequestCount else {
return nil
}
guard assetRequest.firstWaitingSegment() != nil else {
/// Asset request does not have a waiting segment.
continue
}
return assetRequest
}
}
return nil
}
// MARK: URLSessionDataDelegate
@nonobjc
public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
completionHandler(.allow)
}
public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
let assetRequest = dataTask.assetRequest
let assetSegment = dataTask.assetSegment
guard !assetRequest.wasCancelled else {
dataTask.cancel()
segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
return
}
assetSegment.append(data: data)
}
public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Void) {
completionHandler(nil)
}
// MARK: URLSessionTaskDelegate
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
let assetRequest = task.assetRequest
let assetSegment = task.assetSegment
guard !assetRequest.wasCancelled else {
task.cancel()
segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
return
}
if let error = error {
Logger.error("download failed with error: \(error)")
segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
return
}
guard let httpResponse = task.response as? HTTPURLResponse else {
Logger.error("missing or unexpected response: \(String(describing: task.response))")
segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
return
}
let statusCode = httpResponse.statusCode
guard statusCode >= 200 && statusCode < 400 else {
Logger.error("response has invalid status code: \(statusCode)")
segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
return
}
guard assetSegment.totalDataSize() == assetSegment.segmentLength else {
Logger.error("segment is missing data: \(statusCode)")
segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
return
}
segmentRequestDidSucceed(assetRequest: assetRequest, assetSegment: assetSegment)
}
// MARK: Temp Directory
public func ensureGifFolder() {
// We write assets to the temporary directory so that iOS can clean them up.
// We try to eagerly clean up these assets when they are no longer in use.
let tempDirPath = OWSTemporaryDirectory()
let dirPath = (tempDirPath as NSString).appendingPathComponent("GIFs")
do {
let fileManager = FileManager.default
// Try to delete existing folder if necessary.
if fileManager.fileExists(atPath: dirPath) {
try fileManager.removeItem(atPath: dirPath)
gifFolderPath = dirPath
}
// Try to create folder if necessary.
if !fileManager.fileExists(atPath: dirPath) {
try fileManager.createDirectory(atPath: dirPath,
withIntermediateDirectories: true,
attributes: nil)
gifFolderPath = dirPath
}
// Don't back up Giphy downloads.
OWSFileSystem.protectFileOrFolder(atPath: dirPath)
} catch let error as NSError {
owsFailDebug("ensureTempFolder failed: \(dirPath), \(error)")
gifFolderPath = tempDirPath
}
}
public static let giphyDownloader = GiphyDownloader(downloadFolderName: "GIFs")
}

@ -6,72 +6,39 @@ import Foundation
import ObjectiveC
// Stills should be loaded before full GIFs.
enum ProxiedContentRequestPriority {
public enum ProxiedContentRequestPriority {
case low, high
}
// MARK: -
@objc class ProxiedContentDescription: NSObject {
// let format: GiphyFormat
// let name: String
// let width: UInt
// let height: UInt
// let fileSize: UInt
// let url: NSURL
//
// init(format: GiphyFormat,
// name: String,
// width: UInt,
// height: UInt,
// fileSize: UInt,
// url: NSURL) {
// self.format = format
// self.name = name
// self.width = width
// self.height = height
// self.fileSize = fileSize
// self.url = url
// }
//
// public var fileExtension: String {
// switch format {
// case .gif:
// return "gif"
// case .mp4:
// return "mp4"
// case .jpg:
// return "jpg"
// }
// }
//
// public var utiType: String {
// switch format {
// case .gif:
// return kUTTypeGIF as String
// case .mp4:
// return kUTTypeMPEG4 as String
// case .jpg:
// return kUTTypeJPEG as String
// }
// }
//
// public var isStill: Bool {
// return name.hasSuffix("_still")
// }
//
// public var isDownsampled: Bool {
// return name.hasSuffix("_downsampled")
// }
//
// public func log() {
// Logger.verbose("\t \(format), \(name), \(width), \(height), \(fileSize)")
// }
@objc
open class ProxiedContentAssetDescription: NSObject {
@objc
public let url: NSURL
@objc
public let fileExtension: String
public init?(url: NSURL,
fileExtension: String? = nil) {
self.url = url
if let fileExtension = fileExtension {
self.fileExtension = fileExtension
} else {
guard let pathExtension = url.pathExtension else {
owsFailDebug("URL has not path extension.")
return nil
}
self.fileExtension = pathExtension
}
}
}
// MARK: -
enum ProxiedContentAssetSegmentState: UInt {
public enum ProxiedContentAssetSegmentState: UInt {
case waiting
case downloading
case complete
@ -80,7 +47,7 @@ enum ProxiedContentAssetSegmentState: UInt {
// MARK: -
class ProxiedContentAssetSegment: NSObject {
public class ProxiedContentAssetSegment: NSObject {
public let index: UInt
public let segmentStart: UInt
@ -154,7 +121,7 @@ class ProxiedContentAssetSegment: NSObject {
// MARK: -
enum ProxiedContentAssetRequestState: UInt {
public enum ProxiedContentAssetRequestState: UInt {
// Does not yet have content length.
case waiting
// Getting content length.
@ -172,9 +139,10 @@ enum ProxiedContentAssetRequestState: UInt {
// Represents a request to download an asset.
//
// Should be cancelled if no longer necessary.
@objc class ProxiedContentAssetRequest: NSObject {
@objc
public class ProxiedContentAssetRequest: NSObject {
let rendition: ProxiedContentRendition
let assetDescription: ProxiedContentAssetDescription
let priority: ProxiedContentRequestPriority
// Exactly one of success or failure should be called once,
// on the main thread _unless_ this request is cancelled before
@ -200,11 +168,11 @@ enum ProxiedContentAssetRequestState: UInt {
}
public weak var contentLengthTask: URLSessionDataTask?
init(rendition: ProxiedContentRendition,
init(assetDescription: ProxiedContentAssetDescription,
priority: ProxiedContentRequestPriority,
success:@escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void),
failure:@escaping ((ProxiedContentAssetRequest) -> Void)) {
self.rendition = rendition
self.assetDescription = assetDescription
self.priority = priority
self.success = success
self.failure = failure
@ -342,7 +310,7 @@ enum ProxiedContentAssetRequestState: UInt {
return nil
}
let fileExtension = rendition.fileExtension
let fileExtension = assetDescription.fileExtension
let fileName = (NSUUID().uuidString as NSString).appendingPathExtension(fileExtension)!
let filePath = (downloadFolderPath as NSString).appendingPathComponent(fileName)
@ -350,7 +318,7 @@ enum ProxiedContentAssetRequestState: UInt {
do {
try assetData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite)
let asset = ProxiedContentAsset(rendition: rendition, filePath: filePath)
let asset = ProxiedContentAsset(assetDescription: assetDescription, filePath: filePath)
return asset
} catch let error as NSError {
owsFailDebug("file write failed: \(filePath), \(error)")
@ -406,14 +374,18 @@ enum ProxiedContentAssetRequestState: UInt {
// The blob on disk is cleaned up when this instance is deallocated,
// so consumers of this resource should retain a strong reference to
// this instance as long as they are using the asset.
@objc class ProxiedContentAsset: NSObject {
@objc
public class ProxiedContentAsset: NSObject {
@objc
public let assetDescription: ProxiedContentAssetDescription
let rendition: ProxiedContentRendition
let filePath: String
@objc
public let filePath: String
init(rendition: ProxiedContentRendition,
init(assetDescription: ProxiedContentAssetDescription,
filePath: String) {
self.rendition = rendition
self.assetDescription = assetDescription
self.filePath = filePath
}
@ -458,18 +430,23 @@ extension URLSessionTask {
// MARK: -
@objc class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate {
@objc
open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate {
// MARK: - Properties
static let sharedInstance = ProxiedContentDownloader()
public static let defaultDownloader = ProxiedContentDownloader(downloadFolderName: "proxiedContent")
var downloadFolderPath = ""
private let downloadFolderName: String
private var downloadFolderPath: String?
// Force usage as a singleton
override private init() {
public required init(downloadFolderName: String) {
AssertIsOnMainThread()
self.downloadFolderName = downloadFolderName
super.init()
SwiftSingletons.register(self)
@ -510,15 +487,15 @@ extension URLSessionTask {
//
// The success callbacks may be called synchronously on cache hit, in
// which case the ProxiedContentAssetRequest parameter will be nil.
public func requestAsset(rendition: ProxiedContentRendition,
public func requestAsset(assetDescription: ProxiedContentAssetDescription,
priority: ProxiedContentRequestPriority,
success:@escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void),
failure:@escaping ((ProxiedContentAssetRequest) -> Void)) -> ProxiedContentAssetRequest? {
AssertIsOnMainThread()
if let asset = assetMap.get(key: rendition.url) {
if let asset = assetMap.get(key: assetDescription.url) {
// Synchronous cache hit.
Logger.verbose("asset cache hit: \(rendition.url)")
Logger.verbose("asset cache hit: \(assetDescription.url)")
success(nil, asset)
return nil
}
@ -526,8 +503,8 @@ extension URLSessionTask {
// Cache miss.
//
// Asset requests are done queued and performed asynchronously.
Logger.verbose("asset cache miss: \(rendition.url)")
let assetRequest = ProxiedContentAssetRequest(rendition: rendition,
Logger.verbose("asset cache miss: \(assetDescription.url)")
let assetRequest = ProxiedContentAssetRequest(assetDescription: assetDescription,
priority: priority,
success: success,
failure: failure)
@ -560,7 +537,11 @@ extension URLSessionTask {
// Move write off main thread.
DispatchQueue.global().async {
guard let asset = assetRequest.writeAssetToFile(downloadFolderPath: self.downloadFolderPath) else {
guard let downloadFolderPath = self.downloadFolderPath else {
owsFailDebug("Missing downloadFolderPath")
return
}
guard let asset = assetRequest.writeAssetToFile(downloadFolderPath: downloadFolderPath) else {
self.segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
return
}
@ -575,7 +556,7 @@ extension URLSessionTask {
private func assetRequestDidSucceed(assetRequest: ProxiedContentAssetRequest, asset: ProxiedContentAsset) {
DispatchQueue.main.async {
self.assetMap.set(key: assetRequest.rendition.url, value: asset)
self.assetMap.set(key: assetRequest.assetDescription.url, value: asset)
self.removeAssetRequestFromQueue(assetRequest: assetRequest)
assetRequest.requestDidSucceed(asset: asset)
}
@ -603,7 +584,7 @@ extension URLSessionTask {
AssertIsOnMainThread()
guard assetRequestQueue.contains(assetRequest) else {
Logger.warn("could not remove asset request from queue: \(assetRequest.rendition.url)")
Logger.warn("could not remove asset request from queue: \(assetRequest.assetDescription.url)")
return
}
@ -641,7 +622,7 @@ extension URLSessionTask {
return
}
if let asset = assetMap.get(key: assetRequest.rendition.url) {
if let asset = assetMap.get(key: assetRequest.assetDescription.url) {
// Deferred cache hit, avoids re-downloading assets that were
// downloaded while this request was queued.
@ -655,7 +636,7 @@ extension URLSessionTask {
// try to do so now.
assetRequest.state = .requestingSize
var request = URLRequest(url: assetRequest.rendition.url as URL)
var request = URLRequest(url: assetRequest.assetDescription.url as URL)
request.httpMethod = "HEAD"
request.httpShouldUsePipelining = true
@ -676,7 +657,7 @@ extension URLSessionTask {
}
assetSegment.state = .downloading
var request = URLRequest(url: assetRequest.rendition.url as URL)
var request = URLRequest(url: assetRequest.assetDescription.url as URL)
request.httpShouldUsePipelining = true
let rangeHeaderValue = "bytes=\(assetSegment.segmentStart)-\(assetSegment.segmentStart + assetSegment.segmentLength - 1)"
request.addValue(rangeHeaderValue, forHTTPHeaderField: "Range")
@ -850,7 +831,7 @@ extension URLSessionTask {
// We try to eagerly clean up these assets when they are no longer in use.
let tempDirPath = OWSTemporaryDirectory()
let dirPath = (tempDirPath as NSString).appendingPathComponent("GIFs")
let dirPath = (tempDirPath as NSString).appendingPathComponent(downloadFolderName)
do {
let fileManager = FileManager.default

Loading…
Cancel
Save