diff --git a/Signal/src/ViewControllers/MediaGalleryViewController.swift b/Signal/src/ViewControllers/MediaGalleryViewController.swift index 84269a424..3e4fdf88a 100644 --- a/Signal/src/ViewControllers/MediaGalleryViewController.swift +++ b/Signal/src/ViewControllers/MediaGalleryViewController.swift @@ -25,7 +25,16 @@ public struct MediaGalleryItem: Equatable { return attachmentStream.isVideo() } - var image: UIImage { + var thumbnailImage: UIImage { + guard let image = attachmentStream.thumbnailImage else { + owsFail("\(logTag) in \(#function) unexpectedly unable to build attachment thumbnail") + return UIImage() + } + + return image + } + + var fullSizedImage: UIImage { guard let image = attachmentStream.image() else { owsFail("\(logTag) in \(#function) unexpectedly unable to build attachment image") return UIImage() @@ -280,7 +289,7 @@ class MediaGalleryViewController: UINavigationController, MediaGalleryDataSource // loadView hasn't necessarily been called yet. self.loadViewIfNeeded() - self.presentationView.image = self.initialGalleryItem.image + self.presentationView.image = self.initialGalleryItem.fullSizedImage self.applyInitialMediaViewConstraints() // We want to animate the tapped media from it's position in the previous VC diff --git a/Signal/src/ViewControllers/MediaPageViewController.swift b/Signal/src/ViewControllers/MediaPageViewController.swift index 9b18172e6..fe4c02d24 100644 --- a/Signal/src/ViewControllers/MediaPageViewController.swift +++ b/Signal/src/ViewControllers/MediaPageViewController.swift @@ -23,7 +23,8 @@ public struct MediaGalleryPage: Equatable { } public var image: UIImage { - return galleryItem.image + // TODO cache this? + return galleryItem.fullSizedImage } // MARK: Equatable diff --git a/Signal/src/ViewControllers/MediaTileViewController.swift b/Signal/src/ViewControllers/MediaTileViewController.swift index f1bb4cc5e..254316d01 100644 --- a/Signal/src/ViewControllers/MediaTileViewController.swift +++ b/Signal/src/ViewControllers/MediaTileViewController.swift @@ -489,7 +489,7 @@ public class MediaGalleryCell: UICollectionViewCell { public func configure(item: MediaGalleryItem, delegate: MediaGalleryCellDelegate) { self.item = item - self.imageView.image = item.image + self.imageView.image = item.thumbnailImage self.delegate = delegate } diff --git a/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.h b/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.h index 974e66b9d..8b10fa276 100644 --- a/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.h +++ b/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.h @@ -29,6 +29,11 @@ NS_ASSUME_NONNULL_BEGIN // messages received from other clients @property (nullable, nonatomic) NSData *digest; +// A serialized image which lives in the db, accessible without additional disk access. +// This is useful for the gallery view which allows us to fetch potentially many +// attachments in a single read. +@property (nullable, readonly) UIImage *thumbnailImage; + // This only applies for attachments being uploaded. @property (atomic) BOOL isUploaded; @@ -36,6 +41,7 @@ NS_ASSUME_NONNULL_BEGIN #if TARGET_OS_IPHONE - (nullable UIImage *)image; +- (nullable UIImage *)thumbnailImage; #endif - (BOOL)isAnimated; diff --git a/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m b/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m index 778bb3f94..15b3a9f50 100644 --- a/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m +++ b/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m @@ -49,6 +49,7 @@ NS_ASSUME_NONNULL_BEGIN _creationTimestamp = [NSDate new]; [self ensureFilePath]; + [self ensureThumbnail]; return self; } @@ -71,6 +72,7 @@ NS_ASSUME_NONNULL_BEGIN _creationTimestamp = [NSDate new]; [self ensureFilePath]; + [self ensureThumbnail]; return self; } @@ -90,6 +92,9 @@ NS_ASSUME_NONNULL_BEGIN _creationTimestamp = [NSDate new]; } + // This is going to be slow the first time it runs. + [self ensureThumbnail]; + return self; } @@ -224,6 +229,25 @@ NS_ASSUME_NONNULL_BEGIN return [[[self class] attachmentsFolder] stringByAppendingPathComponent:self.localRelativeFilePath]; } +- (nullable NSString *)thumbnailPath +{ + NSString *filePath = self.filePath; + if (!filePath) { + OWSFail(@"%@ Attachment missing local file path.", self.logTag); + return nil; + } + + if (!self.isImage && !self.isVideo && !self.isAnimated) { + return nil; + } + + NSString *filename = filePath.lastPathComponent.stringByDeletingPathExtension; + NSString *containingDir = filePath.stringByDeletingLastPathComponent; + NSString *newFilename = [filename stringByAppendingString:@"-thumbnail"]; + + return [[containingDir stringByAppendingPathComponent:newFilename] stringByAppendingPathExtension:@"jpg"]; +} + - (nullable NSURL *)mediaURL { NSString *_Nullable filePath = self.filePath; @@ -290,18 +314,100 @@ NS_ASSUME_NONNULL_BEGIN } } +- (nullable UIImage *)thumbnailImage +{ + NSString *thumbnailPath = self.thumbnailPath; + if (!thumbnailPath) { + OWSAssert(!self.isImage && !self.isVideo && !self.isAnimated); + + return nil; + } + + if (![[NSFileManager defaultManager] fileExistsAtPath:thumbnailPath]) { + OWSFail(@"%@ missing thumbnail for attachmentId: %@", self.logTag, self.uniqueId); + + return nil; + } + + return [UIImage imageWithContentsOfFile:self.thumbnailPath]; +} + +- (void)ensureThumbnail +{ + NSString *thumbnailPath = self.thumbnailPath; + if (!thumbnailPath) { + return; + } + + if ([[NSFileManager defaultManager] fileExistsAtPath:thumbnailPath]) { + // already exists + return; + } + + if (![[NSFileManager defaultManager] fileExistsAtPath:self.mediaURL.path]) { + OWSFail(@"%@ while generating thumbnail, source file doesn't exist: %@", self.logTag, self.mediaURL) return; + } + + // TODO proper resolution? + CGFloat thumbnailSize = 200; + + UIImage *_Nullable result; + if (self.isImage || self.isAnimated) { + CGImageSourceRef imageSource = CGImageSourceCreateWithURL((__bridge CFURLRef)self.mediaURL, NULL); + OWSAssert(imageSource != NULL) NSDictionary *imageOptions = @{ + (NSString const *)kCGImageSourceCreateThumbnailFromImageIfAbsent : (NSNumber const *)kCFBooleanTrue, + (NSString const *)kCGImageSourceThumbnailMaxPixelSize : @(thumbnailSize), + (NSString const *)kCGImageSourceCreateThumbnailWithTransform : (NSNumber const *)kCFBooleanTrue + }; + CGImageRef thumbnail + = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, (__bridge CFDictionaryRef)imageOptions); + CFRelease(imageSource); + + result = [[UIImage alloc] initWithCGImage:thumbnail]; + CGImageRelease(thumbnail); + + } else if (self.isVideo) { + result = [self videoStillImageWithMaxSize:CGSizeMake(thumbnailSize, thumbnailSize)]; + } else { + OWSFail(@"%@ trying to generate thumnail for unexpected attachment: %@ of type: %@", + self.logTag, + self.uniqueId, + self.contentType); + } + + if (result == nil) { + OWSFail(@"%@ Unable to build thumnail for attachmentId: %@", self.logTag, self.uniqueId); + return; + } + + NSData *thumbnailData = UIImageJPEGRepresentation(result, 0.9); + + OWSAssert(thumbnailData.length > 0) + DDLogDebug(@"%@ generated thumbnail with size: %lu", self.logTag, (unsigned long)thumbnailData.length); + [thumbnailData writeToFile:thumbnailPath atomically:YES]; +} + - (nullable UIImage *)videoStillImage +{ + // Uses the assets intrinsic size by default + return [self videoStillImageWithMaxSize:CGSizeZero]; +} + +- (nullable UIImage *)videoStillImageWithMaxSize:(CGSize)maxSize { NSURL *_Nullable mediaUrl = [self mediaURL]; if (!mediaUrl) { return nil; } AVURLAsset *asset = [[AVURLAsset alloc] initWithURL:mediaUrl options:nil]; - AVAssetImageGenerator *generate = [[AVAssetImageGenerator alloc] initWithAsset:asset]; - generate.appliesPreferredTrackTransform = YES; - NSError *err = NULL; - CMTime time = CMTimeMake(1, 60); - CGImageRef imgRef = [generate copyCGImageAtTime:time actualTime:NULL error:&err]; + + AVAssetImageGenerator *generator = [[AVAssetImageGenerator alloc] initWithAsset:asset]; + generator.maximumSize = maxSize; + generator.appliesPreferredTrackTransform = YES; + NSError *err = NULL; + CMTime time = CMTimeMake(1, 60); + CGImageRef imgRef = [generator copyCGImageAtTime:time actualTime:NULL error:&err]; + return [[UIImage alloc] initWithCGImage:imgRef]; }