diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m index 4fac6d6e7..cf684f8ec 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m @@ -11,6 +11,7 @@ #import "UIColor+OWS.h" #import "UIFont+OWS.h" #import "ViewControllerUtils.h" +#import #import #import #import @@ -739,27 +740,24 @@ const CGFloat kMaxTextViewHeight = 98; [self ensureLinkPreviewViewWithState:[LinkPreviewLoading new]]; __weak ConversationInputToolbar *weakSelf = self; - [OWSLinkPreview tryToBuildPreviewInfoWithPreviewUrl:previewUrl - callbackQueue:dispatch_get_main_queue() - completion:^(OWSLinkPreviewDraft *_Nullable linkPreviewDraft) { - ConversationInputToolbar *_Nullable strongSelf = weakSelf; - if (!strongSelf) { - return; - } - if (strongSelf.inputLinkPreview != inputLinkPreview) { - // Obsolete callback. - return; - } - if (!linkPreviewDraft) { - // The link preview could not be loaded. - [strongSelf clearLinkPreviewView]; - return; - } - inputLinkPreview.linkPreviewDraft = linkPreviewDraft; - LinkPreviewDraft *viewState = [[LinkPreviewDraft alloc] - initWithLinkPreviewDraft:linkPreviewDraft]; - [strongSelf ensureLinkPreviewViewWithState:viewState]; - }]; + [[OWSLinkPreview tryToBuildPreviewInfoObjcWithPreviewUrl:previewUrl] + .then(^(OWSLinkPreviewDraft *linkPreviewDraft) { + ConversationInputToolbar *_Nullable strongSelf = weakSelf; + if (!strongSelf) { + return; + } + if (strongSelf.inputLinkPreview != inputLinkPreview) { + // Obsolete callback. + return; + } + inputLinkPreview.linkPreviewDraft = linkPreviewDraft; + LinkPreviewDraft *viewState = [[LinkPreviewDraft alloc] initWithLinkPreviewDraft:linkPreviewDraft]; + [strongSelf ensureLinkPreviewViewWithState:viewState]; + }) + .catch(^(id error) { + // The link preview could not be loaded. + [weakSelf clearLinkPreviewView]; + }) retainUntilComplete]; } - (void)ensureLinkPreviewViewWithState:(id)state diff --git a/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift b/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift index c3cea1fdd..078fd50cb 100644 --- a/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift +++ b/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift @@ -3,11 +3,15 @@ // import Foundation +import PromiseKit @objc public enum LinkPreviewError: Int, Error { case invalidInput case noPreview + case assertionFailure + case couldNotDownload + case featureDisabled } // MARK: - OWSLinkPreviewDraft @@ -424,70 +428,62 @@ public class OWSLinkPreview: MTLModel { // This cache should only be accessed on serialQueue. private static var linkPreviewDraftCache: NSCache = NSCache() + private class func cachedLinkPreview(forPreviewUrl previewUrl: String) -> OWSLinkPreviewDraft? { + var result: OWSLinkPreviewDraft? + serialQueue.sync { + result = linkPreviewDraftCache.object(forKey: previewUrl as AnyObject) + } + return result + } + + private class func setCachedLinkPreview(_ linkPreviewDraft: OWSLinkPreviewDraft, + forPreviewUrl previewUrl: String) { + serialQueue.sync { + previewUrlCache.setObject(linkPreviewDraft, forKey: previewUrl as AnyObject) + } + } + + @objc + public class func tryToBuildPreviewInfoObjc(previewUrl: String?) -> AnyPromise { + return AnyPromise(tryToBuildPreviewInfo(previewUrl: previewUrl)) + } + // Completion will always be invoked exactly once. // // The completion is called with a link preview if one can be built for // the message body. It building the preview fails, completion will be // called with nil to avoid failing the message send. - @objc - public class func tryToBuildPreviewInfo(previewUrl: String?, - callbackQueue: DispatchQueue, - completion completionParam: @escaping (OWSLinkPreviewDraft?) -> Void) { - - // Ensure we invoke completion on the callback queue. - let completion = { (linkPreviewDraft) in - callbackQueue.async { - completionParam(linkPreviewDraft) - } - } + public class func tryToBuildPreviewInfo(previewUrl: String?) -> Promise { guard OWSLinkPreview.featureEnabled else { - completion(nil) - return + return Promise(error: LinkPreviewError.featureDisabled) } guard SSKPreferences.areLinkPreviewsEnabled() else { - completion(nil) - return + return Promise(error: LinkPreviewError.featureDisabled) } guard let previewUrl = previewUrl else { - completion(nil) - return + return Promise(error: LinkPreviewError.invalidInput) } - serialQueue.async { - if let cachedInfo = linkPreviewDraftCache.object(forKey: previewUrl as AnyObject) { - Logger.verbose("Link preview info cache hit.") - completion(cachedInfo) - return - } - downloadLink(url: previewUrl, completion: { (data) in - DispatchQueue.global().async { - guard let data = data else { - completion(nil) - return - } - parse(linkData: data, linkUrlString: previewUrl) { (linkPreviewDraft) in - guard let linkPreviewDraft = linkPreviewDraft else { - completion(nil) - return - } + if let cachedInfo = cachedLinkPreview(forPreviewUrl: previewUrl) { + Logger.verbose("Link preview info cache hit.") + return Promise.value(cachedInfo) + } + return downloadLink(url: previewUrl) + .then(on: DispatchQueue.global()) { (data) -> Promise in + return parse(linkData: data, linkUrlString: previewUrl) + .then(on: DispatchQueue.global()) { (linkPreviewDraft) -> Promise in guard linkPreviewDraft.isValid() else { - completion(nil) - return + return Promise(error: LinkPreviewError.noPreview) } - serialQueue.async { - previewUrlCache.setObject(linkPreviewDraft, forKey: previewUrl as AnyObject) + setCachedLinkPreview(linkPreviewDraft, forPreviewUrl: previewUrl) - completion(linkPreviewDraft) - } - } + return Promise.value(linkPreviewDraft) } - }) } } private class func downloadLink(url: String, - completion: @escaping (Data?) -> Void, - remainingRetries: UInt = 3) { + remainingRetries: UInt = 3) -> Promise { Logger.verbose("url: \(url)") @@ -507,6 +503,7 @@ public class OWSLinkPreview: MTLModel { sessionManager.requestSerializer.setValue(nil, forHTTPHeaderField: headerField) } + let (promise, resolver) = Promise.pending() sessionManager.get(url, parameters: [String: AnyObject](), progress: nil, @@ -514,65 +511,68 @@ public class OWSLinkPreview: MTLModel { guard let data = value as? Data else { Logger.warn("Result is not data: \(type(of: value)).") - completion(nil) + resolver.reject( LinkPreviewError.assertionFailure) return } - completion(data) + resolver.fulfill(data) }, failure: { _, error in Logger.verbose("Error: \(error)") guard isRetryable(error: error) else { Logger.warn("Error is not retryable.") - completion(nil) + resolver.reject( LinkPreviewError.couldNotDownload) return } guard remainingRetries > 0 else { Logger.warn("No more retries.") - completion(nil) + resolver.reject( LinkPreviewError.couldNotDownload) return } - OWSLinkPreview.downloadLink(url: url, completion: completion, remainingRetries: remainingRetries - 1) + OWSLinkPreview.downloadLink(url: url, remainingRetries: remainingRetries - 1) + .done(on: DispatchQueue.global()) { (data) in + resolver.fulfill(data) + }.catch(on: DispatchQueue.global()) { (error) in + resolver.reject( error) + }.retainUntilComplete() }) + return promise } - private class func downloadImage(url urlString: String, - completion: @escaping (Data?) -> Void) { + private class func downloadImage(url urlString: String) -> Promise { Logger.verbose("url: \(urlString)") guard let url = URL(string: urlString) else { Logger.error("Could not parse URL.") - return completion(nil) + return Promise(error: LinkPreviewError.invalidInput) } guard let assetDescription = ProxiedContentAssetDescription(url: url as NSURL) else { Logger.error("Could not create asset description.") - return completion(nil) + return Promise(error: LinkPreviewError.invalidInput) } + let (promise, resolver) = Promise.pending() 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) - } - } - + resolver.fulfill(asset) }, failure: { (_) in - DispatchQueue.global().async { - Logger.verbose("Error downloading asset") - - completion(nil) - } + Logger.warn("Error downloading asset") + resolver.reject(LinkPreviewError.couldNotDownload) }) } + return promise.then(on: DispatchQueue.global()) { (asset: ProxiedContentAsset) -> Promise in + do { + let data = try Data(contentsOf: URL(fileURLWithPath: asset.filePath)) + return Promise.value(data) + } catch { + owsFailDebug("Could not load asset data: \(type(of: asset.filePath)).") + return Promise(error: LinkPreviewError.assertionFailure) + } + } } private class func isRetryable(error: Error) -> Bool { @@ -589,12 +589,10 @@ public class OWSLinkPreview: MTLModel { // // private class func parse(linkData: Data, - linkUrlString: String, - completion: @escaping (OWSLinkPreviewDraft?) -> Void) { + linkUrlString: String) -> Promise { guard let linkText = String(bytes: linkData, encoding: .utf8) else { owsFailDebug("Could not parse link text.") - completion(nil) - return + return Promise(error: LinkPreviewError.invalidInput) } var title: String? @@ -610,25 +608,25 @@ public class OWSLinkPreview: MTLModel { Logger.verbose("title: \(String(describing: title))") guard let rawImageUrlString = NSRegularExpression.parseFirstMatch(pattern: "", text: linkText) else { - return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) + return Promise.value(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) } guard let imageUrlString = decodeHTMLEntities(inString: rawImageUrlString)?.ows_stripped() else { - return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) + return Promise.value(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) } guard isValidMediaUrl(imageUrlString) else { Logger.error("Invalid image URL.") - return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) + return Promise.value(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) } Logger.verbose("imageUrlString: \(imageUrlString)") guard let imageUrl = URL(string: imageUrlString) else { Logger.error("Could not parse image URL.") - return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) + return Promise.value(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) } let imageFilename = imageUrl.lastPathComponent let imageFileExtension = (imageFilename as NSString).pathExtension.lowercased() guard let imageMimeType = MIMETypeUtil.mimeType(forFileExtension: imageFileExtension) else { Logger.error("Image URL has unknown content type: \(imageFileExtension).") - return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) + return Promise.value(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) } let kValidMimeTypes = [ OWSMimeTypeImagePng, @@ -636,36 +634,35 @@ public class OWSLinkPreview: MTLModel { ] guard kValidMimeTypes.contains(imageMimeType) else { Logger.error("Image URL has invalid content type: \(imageMimeType).") - return completion(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) + return Promise.value(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) } - 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)) - } + return downloadImage(url: imageUrlString) + .then(on: DispatchQueue.global()) { (imageData: Data) -> Promise in + 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 Promise(error: LinkPreviewError.assertionFailure) + } + // 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 Promise(error: LinkPreviewError.assertionFailure) + } - let linkPreviewDraft = OWSLinkPreviewDraft(urlString: linkUrlString, title: title, imageFilePath: imageFilePath) - completion(linkPreviewDraft) - }) + let linkPreviewDraft = OWSLinkPreviewDraft(urlString: linkUrlString, title: title, imageFilePath: imageFilePath) + return Promise.value(linkPreviewDraft) + } + .recover(on: DispatchQueue.global()) { (_) -> Promise in + return Promise.value(OWSLinkPreviewDraft(urlString: linkUrlString, title: title)) + } } private class func decodeHTMLEntities(inString value: String) -> String? {