mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Segment proxied content downloads.
This commit is contained in:
parent
bf4c43f063
commit
4e7dbc486d
3 changed files with 878 additions and 5 deletions
|
@ -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 = "<group>"; };
|
||||
4523149F1F7E9E18003A428C /* DirectionalPanGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DirectionalPanGestureRecognizer.swift; sourceTree = "<group>"; };
|
||||
4523D015206EDC2B00A2AB51 /* LRUCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = "<group>"; };
|
||||
452B998F20A34B6B006F2F9E /* AddContactShareToExistingContactViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactShareToExistingContactViewController.swift; sourceTree = "<group>"; };
|
||||
452C468E1E427E200087B011 /* OutboundCallInitiator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutboundCallInitiator.swift; sourceTree = "<group>"; };
|
||||
452D1AF02081059C00A67F7F /* StringAdditionsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringAdditionsTest.swift; sourceTree = "<group>"; };
|
||||
|
@ -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 */,
|
||||
|
|
877
SignalServiceKit/src/Network/ProxiedContentDownloader.swift
Normal file
877
SignalServiceKit/src/Network/ProxiedContentDownloader.swift
Normal file
|
@ -0,0 +1,877 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ObjectiveC
|
||||
|
||||
// Stills should be loaded before full GIFs.
|
||||
enum ProxiedContentRequestPriority {
|
||||
case low, high
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@objc class ProxiedContentDescription: NSObject {
|
||||
// let format: GiphyFormat
|
||||
// let name: String
|
||||
// let width: UInt
|
||||
// let height: UInt
|
||||
// let fileSize: UInt
|
||||
// let url: NSURL
|
||||
//
|
||||
// init(format: GiphyFormat,
|
||||
// name: String,
|
||||
// width: UInt,
|
||||
// height: UInt,
|
||||
// fileSize: UInt,
|
||||
// url: NSURL) {
|
||||
// self.format = format
|
||||
// self.name = name
|
||||
// self.width = width
|
||||
// self.height = height
|
||||
// self.fileSize = fileSize
|
||||
// self.url = url
|
||||
// }
|
||||
//
|
||||
// public var fileExtension: String {
|
||||
// switch format {
|
||||
// case .gif:
|
||||
// return "gif"
|
||||
// case .mp4:
|
||||
// return "mp4"
|
||||
// case .jpg:
|
||||
// return "jpg"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public var utiType: String {
|
||||
// switch format {
|
||||
// case .gif:
|
||||
// return kUTTypeGIF as String
|
||||
// case .mp4:
|
||||
// return kUTTypeMPEG4 as String
|
||||
// case .jpg:
|
||||
// return kUTTypeJPEG as String
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public var isStill: Bool {
|
||||
// return name.hasSuffix("_still")
|
||||
// }
|
||||
//
|
||||
// public var isDownsampled: Bool {
|
||||
// return name.hasSuffix("_downsampled")
|
||||
// }
|
||||
//
|
||||
// public func log() {
|
||||
// Logger.verbose("\t \(format), \(name), \(width), \(height), \(fileSize)")
|
||||
// }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
enum ProxiedContentAssetSegmentState: UInt {
|
||||
case waiting
|
||||
case downloading
|
||||
case complete
|
||||
case failed
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
class ProxiedContentAssetSegment: NSObject {
|
||||
|
||||
public let index: UInt
|
||||
public let segmentStart: UInt
|
||||
public let segmentLength: UInt
|
||||
// The amount of the segment that is overlap.
|
||||
// The overlap lies in the _first_ n bytes of the segment data.
|
||||
public let redundantLength: UInt
|
||||
|
||||
// This state should only be accessed on the main thread.
|
||||
public var state: ProxiedContentAssetSegmentState = .waiting {
|
||||
didSet {
|
||||
AssertIsOnMainThread()
|
||||
}
|
||||
}
|
||||
|
||||
// This state is accessed off the main thread.
|
||||
//
|
||||
// * During downloads it will be accessed on the task delegate queue.
|
||||
// * After downloads it will be accessed on a worker queue.
|
||||
private var segmentData = Data()
|
||||
|
||||
// This state should only be accessed on the main thread.
|
||||
public weak var task: URLSessionDataTask?
|
||||
|
||||
init(index: UInt,
|
||||
segmentStart: UInt,
|
||||
segmentLength: UInt,
|
||||
redundantLength: UInt) {
|
||||
self.index = index
|
||||
self.segmentStart = segmentStart
|
||||
self.segmentLength = segmentLength
|
||||
self.redundantLength = redundantLength
|
||||
}
|
||||
|
||||
public func totalDataSize() -> UInt {
|
||||
return UInt(segmentData.count)
|
||||
}
|
||||
|
||||
public func append(data: Data) {
|
||||
guard state == .downloading else {
|
||||
owsFailDebug("appending data in invalid state: \(state)")
|
||||
return
|
||||
}
|
||||
|
||||
segmentData.append(data)
|
||||
}
|
||||
|
||||
public func mergeData(assetData: inout Data) -> Bool {
|
||||
guard state == .complete else {
|
||||
owsFailDebug("merging data in invalid state: \(state)")
|
||||
return false
|
||||
}
|
||||
guard UInt(segmentData.count) == segmentLength else {
|
||||
owsFailDebug("segment data length: \(segmentData.count) doesn't match expected length: \(segmentLength)")
|
||||
return false
|
||||
}
|
||||
|
||||
// In some cases the last two segments will overlap.
|
||||
// In that case, we only want to append the non-overlapping
|
||||
// tail of the segment data.
|
||||
let bytesToIgnore = Int(redundantLength)
|
||||
if bytesToIgnore > 0 {
|
||||
let subdata = segmentData.subdata(in: bytesToIgnore..<Int(segmentLength))
|
||||
assetData.append(subdata)
|
||||
} else {
|
||||
assetData.append(segmentData)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
enum ProxiedContentAssetRequestState: 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
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
// Represents a request to download an asset.
|
||||
//
|
||||
// Should be cancelled if no longer necessary.
|
||||
@objc class ProxiedContentAssetRequest: NSObject {
|
||||
|
||||
let rendition: ProxiedContentRendition
|
||||
let priority: ProxiedContentRequestPriority
|
||||
// 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: ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void)?
|
||||
private var failure: ((ProxiedContentAssetRequest) -> Void)?
|
||||
|
||||
var wasCancelled = false
|
||||
// This property is an internal implementation detail of the download process.
|
||||
var assetFilePath: String?
|
||||
|
||||
// This state should only be accessed on the main thread.
|
||||
private var segments = [ProxiedContentAssetSegment]()
|
||||
public var state: ProxiedContentAssetRequestState = .waiting
|
||||
public var contentLength: Int = 0 {
|
||||
didSet {
|
||||
AssertIsOnMainThread()
|
||||
assert(oldValue == 0)
|
||||
assert(contentLength > 0)
|
||||
|
||||
createSegments()
|
||||
}
|
||||
}
|
||||
public weak var contentLengthTask: URLSessionDataTask?
|
||||
|
||||
init(rendition: ProxiedContentRendition,
|
||||
priority: ProxiedContentRequestPriority,
|
||||
success:@escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void),
|
||||
failure:@escaping ((ProxiedContentAssetRequest) -> Void)) {
|
||||
self.rendition = rendition
|
||||
self.priority = priority
|
||||
self.success = success
|
||||
self.failure = failure
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
private func segmentSize() -> UInt {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
let contentLength = UInt(self.contentLength)
|
||||
guard contentLength > 0 else {
|
||||
owsFailDebug("asset missing contentLength")
|
||||
requestDidFail()
|
||||
return 0
|
||||
}
|
||||
|
||||
let k1MB: UInt = 1024 * 1024
|
||||
let k500KB: UInt = 500 * 1024
|
||||
let k100KB: UInt = 100 * 1024
|
||||
let k50KB: UInt = 50 * 1024
|
||||
let k10KB: UInt = 10 * 1024
|
||||
let k1KB: UInt = 1 * 1024
|
||||
for segmentSize in [k1MB, k500KB, k100KB, k50KB, k10KB, k1KB ] {
|
||||
if contentLength >= segmentSize {
|
||||
return segmentSize
|
||||
}
|
||||
}
|
||||
return contentLength
|
||||
}
|
||||
|
||||
private func createSegments() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
let segmentLength = segmentSize()
|
||||
guard segmentLength > 0 else {
|
||||
return
|
||||
}
|
||||
let contentLength = UInt(self.contentLength)
|
||||
|
||||
var nextSegmentStart: UInt = 0
|
||||
var index: UInt = 0
|
||||
while nextSegmentStart < contentLength {
|
||||
var segmentStart: UInt = nextSegmentStart
|
||||
var redundantLength: UInt = 0
|
||||
// The last segment may overlap the penultimate segment
|
||||
// in order to keep the segment sizes uniform.
|
||||
if segmentStart + segmentLength > contentLength {
|
||||
redundantLength = segmentStart + segmentLength - contentLength
|
||||
segmentStart = contentLength - segmentLength
|
||||
}
|
||||
let assetSegment = ProxiedContentAssetSegment(index: index,
|
||||
segmentStart: segmentStart,
|
||||
segmentLength: segmentLength,
|
||||
redundantLength: redundantLength)
|
||||
segments.append(assetSegment)
|
||||
nextSegmentStart = segmentStart + segmentLength
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
private func firstSegmentWithState(state: ProxiedContentAssetSegmentState) -> ProxiedContentAssetSegment? {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
for segment in segments {
|
||||
guard segment.state != .failed else {
|
||||
owsFailDebug("unexpected failed segment.")
|
||||
continue
|
||||
}
|
||||
if segment.state == state {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func firstWaitingSegment() -> ProxiedContentAssetSegment? {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
return firstSegmentWithState(state: .waiting)
|
||||
}
|
||||
|
||||
public func downloadingSegmentsCount() -> UInt {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
var result: UInt = 0
|
||||
for segment in segments {
|
||||
guard segment.state != .failed else {
|
||||
owsFailDebug("unexpected failed segment.")
|
||||
continue
|
||||
}
|
||||
if segment.state == .downloading {
|
||||
result += 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public func areAllSegmentsComplete() -> Bool {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
for segment in segments {
|
||||
guard segment.state == .complete else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
public func writeAssetToFile(downloadFolderPath: String) -> ProxiedContentAsset? {
|
||||
|
||||
var assetData = Data()
|
||||
for segment in segments {
|
||||
guard segment.state == .complete else {
|
||||
owsFailDebug("unexpected incomplete segment.")
|
||||
return nil
|
||||
}
|
||||
guard segment.totalDataSize() > 0 else {
|
||||
owsFailDebug("could not merge empty segment.")
|
||||
return nil
|
||||
}
|
||||
guard segment.mergeData(assetData: &assetData) else {
|
||||
owsFailDebug("failed to merge segment data.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
guard assetData.count == contentLength else {
|
||||
owsFailDebug("asset data has unexpected length.")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard assetData.count > 0 else {
|
||||
owsFailDebug("could not write empty asset to disk.")
|
||||
return nil
|
||||
}
|
||||
|
||||
let fileExtension = rendition.fileExtension
|
||||
let fileName = (NSUUID().uuidString as NSString).appendingPathExtension(fileExtension)!
|
||||
let filePath = (downloadFolderPath as NSString).appendingPathComponent(fileName)
|
||||
|
||||
Logger.verbose("filePath: \(filePath).")
|
||||
|
||||
do {
|
||||
try assetData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite)
|
||||
let asset = ProxiedContentAsset(rendition: rendition, filePath: filePath)
|
||||
return asset
|
||||
} catch let error as NSError {
|
||||
owsFailDebug("file write failed: \(filePath), \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func cancel() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
wasCancelled = true
|
||||
contentLengthTask?.cancel()
|
||||
contentLengthTask = nil
|
||||
for segment in segments {
|
||||
segment.task?.cancel()
|
||||
segment.task = nil
|
||||
}
|
||||
|
||||
// Don't call the callbacks if the request is cancelled.
|
||||
clearCallbacks()
|
||||
}
|
||||
|
||||
private func clearCallbacks() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
success = nil
|
||||
failure = nil
|
||||
}
|
||||
|
||||
public func requestDidSucceed(asset: ProxiedContentAsset) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
success?(self, asset)
|
||||
|
||||
// Only one of the callbacks should be called, and only once.
|
||||
clearCallbacks()
|
||||
}
|
||||
|
||||
public func requestDidFail() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
failure?(self)
|
||||
|
||||
// Only one of the callbacks should be called, and only once.
|
||||
clearCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
// Represents a downloaded asset.
|
||||
//
|
||||
// The blob on disk is cleaned up when this instance is deallocated,
|
||||
// so consumers of this resource should retain a strong reference to
|
||||
// this instance as long as they are using the asset.
|
||||
@objc class ProxiedContentAsset: NSObject {
|
||||
|
||||
let rendition: ProxiedContentRendition
|
||||
let filePath: String
|
||||
|
||||
init(rendition: ProxiedContentRendition,
|
||||
filePath: String) {
|
||||
self.rendition = rendition
|
||||
self.filePath = filePath
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Clean up on the asset on disk.
|
||||
let filePathCopy = filePath
|
||||
DispatchQueue.global().async {
|
||||
do {
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.removeItem(atPath: filePathCopy)
|
||||
} catch let error as NSError {
|
||||
owsFailDebug("file cleanup failed: \(filePathCopy), \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private var URLSessionTaskProxiedContentAssetRequest: UInt8 = 0
|
||||
private var URLSessionTaskProxiedContentAssetSegment: UInt8 = 0
|
||||
|
||||
// This extension is used to punch an asset request onto a download task.
|
||||
extension URLSessionTask {
|
||||
var assetRequest: ProxiedContentAssetRequest {
|
||||
get {
|
||||
return objc_getAssociatedObject(self, &URLSessionTaskProxiedContentAssetRequest) as! ProxiedContentAssetRequest
|
||||
}
|
||||
set {
|
||||
objc_setAssociatedObject(self, &URLSessionTaskProxiedContentAssetRequest, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
}
|
||||
}
|
||||
var assetSegment: ProxiedContentAssetSegment {
|
||||
get {
|
||||
return objc_getAssociatedObject(self, &URLSessionTaskProxiedContentAssetSegment) as! ProxiedContentAssetSegment
|
||||
}
|
||||
set {
|
||||
objc_setAssociatedObject(self, &URLSessionTaskProxiedContentAssetSegment, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@objc class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
static let sharedInstance = ProxiedContentDownloader()
|
||||
|
||||
var downloadFolderPath = ""
|
||||
|
||||
// Force usage as a singleton
|
||||
override private init() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
super.init()
|
||||
|
||||
SwiftSingletons.register(self)
|
||||
|
||||
ensureDownloadFolder()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
private lazy var downloadSession: URLSession = {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
let configuration = ContentProxy.sessionConfiguration()
|
||||
configuration.urlCache = nil
|
||||
configuration.requestCachePolicy = .reloadIgnoringCacheData
|
||||
configuration.httpMaximumConnectionsPerHost = 10
|
||||
let session = URLSession(configuration: configuration,
|
||||
delegate: self,
|
||||
delegateQueue: nil)
|
||||
return session
|
||||
}()
|
||||
|
||||
// 100 entries of which at least half will probably be stills.
|
||||
// Actual animated GIFs will usually be less than 3 MB so the
|
||||
// max size of the cache on disk should be ~150 MB. Bear in mind
|
||||
// that assets are not always deleted on disk as soon as they are
|
||||
// evacuated from the cache; if a cache consumer (e.g. view) is
|
||||
// still using the asset, the asset won't be deleted on disk until
|
||||
// it is no longer in use.
|
||||
private var assetMap = LRUCache<NSURL, ProxiedContentAsset>(maxSize: 100)
|
||||
// TODO: We could use a proper queue, e.g. implemented with a linked
|
||||
// list.
|
||||
private var assetRequestQueue = [ProxiedContentAssetRequest]()
|
||||
|
||||
// The success and failure callbacks are always called on main queue.
|
||||
//
|
||||
// The success callbacks may be called synchronously on cache hit, in
|
||||
// which case the ProxiedContentAssetRequest parameter will be nil.
|
||||
public func requestAsset(rendition: ProxiedContentRendition,
|
||||
priority: ProxiedContentRequestPriority,
|
||||
success:@escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void),
|
||||
failure:@escaping ((ProxiedContentAssetRequest) -> Void)) -> ProxiedContentAssetRequest? {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
if let asset = assetMap.get(key: rendition.url) {
|
||||
// Synchronous cache hit.
|
||||
Logger.verbose("asset cache hit: \(rendition.url)")
|
||||
success(nil, asset)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cache miss.
|
||||
//
|
||||
// Asset requests are done queued and performed asynchronously.
|
||||
Logger.verbose("asset cache miss: \(rendition.url)")
|
||||
let assetRequest = ProxiedContentAssetRequest(rendition: rendition,
|
||||
priority: priority,
|
||||
success: success,
|
||||
failure: failure)
|
||||
assetRequestQueue.append(assetRequest)
|
||||
// Process the queue (which may start this request)
|
||||
// asynchronously so that the caller has time to store
|
||||
// a reference to the asset request returned by this
|
||||
// method before its success/failure handler is called.
|
||||
processRequestQueueAsync()
|
||||
return assetRequest
|
||||
}
|
||||
|
||||
public func cancelAllRequests() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
Logger.verbose("cancelAllRequests")
|
||||
|
||||
self.assetRequestQueue.forEach { $0.cancel() }
|
||||
self.assetRequestQueue = []
|
||||
}
|
||||
|
||||
private func segmentRequestDidSucceed(assetRequest: ProxiedContentAssetRequest, assetSegment: ProxiedContentAssetSegment) {
|
||||
DispatchQueue.main.async {
|
||||
assetSegment.state = .complete
|
||||
|
||||
if assetRequest.areAllSegmentsComplete() {
|
||||
// If the asset request has completed all of its segments,
|
||||
// try to write the asset to file.
|
||||
assetRequest.state = .complete
|
||||
|
||||
// Move write off main thread.
|
||||
DispatchQueue.global().async {
|
||||
guard let asset = assetRequest.writeAssetToFile(downloadFolderPath: self.downloadFolderPath) else {
|
||||
self.segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
|
||||
return
|
||||
}
|
||||
self.assetRequestDidSucceed(assetRequest: assetRequest, asset: asset)
|
||||
}
|
||||
} else {
|
||||
self.processRequestQueueSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func assetRequestDidSucceed(assetRequest: ProxiedContentAssetRequest, asset: ProxiedContentAsset) {
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.assetMap.set(key: assetRequest.rendition.url, value: asset)
|
||||
self.removeAssetRequestFromQueue(assetRequest: assetRequest)
|
||||
assetRequest.requestDidSucceed(asset: asset)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: If we wanted to implement segment retry, we'll need to add
|
||||
// a segmentRequestDidFail() method.
|
||||
private func segmentRequestDidFail(assetRequest: ProxiedContentAssetRequest, assetSegment: ProxiedContentAssetSegment) {
|
||||
DispatchQueue.main.async {
|
||||
assetSegment.state = .failed
|
||||
assetRequest.state = .failed
|
||||
self.assetRequestDidFail(assetRequest: assetRequest)
|
||||
}
|
||||
}
|
||||
|
||||
private func assetRequestDidFail(assetRequest: ProxiedContentAssetRequest) {
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.removeAssetRequestFromQueue(assetRequest: assetRequest)
|
||||
assetRequest.requestDidFail()
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAssetRequestFromQueue(assetRequest: ProxiedContentAssetRequest) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard assetRequestQueue.contains(assetRequest) else {
|
||||
Logger.warn("could not remove asset request from queue: \(assetRequest.rendition.url)")
|
||||
return
|
||||
}
|
||||
|
||||
assetRequestQueue = assetRequestQueue.filter { $0 != assetRequest }
|
||||
// Process the queue async to ensure that state in the downloader
|
||||
// classes is consistent before we try to start a new request.
|
||||
processRequestQueueAsync()
|
||||
}
|
||||
|
||||
private func processRequestQueueAsync() {
|
||||
DispatchQueue.main.async {
|
||||
self.processRequestQueueSync()
|
||||
}
|
||||
}
|
||||
|
||||
// * Start a segment request or content length request if possible.
|
||||
// * Complete/cancel asset requests if possible.
|
||||
//
|
||||
private func processRequestQueueSync() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let assetRequest = popNextAssetRequest() else {
|
||||
return
|
||||
}
|
||||
guard !assetRequest.wasCancelled else {
|
||||
// Discard the cancelled asset request and try again.
|
||||
removeAssetRequestFromQueue(assetRequest: assetRequest)
|
||||
return
|
||||
}
|
||||
guard CurrentAppContext().isMainAppAndActive else {
|
||||
// If app is not active, fail the asset request.
|
||||
assetRequest.state = .failed
|
||||
assetRequestDidFail(assetRequest: assetRequest)
|
||||
processRequestQueueSync()
|
||||
return
|
||||
}
|
||||
|
||||
if let asset = assetMap.get(key: assetRequest.rendition.url) {
|
||||
// Deferred cache hit, avoids re-downloading assets that were
|
||||
// downloaded while this request was queued.
|
||||
|
||||
assetRequest.state = .complete
|
||||
assetRequestDidSucceed(assetRequest: assetRequest, asset: asset)
|
||||
return
|
||||
}
|
||||
|
||||
if assetRequest.state == .waiting {
|
||||
// If asset request hasn't yet determined the resource size,
|
||||
// try to do so now.
|
||||
assetRequest.state = .requestingSize
|
||||
|
||||
var request = URLRequest(url: assetRequest.rendition.url as URL)
|
||||
request.httpMethod = "HEAD"
|
||||
request.httpShouldUsePipelining = true
|
||||
|
||||
let task = downloadSession.dataTask(with: request, completionHandler: { data, response, error -> Void in
|
||||
if let data = data, data.count > 0 {
|
||||
owsFailDebug("HEAD request has unexpected body: \(data.count).")
|
||||
}
|
||||
self.handleAssetSizeResponse(assetRequest: assetRequest, response: response, error: error)
|
||||
})
|
||||
assetRequest.contentLengthTask = task
|
||||
task.resume()
|
||||
} else {
|
||||
// Start a download task.
|
||||
|
||||
guard let assetSegment = assetRequest.firstWaitingSegment() else {
|
||||
owsFailDebug("queued asset request does not have a waiting segment.")
|
||||
return
|
||||
}
|
||||
assetSegment.state = .downloading
|
||||
|
||||
var request = URLRequest(url: assetRequest.rendition.url as URL)
|
||||
request.httpShouldUsePipelining = true
|
||||
let rangeHeaderValue = "bytes=\(assetSegment.segmentStart)-\(assetSegment.segmentStart + assetSegment.segmentLength - 1)"
|
||||
request.addValue(rangeHeaderValue, forHTTPHeaderField: "Range")
|
||||
let task: URLSessionDataTask = downloadSession.dataTask(with: request)
|
||||
task.assetRequest = assetRequest
|
||||
task.assetSegment = assetSegment
|
||||
assetSegment.task = task
|
||||
task.resume()
|
||||
}
|
||||
|
||||
// Recurse; we may be able to start multiple downloads.
|
||||
processRequestQueueSync()
|
||||
}
|
||||
|
||||
private func handleAssetSizeResponse(assetRequest: ProxiedContentAssetRequest, response: URLResponse?, error: Error?) {
|
||||
guard error == nil else {
|
||||
assetRequest.state = .failed
|
||||
self.assetRequestDidFail(assetRequest: assetRequest)
|
||||
return
|
||||
}
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
owsFailDebug("Asset size response is invalid.")
|
||||
assetRequest.state = .failed
|
||||
self.assetRequestDidFail(assetRequest: assetRequest)
|
||||
return
|
||||
}
|
||||
guard let contentLengthString = httpResponse.allHeaderFields["Content-Length"] as? String else {
|
||||
owsFailDebug("Asset size response is missing content length.")
|
||||
assetRequest.state = .failed
|
||||
self.assetRequestDidFail(assetRequest: assetRequest)
|
||||
return
|
||||
}
|
||||
guard let contentLength = Int(contentLengthString) else {
|
||||
owsFailDebug("Asset size response has unparsable content length.")
|
||||
assetRequest.state = .failed
|
||||
self.assetRequestDidFail(assetRequest: assetRequest)
|
||||
return
|
||||
}
|
||||
guard contentLength > 0 else {
|
||||
owsFailDebug("Asset size response has invalid content length.")
|
||||
assetRequest.state = .failed
|
||||
self.assetRequestDidFail(assetRequest: assetRequest)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
assetRequest.contentLength = contentLength
|
||||
assetRequest.state = .active
|
||||
self.processRequestQueueSync()
|
||||
}
|
||||
}
|
||||
|
||||
// Return the first asset request for which we either:
|
||||
//
|
||||
// * Need to download the content length.
|
||||
// * Need to download at least one of its segments.
|
||||
private func popNextAssetRequest() -> ProxiedContentAssetRequest? {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
let kMaxAssetRequestCount: UInt = 3
|
||||
let kMaxAssetRequestsPerAssetCount: UInt = kMaxAssetRequestCount - 1
|
||||
|
||||
// Prefer the first "high" priority request;
|
||||
// fall back to the first "low" priority request.
|
||||
var activeAssetRequestsCount: UInt = 0
|
||||
for priority in [ProxiedContentRequestPriority.high, ProxiedContentRequestPriority.low] {
|
||||
for assetRequest in assetRequestQueue where assetRequest.priority == priority {
|
||||
switch assetRequest.state {
|
||||
case .waiting:
|
||||
// This asset request needs its content length.
|
||||
return assetRequest
|
||||
case .requestingSize:
|
||||
activeAssetRequestsCount += 1
|
||||
// Ensure that only N requests are active at a time.
|
||||
guard activeAssetRequestsCount < kMaxAssetRequestCount else {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
case .active:
|
||||
break
|
||||
case .complete:
|
||||
continue
|
||||
case .failed:
|
||||
continue
|
||||
}
|
||||
|
||||
let downloadingSegmentsCount = assetRequest.downloadingSegmentsCount()
|
||||
activeAssetRequestsCount += downloadingSegmentsCount
|
||||
// Ensure that only N segment requests are active per asset at a time.
|
||||
guard downloadingSegmentsCount < kMaxAssetRequestsPerAssetCount else {
|
||||
continue
|
||||
}
|
||||
// Ensure that only N requests are active at a time.
|
||||
guard activeAssetRequestsCount < kMaxAssetRequestCount else {
|
||||
return nil
|
||||
}
|
||||
guard assetRequest.firstWaitingSegment() != nil else {
|
||||
/// Asset request does not have a waiting segment.
|
||||
continue
|
||||
}
|
||||
return assetRequest
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: URLSessionDataDelegate
|
||||
|
||||
@nonobjc
|
||||
public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
||||
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
let assetRequest = dataTask.assetRequest
|
||||
let assetSegment = dataTask.assetSegment
|
||||
guard !assetRequest.wasCancelled else {
|
||||
dataTask.cancel()
|
||||
segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
|
||||
return
|
||||
}
|
||||
assetSegment.append(data: data)
|
||||
}
|
||||
|
||||
public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
|
||||
// MARK: URLSessionTaskDelegate
|
||||
|
||||
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
|
||||
let assetRequest = task.assetRequest
|
||||
let assetSegment = task.assetSegment
|
||||
guard !assetRequest.wasCancelled else {
|
||||
task.cancel()
|
||||
segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
|
||||
return
|
||||
}
|
||||
if let error = error {
|
||||
Logger.error("download failed with error: \(error)")
|
||||
segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
|
||||
return
|
||||
}
|
||||
guard let httpResponse = task.response as? HTTPURLResponse else {
|
||||
Logger.error("missing or unexpected response: \(String(describing: task.response))")
|
||||
segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
|
||||
return
|
||||
}
|
||||
let statusCode = httpResponse.statusCode
|
||||
guard statusCode >= 200 && statusCode < 400 else {
|
||||
Logger.error("response has invalid status code: \(statusCode)")
|
||||
segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
|
||||
return
|
||||
}
|
||||
guard assetSegment.totalDataSize() == assetSegment.segmentLength else {
|
||||
Logger.error("segment is missing data: \(statusCode)")
|
||||
segmentRequestDidFail(assetRequest: assetRequest, assetSegment: assetSegment)
|
||||
return
|
||||
}
|
||||
|
||||
segmentRequestDidSucceed(assetRequest: assetRequest, assetSegment: assetSegment)
|
||||
}
|
||||
|
||||
// MARK: Temp Directory
|
||||
|
||||
public func ensureDownloadFolder() {
|
||||
// We write assets to the temporary directory so that iOS can clean them up.
|
||||
// We try to eagerly clean up these assets when they are no longer in use.
|
||||
|
||||
let tempDirPath = OWSTemporaryDirectory()
|
||||
let dirPath = (tempDirPath as NSString).appendingPathComponent("GIFs")
|
||||
do {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
// Try to delete existing folder if necessary.
|
||||
if fileManager.fileExists(atPath: dirPath) {
|
||||
try fileManager.removeItem(atPath: dirPath)
|
||||
downloadFolderPath = dirPath
|
||||
}
|
||||
// Try to create folder if necessary.
|
||||
if !fileManager.fileExists(atPath: dirPath) {
|
||||
try fileManager.createDirectory(atPath: dirPath,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil)
|
||||
downloadFolderPath = dirPath
|
||||
}
|
||||
|
||||
// Don't back up ProxiedContent downloads.
|
||||
OWSFileSystem.protectFileOrFolder(atPath: dirPath)
|
||||
} catch let error as NSError {
|
||||
owsFailDebug("ensureTempFolder failed: \(dirPath), \(error)")
|
||||
downloadFolderPath = tempDirPath
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
@objc
|
Loading…
Reference in a new issue