diff --git a/Signal/Signal-Info.plist b/Signal/Signal-Info.plist index b4ed0ec7c..96a81db1a 100644 --- a/Signal/Signal-Info.plist +++ b/Signal/Signal-Info.plist @@ -6,14 +6,10 @@ CarthageVersion 0.33.0 - DateTime - Fri Sep 13 00:30:48 UTC 2019 OSXVersion 10.14.6 WebRTCCommit 1445d719bf05280270e9f77576f80f973fd847f8 M73 - XCodeVersion - 1000.1030 CFBundleDevelopmentRegion en diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m index 39687b8c1..83044897c 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m @@ -962,16 +962,18 @@ const CGFloat kMaxTextViewHeight = 98; // It's key that we use the *raw/unstripped* text, so we can reconcile cursor position with the // selectedRange. - NSString *_Nullable previewUrl = [OWSLinkPreview previewUrlForRawBodyText:self.inputTextView.text - selectedRange:self.inputTextView.selectedRange]; + NSString *_Nullable previewUrl = [OWSLinkPreview previewUrlForRawBodyText:self.inputTextView.text selectedRange:self.inputTextView.selectedRange]; + + if ([previewUrl hasSuffix:@".gif"]) { + return [self clearLinkPreviewStateAndView]; + } + if (previewUrl.length < 1) { - [self clearLinkPreviewStateAndView]; - return; + return [self clearLinkPreviewStateAndView]; } if (self.inputLinkPreview && [self.inputLinkPreview.previewUrl isEqualToString:previewUrl]) { - // No need to update. - return; + return; // No need to update. } InputLinkPreview *inputLinkPreview = [InputLinkPreview new]; diff --git a/SignalServiceKit/src/Loki/API/LokiAPI.swift b/SignalServiceKit/src/Loki/API/LokiAPI.swift index c81a39589..826a0e74b 100644 --- a/SignalServiceKit/src/Loki/API/LokiAPI.swift +++ b/SignalServiceKit/src/Loki/API/LokiAPI.swift @@ -4,7 +4,10 @@ import PromiseKit public final class LokiAPI : NSObject { internal static let storage = OWSPrimaryStorage.shared() - internal static var userHexEncodedPublicKey: String { return OWSIdentityManager.shared().identityKeyPair()!.hexEncodedPublicKey } + // MARK: Convenience + internal static var userHexEncodedPublicKey: String { + return OWSIdentityManager.shared().identityKeyPair()!.hexEncodedPublicKey + } // MARK: Settings private static let version = "v1" diff --git a/SignalServiceKit/src/Messages/Attachments/TSAttachment.h b/SignalServiceKit/src/Messages/Attachments/TSAttachment.h index fea89979b..6b6e6b61c 100644 --- a/SignalServiceKit/src/Messages/Attachments/TSAttachment.h +++ b/SignalServiceKit/src/Messages/Attachments/TSAttachment.h @@ -43,7 +43,7 @@ typedef NS_ENUM(NSUInteger, TSAttachmentType) { #pragma mark - Media Album @property (nonatomic, readonly, nullable) NSString *caption; -@property (nonatomic, readonly, nullable) NSString *albumMessageId; +@property (nonatomic, nullable) NSString *albumMessageId; - (nullable TSMessage *)fetchAlbumMessageWithTransaction:(YapDatabaseReadTransaction *)transaction; // `migrateAlbumMessageId` is only used in the migration to the new multi-attachment message scheme, diff --git a/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift b/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift index 5c639d05a..c98a26d74 100644 --- a/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift +++ b/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift @@ -13,6 +13,8 @@ public enum LinkPreviewError: Int, Error { case couldNotDownload case featureDisabled case invalidContent + case invalidMediaContent + case attachmentFailedToSave } // MARK: - OWSLinkPreviewDraft @@ -81,12 +83,17 @@ public class OWSLinkPreview: MTLModel { @objc public var imageAttachmentId: String? + + // Whether this preview can be rendered as an attachment + @objc + public var isDirectAttachment: Bool = false @objc - public init(urlString: String, title: String?, imageAttachmentId: String?) { + public init(urlString: String, title: String?, imageAttachmentId: String?, isDirectAttachment: Bool = false) { self.urlString = urlString self.title = title self.imageAttachmentId = imageAttachmentId + self.isDirectAttachment = isDirectAttachment super.init() } @@ -113,6 +120,12 @@ public class OWSLinkPreview: MTLModel { } return error == .noPreview } + + @objc + public class func isInvalidContentError(_ error: Error) -> Bool { + guard let error = error as? LinkPreviewError else { return false } + return error == .invalidContent + } @objc public class func buildValidatedLinkPreview(dataMessage: SSKProtoDataMessage, @@ -203,33 +216,35 @@ public class OWSLinkPreview: MTLModel { return linkPreview } - + private class func saveAttachmentIfPossible(jpegImageData: Data?, transaction: YapDatabaseReadWriteTransaction) -> String? { - guard let jpegImageData = jpegImageData else { - return nil - } - let fileSize = jpegImageData.count + return saveAttachmentIfPossible(imageData: jpegImageData, mimeType: OWSMimeTypeImageJpeg, transaction: transaction); + } + + private class func saveAttachmentIfPossible(imageData: Data?, mimeType: String, transaction: YapDatabaseReadWriteTransaction) -> String? { + guard let imageData = imageData else { return nil } + + let fileSize = imageData.count guard fileSize > 0 else { owsFailDebug("Invalid file size for image data.") return nil } - let fileExtension = "jpg" - let contentType = OWSMimeTypeImageJpeg - + + guard let fileExtension = fileExtension(forMimeType: mimeType) else { return nil } let filePath = OWSFileSystem.temporaryFilePath(withFileExtension: fileExtension) do { - try jpegImageData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite) + try imageData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite) } catch let error as NSError { owsFailDebug("file write failed: \(filePath), \(error)") return nil } - + guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath, shouldDeleteOnDeallocation: true) else { owsFailDebug("Could not create data source for path: \(filePath)") return nil } - let attachment = TSAttachmentStream(contentType: contentType, byteCount: UInt32(fileSize), sourceFilename: nil, caption: nil, albumMessageId: nil) + let attachment = TSAttachmentStream(contentType: mimeType, byteCount: UInt32(fileSize), sourceFilename: nil, caption: nil, albumMessageId: nil) guard attachment.write(dataSource) else { owsFailDebug("Could not write data source for path: \(filePath)") return nil @@ -318,7 +333,13 @@ public class OWSLinkPreview: MTLModel { // Pinterest "pinterest.com", "www.pinterest.com", - "pin.it" + "pin.it", + + // Giphy + "giphy.com", + "media.giphy.com", + "gph.is" + ] // For media domains, we DO NOT require an exact match - subdomains are allowed. @@ -337,7 +358,10 @@ public class OWSLinkPreview: MTLModel { "fbcdn.net", // Pinterest - "pinimg.com" + "pinimg.com", + + // Giphy + "giphy.com" ] private static let protocolWhitelist = [ @@ -672,6 +696,65 @@ public class OWSLinkPreview: MTLModel { }) return promise } + + public class func getImagePreview(from url: String, in transaction: YapDatabaseReadWriteTransaction) -> Promise { + // Get the MIME type + guard let imageFileExtension = fileExtension(forImageUrl: url), let imageMIMEType = mimetype(forImageFileExtension: imageFileExtension) else { + return Promise(error: LinkPreviewError.invalidInput) + } + + return downloadImage(url: url).map { data in + // Make sure the downloaded image has the correct MIME type + guard let newImageMIMEType = NSData(data: data).ows_guessMimeType() else { + throw LinkPreviewError.invalidContent + } + + // Save the attachment + guard let attachmentId = saveAttachmentIfPossible(imageData: data, mimeType: newImageMIMEType, transaction: transaction) else { + Logger.verbose("Failed to save attachment for: \(url).") + throw LinkPreviewError.attachmentFailedToSave + } + + // If it's a GIF and the data we have is not a GIF then we need to render a link preview without attachments + if (imageMIMEType == OWSMimeTypeImageGif && newImageMIMEType != OWSMimeTypeImageGif) { + return OWSLinkPreview(urlString: url, title: nil, imageAttachmentId: attachmentId) + } + + return OWSLinkPreview(urlString: url, title: nil, imageAttachmentId: attachmentId, isDirectAttachment: true) + } + } + + @objc(getImagePreviewFromUrl:transaction:) + public class func objc_getImagePreview(url: String, in transaction: YapDatabaseReadWriteTransaction) -> AnyPromise { + return AnyPromise.from(getImagePreview(from: url, in: transaction)) + } + + public class func downloadImage(url imageUrl: String) -> Promise { + guard OWSLinkPreview.featureEnabled else { + return Promise(error: LinkPreviewError.featureDisabled) + } + + guard SSKPreferences.areLinkPreviewsEnabled else { + return Promise(error: LinkPreviewError.featureDisabled) + } + + guard isValidMediaUrl(imageUrl) else { + Logger.error("Invalid image URL.") + return Promise.init(error: LinkPreviewError.invalidInput) + } + + guard let imageFileExtension = fileExtension(forImageUrl: imageUrl) else { + Logger.error("Image URL has unknown or invalid file extension: \(imageUrl).") + return Promise.init(error: LinkPreviewError.invalidInput) + } + + guard let imageMimeType = mimetype(forImageFileExtension: imageFileExtension) else { + Logger.error("Image URL has unknown or invalid content type: \(imageUrl).") + return Promise.init(error: LinkPreviewError.invalidInput) + } + + return downloadImage(url: imageUrl, imageMimeType: imageMimeType) + } private class func downloadImage(url urlString: String, imageMimeType: String) -> Promise { @@ -710,6 +793,9 @@ public class OWSLinkPreview: MTLModel { Logger.error("Could not parse image.") return Promise(error: LinkPreviewError.invalidContent) } + + // Loki: If it's a GIF then ensure its validity and don't download it as a JPG + if (imageMimeType == OWSMimeTypeImageGif && NSData(data: data).ows_isValidImage(withMimeType: OWSMimeTypeImageGif)) { return Promise.value(data) } let maxImageSize: CGFloat = 1024 let shouldResize = imageSize.width > maxImageSize || imageSize.height > maxImageSize @@ -830,6 +916,15 @@ public class OWSLinkPreview: MTLModel { } return imageFileExtension } + + class func fileExtension(forMimeType mimeType: String) -> String? { + switch mimeType { + case OWSMimeTypeImageGif: return "gif" + case OWSMimeTypeImagePng: return "png" + case OWSMimeTypeImageJpeg: return "jpg" + default: return nil + } + } class func mimetype(forImageFileExtension imageFileExtension: String) -> String? { guard imageFileExtension.count > 0 else { @@ -841,7 +936,8 @@ public class OWSLinkPreview: MTLModel { } let kValidMimeTypes = [ OWSMimeTypeImagePng, - OWSMimeTypeImageJpeg + OWSMimeTypeImageJpeg, + OWSMimeTypeImageGif, ] guard kValidMimeTypes.contains(imageMimeType) else { Logger.error("Image URL has invalid content type: \(imageMimeType).") diff --git a/SignalServiceKit/src/Messages/Interactions/TSMessage.h b/SignalServiceKit/src/Messages/Interactions/TSMessage.h index 2488fd502..d115a4fe0 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSMessage.h +++ b/SignalServiceKit/src/Messages/Interactions/TSMessage.h @@ -67,6 +67,7 @@ typedef NS_ENUM(NSInteger, LKMessageFriendRequestStatus) { - (NSArray *)attachmentsWithTransaction:(YapDatabaseReadTransaction *)transaction; - (NSArray *)mediaAttachmentsWithTransaction:(YapDatabaseReadTransaction *)transaction; - (nullable TSAttachment *)oversizeTextAttachmentWithTransaction:(YapDatabaseReadTransaction *)transaction; +- (void)addAttachmentWithID:(NSString *)attachmentID in:(YapDatabaseReadWriteTransaction *)transaction; - (void)removeAttachment:(TSAttachment *)attachment transaction:(YapDatabaseReadWriteTransaction *)transaction NS_SWIFT_NAME(removeAttachment(_:transaction:)); @@ -97,6 +98,10 @@ typedef NS_ENUM(NSInteger, LKMessageFriendRequestStatus) { - (void)saveGroupChatMessageID:(uint64_t)serverMessageID in:(YapDatabaseReadWriteTransaction *_Nullable)transaction; +#pragma mark - Link Preview + +- (void)generateLinkPreviewIfNeededFromURL:(NSString *)url; + @end NS_ASSUME_NONNULL_END diff --git a/SignalServiceKit/src/Messages/Interactions/TSMessage.m b/SignalServiceKit/src/Messages/Interactions/TSMessage.m index 66f7922e0..033d5a80d 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSMessage.m +++ b/SignalServiceKit/src/Messages/Interactions/TSMessage.m @@ -250,6 +250,12 @@ static const NSUInteger OWSMessageSchemaVersion = 4; [self saveWithTransaction:transaction]; } +- (void)addAttachmentWithID:(NSString *)attachmentID in:(YapDatabaseReadWriteTransaction *)transaction { + if (!self.attachmentIds) { return; } + [self.attachmentIds addObject:attachmentID]; + [self saveWithTransaction:transaction]; +} + - (NSString *)debugDescription { if ([self hasAttachments] && self.body.length > 0) { @@ -506,6 +512,50 @@ static const NSUInteger OWSMessageSchemaVersion = 4; } } +#pragma mark - Link Preview + + +- (void)generateLinkPreviewIfNeededFromURL:(NSString *)url { + // If we already have a link preview or attachment then don't bother + if (self.linkPreview != nil || self.hasAttachments) { return; } + [OWSLinkPreview tryToBuildPreviewInfoObjcWithPreviewUrl:url] + .thenOn(dispatch_get_main_queue(), ^(OWSLinkPreviewDraft *linkPreviewDraft) { + [self.dbReadWriteConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + OWSLinkPreview *linkPreview = [OWSLinkPreview buildValidatedLinkPreviewFromInfo:linkPreviewDraft transaction:transaction error:nil]; + self.linkPreview = linkPreview; + [self saveWithTransaction:transaction]; + }]; + }) + .catchOn(dispatch_get_main_queue(), ^(NSError *error) { + // If we failed to get a link preview due to an invalid content type error then this could be a direct image link + if ([OWSLinkPreview isInvalidContentError:error]) { + __block AnyPromise *promise; + [self.dbReadWriteConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + promise = [OWSLinkPreview getImagePreviewFromUrl:url transaction:transaction]; + }]; + return promise; + } + return [AnyPromise promiseWithValue:error]; + }) + .thenOn(dispatch_get_main_queue(), ^(OWSLinkPreview *linkPreview) { + // If we managed to get a direct image preview then render it + [self.dbReadWriteConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { + if (linkPreview.isDirectAttachment) { + if (!self.hasAttachments) { + [self addAttachmentWithID:linkPreview.imageAttachmentId in:transaction]; + TSAttachmentStream *linkPreviewAttachment = [TSAttachmentStream fetchObjectWithUniqueID:linkPreview.imageAttachmentId transaction:transaction]; + linkPreviewAttachment.albumMessageId = self.uniqueId; + linkPreviewAttachment.isUploaded = true; + [linkPreviewAttachment saveWithTransaction:transaction]; + } + } else { + self.linkPreview = linkPreview; + [self saveWithTransaction:transaction]; + } + }]; + }); +} + @end NS_ASSUME_NONNULL_END diff --git a/SignalServiceKit/src/Messages/OWSMessageManager.m b/SignalServiceKit/src/Messages/OWSMessageManager.m index 5d5bad36a..6c88dd9ab 100644 --- a/SignalServiceKit/src/Messages/OWSMessageManager.m +++ b/SignalServiceKit/src/Messages/OWSMessageManager.m @@ -1409,14 +1409,7 @@ NS_ASSUME_NONNULL_BEGIN dispatch_async(dispatch_get_main_queue(), ^{ NSString *url = [OWSLinkPreview previewURLForRawBodyText:incomingMessage.body]; if (url != nil) { - [OWSLinkPreview tryToBuildPreviewInfoObjcWithPreviewUrl:url] - .thenOn(dispatch_get_main_queue(), ^(OWSLinkPreviewDraft *linkPreviewDraft) { - [OWSPrimaryStorage.sharedManager.dbReadWriteConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - OWSLinkPreview *linkPreview = [OWSLinkPreview buildValidatedLinkPreviewFromInfo:linkPreviewDraft transaction:transaction error:nil]; - incomingMessage.linkPreview = linkPreview; - [incomingMessage saveWithTransaction:transaction]; - }]; - }); + [incomingMessage generateLinkPreviewIfNeededFromURL:url]; } }); @@ -1518,14 +1511,7 @@ NS_ASSUME_NONNULL_BEGIN linkPreviewURL = [OWSLinkPreview previewURLForRawBodyText:incomingMessage.body]; } if (linkPreviewURL != nil) { - [OWSLinkPreview tryToBuildPreviewInfoObjcWithPreviewUrl:linkPreviewURL] - .thenOn(dispatch_get_main_queue(), ^(OWSLinkPreviewDraft *linkPreviewDraft) { - [OWSPrimaryStorage.sharedManager.dbReadWriteConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - OWSLinkPreview *linkPreview = [OWSLinkPreview buildValidatedLinkPreviewFromInfo:linkPreviewDraft transaction:transaction error:nil]; - incomingMessage.linkPreview = linkPreview; - [incomingMessage saveWithTransaction:transaction]; - }]; - }); + [incomingMessage generateLinkPreviewIfNeededFromURL:linkPreviewURL]; } }); diff --git a/SignalServiceKit/src/Messages/OWSMessageSender.m b/SignalServiceKit/src/Messages/OWSMessageSender.m index ebde64588..3dbcd588f 100644 --- a/SignalServiceKit/src/Messages/OWSMessageSender.m +++ b/SignalServiceKit/src/Messages/OWSMessageSender.m @@ -1239,6 +1239,15 @@ NSString *const OWSMessageSenderRateLimitedException = @"RateLimitedException"; // have a valid Signal account. [SignalRecipient markRecipientAsRegisteredAndGet:recipient.recipientId transaction:transaction]; }]; + + // Loki: Check if we need to generate a link preview + TSMessage *message = messageSend.message; + if (message.linkPreview == nil && !message.hasAttachments) { + dispatch_async(dispatch_get_main_queue(), ^{ + NSString *url = [OWSLinkPreview previewURLForRawBodyText:message.body]; + if (url) { [message generateLinkPreviewIfNeededFromURL:url]; } + }); + } messageSend.success(); }); diff --git a/SignalServiceKit/src/Util/NSData+Image.h b/SignalServiceKit/src/Util/NSData+Image.h index 8ba02fff3..7c588fa03 100644 --- a/SignalServiceKit/src/Util/NSData+Image.h +++ b/SignalServiceKit/src/Util/NSData+Image.h @@ -12,6 +12,7 @@ NS_ASSUME_NONNULL_BEGIN + (BOOL)ows_isValidImageAtPath:(NSString *)filePath mimeType:(nullable NSString *)mimeType; - (BOOL)ows_isValidImage; - (BOOL)ows_isValidImageWithMimeType:(nullable NSString *)mimeType; +- (NSString *_Nullable)ows_guessMimeType; // Returns the image size in pixels. // diff --git a/SignalServiceKit/src/Util/NSData+Image.m b/SignalServiceKit/src/Util/NSData+Image.m index 13610f8ab..1463758fa 100644 --- a/SignalServiceKit/src/Util/NSData+Image.m +++ b/SignalServiceKit/src/Util/NSData+Image.m @@ -263,6 +263,17 @@ typedef NS_ENUM(NSInteger, ImageFormat) { return ImageFormat_Unknown; } +- (NSString *_Nullable)ows_guessMimeType +{ + ImageFormat format = [self ows_guessImageFormat]; + switch (format) { + case ImageFormat_Gif: return OWSMimeTypeImageGif; + case ImageFormat_Png: return OWSMimeTypeImagePng; + case ImageFormat_Jpeg: return OWSMimeTypeImageJpeg; + default: return nil; + } +} + + (BOOL)ows_areByteArraysEqual:(NSUInteger)length left:(unsigned char *)left right:(unsigned char *)right { for (NSUInteger i = 0; i < length; i++) {