1113 lines
39 KiB
Objective-C
1113 lines
39 KiB
Objective-C
//
|
|
// Copyright 2017 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
#import "TSAttachmentStream.h"
|
|
#import "OWSError.h"
|
|
#import "OWSFileSystem.h"
|
|
#import "TSAttachmentPointer.h"
|
|
#import <AVFoundation/AVFoundation.h>
|
|
#import <SignalCoreKit/Threading.h>
|
|
#import <SignalServiceKit/SignalServiceKit-Swift.h>
|
|
#import <YYImage/YYImage.h>
|
|
|
|
NS_ASSUME_NONNULL_BEGIN
|
|
|
|
typedef void (^OWSLoadedThumbnailSuccess)(OWSLoadedThumbnail *loadedThumbnail);
|
|
|
|
NSString *NSStringForAttachmentThumbnailQuality(TSAttachmentThumbnailQuality value)
|
|
{
|
|
switch (value) {
|
|
case TSAttachmentThumbnailQuality_Small:
|
|
return @"Small";
|
|
case TSAttachmentThumbnailQuality_Medium:
|
|
return @"Medium";
|
|
case TSAttachmentThumbnailQuality_MediumLarge:
|
|
return @"MediumLarge";
|
|
case TSAttachmentThumbnailQuality_Large:
|
|
return @"Large";
|
|
}
|
|
}
|
|
|
|
@interface TSAttachmentStream ()
|
|
|
|
// We only want to generate the file path for this attachment once, so that
|
|
// changes in the file path generation logic don't break existing attachments.
|
|
@property (nullable, nonatomic) NSString *localRelativeFilePath;
|
|
|
|
// These properties should only be accessed while synchronized on self.
|
|
//
|
|
// In pixels, not points.
|
|
@property (nullable, nonatomic) NSNumber *cachedImageWidth;
|
|
@property (nullable, nonatomic) NSNumber *cachedImageHeight;
|
|
|
|
// This property should only be accessed on the main thread.
|
|
@property (nullable, nonatomic) NSNumber *cachedAudioDurationSeconds;
|
|
|
|
@property (atomic, nullable) NSNumber *isValidImageCached;
|
|
@property (atomic, nullable) NSNumber *isValidVideoCached;
|
|
@property (atomic, nullable) NSNumber *isAnimatedCached;
|
|
|
|
@end
|
|
|
|
#pragma mark -
|
|
|
|
@implementation TSAttachmentStream
|
|
|
|
- (instancetype)initWithContentType:(NSString *)contentType
|
|
byteCount:(UInt32)byteCount
|
|
sourceFilename:(nullable NSString *)sourceFilename
|
|
caption:(nullable NSString *)caption
|
|
attachmentType:(TSAttachmentType)attachmentType
|
|
albumMessageId:(nullable NSString *)albumMessageId
|
|
{
|
|
self = [super initAttachmentWithContentType:contentType
|
|
byteCount:byteCount
|
|
sourceFilename:sourceFilename
|
|
caption:caption
|
|
attachmentType:attachmentType
|
|
albumMessageId:albumMessageId];
|
|
if (!self) {
|
|
return self;
|
|
}
|
|
|
|
// TSAttachmentStream doesn't have any "incoming vs. outgoing"
|
|
// state, but this constructor is used only for new outgoing
|
|
// attachments which haven't been uploaded yet.
|
|
_isUploaded = NO;
|
|
_creationTimestamp = [NSDate new];
|
|
|
|
[self ensureFilePath];
|
|
|
|
return self;
|
|
}
|
|
|
|
- (instancetype)initWithPointer:(TSAttachmentPointer *)pointer transaction:(SDSAnyReadTransaction *)transaction
|
|
{
|
|
// Once saved, this AttachmentStream will replace the AttachmentPointer in the attachments collection.
|
|
self = [super initWithPointer:pointer transaction:transaction];
|
|
if (!self) {
|
|
return self;
|
|
}
|
|
|
|
OWSAssertDebug([NSObject isNullableObject:self.contentType equalTo:pointer.contentType]);
|
|
|
|
// TSAttachmentStream doesn't have any "incoming vs. outgoing"
|
|
// state, but this constructor is used only for new incoming
|
|
// attachments which don't need to be uploaded.
|
|
_isUploaded = YES;
|
|
self.digest = pointer.digest;
|
|
_creationTimestamp = [NSDate new];
|
|
|
|
[self ensureFilePath];
|
|
|
|
return self;
|
|
}
|
|
|
|
- (nullable instancetype)initWithCoder:(NSCoder *)coder
|
|
{
|
|
self = [super initWithCoder:coder];
|
|
if (!self) {
|
|
return self;
|
|
}
|
|
|
|
// OWS105AttachmentFilePaths will ensure the file path is saved if necessary.
|
|
[self ensureFilePath];
|
|
|
|
// OWS105AttachmentFilePaths will ensure the creation timestamp is saved if necessary.
|
|
if (!_creationTimestamp) {
|
|
_creationTimestamp = [NSDate new];
|
|
}
|
|
|
|
return self;
|
|
}
|
|
|
|
// --- CODE GENERATION MARKER
|
|
|
|
// This snippet is generated by /Scripts/sds_codegen/sds_generate.py. Do not manually edit it, instead run
|
|
// `sds_codegen.sh`.
|
|
|
|
// clang-format off
|
|
|
|
- (instancetype)initWithGrdbId:(int64_t)grdbId
|
|
uniqueId:(NSString *)uniqueId
|
|
albumMessageId:(nullable NSString *)albumMessageId
|
|
attachmentSchemaVersion:(NSUInteger)attachmentSchemaVersion
|
|
attachmentType:(TSAttachmentType)attachmentType
|
|
blurHash:(nullable NSString *)blurHash
|
|
byteCount:(unsigned int)byteCount
|
|
caption:(nullable NSString *)caption
|
|
cdnKey:(NSString *)cdnKey
|
|
cdnNumber:(unsigned int)cdnNumber
|
|
clientUuid:(nullable NSString *)clientUuid
|
|
contentType:(NSString *)contentType
|
|
encryptionKey:(nullable NSData *)encryptionKey
|
|
serverId:(unsigned long long)serverId
|
|
sourceFilename:(nullable NSString *)sourceFilename
|
|
uploadTimestamp:(unsigned long long)uploadTimestamp
|
|
videoDuration:(nullable NSNumber *)videoDuration
|
|
cachedAudioDurationSeconds:(nullable NSNumber *)cachedAudioDurationSeconds
|
|
cachedImageHeight:(nullable NSNumber *)cachedImageHeight
|
|
cachedImageWidth:(nullable NSNumber *)cachedImageWidth
|
|
creationTimestamp:(NSDate *)creationTimestamp
|
|
digest:(nullable NSData *)digest
|
|
isAnimatedCached:(nullable NSNumber *)isAnimatedCached
|
|
isUploaded:(BOOL)isUploaded
|
|
isValidImageCached:(nullable NSNumber *)isValidImageCached
|
|
isValidVideoCached:(nullable NSNumber *)isValidVideoCached
|
|
localRelativeFilePath:(nullable NSString *)localRelativeFilePath
|
|
{
|
|
self = [super initWithGrdbId:grdbId
|
|
uniqueId:uniqueId
|
|
albumMessageId:albumMessageId
|
|
attachmentSchemaVersion:attachmentSchemaVersion
|
|
attachmentType:attachmentType
|
|
blurHash:blurHash
|
|
byteCount:byteCount
|
|
caption:caption
|
|
cdnKey:cdnKey
|
|
cdnNumber:cdnNumber
|
|
clientUuid:clientUuid
|
|
contentType:contentType
|
|
encryptionKey:encryptionKey
|
|
serverId:serverId
|
|
sourceFilename:sourceFilename
|
|
uploadTimestamp:uploadTimestamp
|
|
videoDuration:videoDuration];
|
|
|
|
if (!self) {
|
|
return self;
|
|
}
|
|
|
|
_cachedAudioDurationSeconds = cachedAudioDurationSeconds;
|
|
_cachedImageHeight = cachedImageHeight;
|
|
_cachedImageWidth = cachedImageWidth;
|
|
_creationTimestamp = creationTimestamp;
|
|
_digest = digest;
|
|
_isAnimatedCached = isAnimatedCached;
|
|
_isUploaded = isUploaded;
|
|
_isValidImageCached = isValidImageCached;
|
|
_isValidVideoCached = isValidVideoCached;
|
|
_localRelativeFilePath = localRelativeFilePath;
|
|
|
|
[self sdsFinalizeAttachmentStream];
|
|
|
|
return self;
|
|
}
|
|
|
|
// clang-format on
|
|
|
|
// --- CODE GENERATION MARKER
|
|
|
|
- (void)sdsFinalizeAttachmentStream
|
|
{
|
|
[self upgradeAttachmentSchemaVersionIfNecessary];
|
|
}
|
|
|
|
- (void)upgradeFromAttachmentSchemaVersion:(NSUInteger)attachmentSchemaVersion
|
|
{
|
|
[super upgradeFromAttachmentSchemaVersion:attachmentSchemaVersion];
|
|
|
|
if (attachmentSchemaVersion < 1) {
|
|
// Older video attachments could incorrectly be marked as not
|
|
// valid before we increased our size limits to allow 4k video.
|
|
if (self.isValidVideoCached && !self.isValidVideoCached.boolValue) {
|
|
self.isValidVideoCached = nil;
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)ensureFilePath
|
|
{
|
|
if (self.localRelativeFilePath) {
|
|
return;
|
|
}
|
|
|
|
NSString *attachmentsFolder = [[self class] attachmentsFolder];
|
|
NSString *filePath = [[self class] filePathForAttachment:self.uniqueId
|
|
mimeType:self.contentType
|
|
sourceFilename:self.sourceFilename
|
|
folder:attachmentsFolder];
|
|
if (!filePath) {
|
|
OWSFailDebug(@"Could not generate path for attachment.");
|
|
return;
|
|
}
|
|
if (![filePath hasPrefix:attachmentsFolder]) {
|
|
OWSFailDebug(@"Attachment paths should all be in the attachments folder.");
|
|
return;
|
|
}
|
|
NSString *localRelativeFilePath = [filePath substringFromIndex:attachmentsFolder.length];
|
|
if (localRelativeFilePath.length < 1) {
|
|
OWSFailDebug(@"Empty local relative attachment paths.");
|
|
return;
|
|
}
|
|
|
|
self.localRelativeFilePath = localRelativeFilePath;
|
|
OWSAssertDebug(self.originalFilePath);
|
|
}
|
|
|
|
#pragma mark - File Management
|
|
|
|
- (nullable NSData *)readDataFromFileWithError:(NSError **)error
|
|
{
|
|
*error = nil;
|
|
NSString *_Nullable filePath = self.originalFilePath;
|
|
if (!filePath) {
|
|
OWSFailDebug(@"Missing path for attachment.");
|
|
return nil;
|
|
}
|
|
return [NSData dataWithContentsOfFile:filePath options:0 error:error];
|
|
}
|
|
|
|
- (BOOL)writeData:(NSData *)data error:(NSError **)error
|
|
{
|
|
OWSAssertDebug(data);
|
|
|
|
*error = nil;
|
|
NSString *_Nullable filePath = self.originalFilePath;
|
|
if (!filePath) {
|
|
*error = OWSErrorMakeAssertionError(@"Missing path for attachment.");
|
|
return NO;
|
|
}
|
|
return [data writeToFile:filePath options:0 error:error];
|
|
}
|
|
|
|
- (BOOL)writeCopyingDataSource:(id<DataSource>)dataSource error:(NSError **)error
|
|
{
|
|
OWSAssertDebug(dataSource);
|
|
|
|
NSURL *_Nullable originalMediaURL = self.originalMediaURL;
|
|
if (originalMediaURL == nil) {
|
|
*error = OWSErrorMakeAssertionError(@"Missing URL for attachment.");
|
|
return NO;
|
|
}
|
|
return [dataSource writeToUrl:originalMediaURL error:error];
|
|
}
|
|
|
|
- (BOOL)writeConsumingDataSource:(id<DataSource>)dataSource error:(NSError **)error
|
|
{
|
|
OWSAssertDebug(dataSource);
|
|
|
|
NSURL *_Nullable originalMediaURL = self.originalMediaURL;
|
|
if (originalMediaURL == nil) {
|
|
*error = OWSErrorMakeAssertionError(@"Missing URL for attachment.");
|
|
return NO;
|
|
}
|
|
return [dataSource moveToUrlAndConsume:originalMediaURL error:error];
|
|
}
|
|
|
|
+ (NSString *)legacyAttachmentsDirPath
|
|
{
|
|
return [[OWSFileSystem appDocumentDirectoryPath] stringByAppendingPathComponent:@"Attachments"];
|
|
}
|
|
|
|
+ (NSString *)sharedDataAttachmentsDirPath
|
|
{
|
|
return [[OWSFileSystem appSharedDataDirectoryPath] stringByAppendingPathComponent:@"Attachments"];
|
|
}
|
|
|
|
+ (NSString *)attachmentsFolder
|
|
{
|
|
static NSString *attachmentsFolder = nil;
|
|
static dispatch_once_t onceToken;
|
|
dispatch_once(&onceToken, ^{
|
|
attachmentsFolder = TSAttachmentStream.sharedDataAttachmentsDirPath;
|
|
|
|
[OWSFileSystem ensureDirectoryExists:attachmentsFolder];
|
|
});
|
|
return attachmentsFolder;
|
|
}
|
|
|
|
- (nullable NSString *)originalFilePath
|
|
{
|
|
if (!self.localRelativeFilePath) {
|
|
OWSFailDebug(@"Attachment missing local file path.");
|
|
return nil;
|
|
}
|
|
|
|
return [[[self class] attachmentsFolder] stringByAppendingPathComponent:self.localRelativeFilePath];
|
|
}
|
|
|
|
/// For new attachments, we create a folder based on the uniqueId where we store all attachment data.
|
|
/// Legacy attachments may have data stored directly in the `attachmentsFolder`.
|
|
- (nullable NSString *)uniqueIdAttachmentFolder
|
|
{
|
|
return [[[self class] attachmentsFolder] stringByAppendingPathComponent:self.uniqueId];
|
|
}
|
|
|
|
- (BOOL)ensureUniqueIdAttachmentFolder
|
|
{
|
|
return [OWSFileSystem ensureDirectoryExists:self.uniqueIdAttachmentFolder];
|
|
}
|
|
|
|
- (nullable NSString *)audioWaveformPath
|
|
{
|
|
if (!self.isAudioMimeType) {
|
|
return nil;
|
|
}
|
|
|
|
return [self.uniqueIdAttachmentFolder stringByAppendingPathComponent:@"waveform.dat"];
|
|
}
|
|
|
|
- (nullable NSString *)legacyThumbnailPath
|
|
{
|
|
NSString *filePath = self.originalFilePath;
|
|
if (!filePath) {
|
|
OWSFailDebug(@"Attachment missing local file path.");
|
|
return nil;
|
|
}
|
|
|
|
if (!self.isImageMimeType && !self.isVideoMimeType && [self getAnimatedMimeType] == TSAnimatedMimeTypeNotAnimated) {
|
|
return nil;
|
|
}
|
|
|
|
NSString *filename = filePath.lastPathComponent.stringByDeletingPathExtension;
|
|
NSString *containingDir = filePath.stringByDeletingLastPathComponent;
|
|
NSString *newFilename = [filename stringByAppendingString:@"-signal-ios-thumbnail"];
|
|
|
|
return [[containingDir stringByAppendingPathComponent:newFilename] stringByAppendingPathExtension:@"jpg"];
|
|
}
|
|
|
|
- (NSString *)thumbnailsDirPath
|
|
{
|
|
if (!self.localRelativeFilePath) {
|
|
OWSFailDebug(@"Attachment missing local file path.");
|
|
return nil;
|
|
}
|
|
|
|
// Thumbnails are written to the caches directory, so that iOS can
|
|
// remove them if necessary.
|
|
NSString *dirName = [NSString stringWithFormat:@"%@-thumbnails", self.uniqueId];
|
|
return [OWSFileSystem.cachesDirectoryPath stringByAppendingPathComponent:dirName];
|
|
}
|
|
|
|
- (NSString *)pathForThumbnailDimensionPoints:(CGFloat)thumbnailDimensionPoints
|
|
{
|
|
NSString *fileExtension = [OWSThumbnailService thumbnailFileExtensionForContentType:self.contentType];
|
|
NSString *filename =
|
|
[NSString stringWithFormat:@"thumbnail-%lu.%@", (unsigned long)thumbnailDimensionPoints, fileExtension];
|
|
return [self.thumbnailsDirPath stringByAppendingPathComponent:filename];
|
|
}
|
|
|
|
- (nullable NSURL *)originalMediaURL
|
|
{
|
|
NSString *_Nullable filePath = self.originalFilePath;
|
|
if (!filePath) {
|
|
OWSFailDebug(@"Missing path for attachment.");
|
|
return nil;
|
|
}
|
|
return [NSURL fileURLWithPath:filePath];
|
|
}
|
|
|
|
- (void)removeFile
|
|
{
|
|
NSString *_Nullable thumbnailsDirPath = self.thumbnailsDirPath;
|
|
if (thumbnailsDirPath && ![OWSFileSystem deleteFileIfExists:thumbnailsDirPath]) {
|
|
OWSLogError(@"remove thumbnails dir failed.");
|
|
}
|
|
|
|
NSString *_Nullable legacyThumbnailPath = self.legacyThumbnailPath;
|
|
if (legacyThumbnailPath && ![OWSFileSystem deleteFileIfExists:legacyThumbnailPath]) {
|
|
OWSLogError(@"remove legacy thumbnail failed.");
|
|
}
|
|
|
|
NSString *_Nullable filePath = self.originalFilePath;
|
|
OWSAssertDebug(filePath);
|
|
if (filePath && ![OWSFileSystem deleteFileIfExists:filePath]) {
|
|
OWSLogError(@"remove file failed");
|
|
}
|
|
|
|
// Remove the attachment specific directory and any associated files stored for this attachment.
|
|
NSString *_Nullable attachmentFolder = self.uniqueIdAttachmentFolder;
|
|
if (attachmentFolder && ![OWSFileSystem deleteFileIfExists:attachmentFolder]) {
|
|
OWSFailDebug(@"remove unique attachment folder failed.");
|
|
}
|
|
}
|
|
|
|
- (void)anyDidInsertWithTransaction:(SDSAnyWriteTransaction *)transaction
|
|
{
|
|
[super anyDidInsertWithTransaction:transaction];
|
|
[self anyDidInsertSwiftWithTx:transaction];
|
|
}
|
|
|
|
- (void)anyDidRemoveWithTransaction:(SDSAnyWriteTransaction *)transaction
|
|
{
|
|
[super anyDidRemoveWithTransaction:transaction];
|
|
|
|
[self removeFile];
|
|
[self anyDidRemoveSwiftWithTx:transaction];
|
|
}
|
|
|
|
- (BOOL)isValidVisualMedia
|
|
{
|
|
return [self isValidVisualMediaIgnoringSize:NO];
|
|
}
|
|
|
|
- (BOOL)isValidVisualMediaIgnoringSize:(BOOL)ignoreSize
|
|
{
|
|
if (self.isImageMimeType && [self isValidImageIgnoringSize:ignoreSize]) {
|
|
return YES;
|
|
}
|
|
|
|
if (self.isVideoMimeType && [self isValidVideoIgnoringSize:ignoreSize]) {
|
|
return YES;
|
|
}
|
|
|
|
if ([self getAnimatedMimeType] != TSAnimatedMimeTypeNotAnimated && [self isValidImageIgnoringSize:ignoreSize]) {
|
|
return YES;
|
|
}
|
|
|
|
return NO;
|
|
}
|
|
|
|
#pragma mark - Image Validation
|
|
|
|
- (BOOL)isValidImage
|
|
{
|
|
return [self isValidImageIgnoringSize:NO];
|
|
}
|
|
|
|
- (BOOL)isValidImageIgnoringSize:(BOOL)ignoreSize
|
|
{
|
|
OWSAssertDebug(self.isImageMimeType || [self getAnimatedMimeType] != TSAnimatedMimeTypeNotAnimated);
|
|
|
|
BOOL result;
|
|
BOOL didUpdateCache = NO;
|
|
@synchronized(self) {
|
|
if (!self.isValidImageCached) {
|
|
self.isValidImageCached = @([NSData imageMetadataWithPath:self.originalFilePath
|
|
mimeType:self.contentType
|
|
ignoreFileSize:ignoreSize]
|
|
.isValid);
|
|
if (!self.isValidImageCached.boolValue) {
|
|
OWSLogWarn(@"Invalid image.");
|
|
}
|
|
didUpdateCache = YES;
|
|
}
|
|
result = self.isValidImageCached.boolValue;
|
|
}
|
|
|
|
if (didUpdateCache && self.canAsyncUpdate) {
|
|
[self applyChangeAsyncToLatestCopyWithChangeBlock:^(
|
|
TSAttachmentStream *latestInstance) { latestInstance.isValidImageCached = @(result); }];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
- (BOOL)canAsyncUpdate
|
|
{
|
|
return !AppContextObjcBridge.CurrentAppContext.isRunningTests;
|
|
}
|
|
|
|
- (BOOL)isValidVideo
|
|
{
|
|
return [self isValidVideoIgnoringSize:NO];
|
|
}
|
|
|
|
- (BOOL)isValidVideoIgnoringSize:(BOOL)ignoreSize
|
|
{
|
|
OWSAssertDebug(self.isVideoMimeType);
|
|
|
|
BOOL result;
|
|
BOOL didUpdateCache = NO;
|
|
@synchronized(self) {
|
|
if (!self.isValidVideoCached) {
|
|
self.isValidVideoCached = @([OWSMediaUtils isValidVideoWithPath:self.originalFilePath
|
|
ignoreSize:ignoreSize]);
|
|
if (!self.isValidVideoCached) {
|
|
OWSLogWarn(@"Invalid video.");
|
|
}
|
|
didUpdateCache = YES;
|
|
}
|
|
result = self.isValidVideoCached.boolValue;
|
|
}
|
|
|
|
if (didUpdateCache && self.canAsyncUpdate) {
|
|
[self applyChangeAsyncToLatestCopyWithChangeBlock:^(
|
|
TSAttachmentStream *latestInstance) { latestInstance.isValidVideoCached = @(result); }];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
- (BOOL)isAnimatedContent
|
|
{
|
|
BOOL result;
|
|
BOOL didUpdateCache = NO;
|
|
@synchronized(self) {
|
|
if (!self.isAnimatedCached) {
|
|
self.isAnimatedCached = @([self hasAnimatedImageContent]);
|
|
didUpdateCache = YES;
|
|
}
|
|
result = self.isAnimatedCached.boolValue;
|
|
}
|
|
|
|
if (didUpdateCache && self.canAsyncUpdate) {
|
|
[self applyChangeAsyncToLatestCopyWithChangeBlock:^(
|
|
TSAttachmentStream *latestInstance) { latestInstance.isAnimatedCached = @(result); }];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
- (BOOL)shouldBeRenderedByYY
|
|
{
|
|
if ([MimeTypeUtil isSupportedDefinitelyAnimatedMimeType:self.contentType]) {
|
|
return YES;
|
|
}
|
|
return self.isAnimatedContent;
|
|
}
|
|
|
|
- (BOOL)hasAnimatedImageContent
|
|
{
|
|
return [OWSVideoAttachmentDetection.sharedInstance attachmentStreamIsAnimated:self];
|
|
}
|
|
|
|
#pragma mark -
|
|
|
|
- (nullable UIImage *)originalImage
|
|
{
|
|
if ([self isVideoMimeType]) {
|
|
return [self videoStillImage];
|
|
} else if ([self isImageMimeType] || [self getAnimatedMimeType] != TSAnimatedMimeTypeNotAnimated) {
|
|
NSString *_Nullable originalFilePath = self.originalFilePath;
|
|
if (!originalFilePath) {
|
|
return nil;
|
|
}
|
|
if (![self isValidImage]) {
|
|
return nil;
|
|
}
|
|
UIImage *_Nullable image;
|
|
if (self.isWebpImageMimeType) {
|
|
image = [[YYImage alloc] initWithContentsOfFile:originalFilePath];
|
|
} else {
|
|
image = [[UIImage alloc] initWithContentsOfFile:originalFilePath];
|
|
}
|
|
if (image == nil) {
|
|
OWSFailDebug(
|
|
@"Couldn't load original image: %d.", [OWSFileSystem fileOrFolderExistsAtPath:originalFilePath]);
|
|
}
|
|
return image;
|
|
} else {
|
|
return nil;
|
|
}
|
|
}
|
|
|
|
- (nullable NSData *)validStillImageData
|
|
{
|
|
if ([self isVideoMimeType]) {
|
|
OWSFailDebug(@"isVideo was unexpectedly true");
|
|
return nil;
|
|
}
|
|
if ([self getAnimatedMimeType] == TSAnimatedMimeTypeAnimated) {
|
|
OWSFailDebug(@"isAnimated was unexpectedly true");
|
|
return nil;
|
|
}
|
|
|
|
if (![NSData ows_isValidImageAtPath:self.originalFilePath mimeType:self.contentType]) {
|
|
OWSFailDebug(@"skipping invalid image");
|
|
return nil;
|
|
}
|
|
|
|
return [NSData dataWithContentsOfFile:self.originalFilePath];
|
|
}
|
|
|
|
- (nullable UIImage *)videoStillImage
|
|
{
|
|
NSError *error;
|
|
UIImage *_Nullable image = [OWSMediaUtils thumbnailForVideoAtPath:self.originalFilePath
|
|
maxDimensionPoints:[TSAttachmentStream thumbnailDimensionPointsLarge]
|
|
error:&error];
|
|
if (error || !image) {
|
|
OWSLogError(@"Could not create video still: %@.", error);
|
|
return nil;
|
|
}
|
|
return image;
|
|
}
|
|
|
|
+ (void)deleteAttachmentsFromDisk
|
|
{
|
|
NSError *error;
|
|
NSFileManager *fileManager = [NSFileManager defaultManager];
|
|
|
|
NSURL *fileURL = [NSURL fileURLWithPath:self.attachmentsFolder];
|
|
NSArray<NSURL *> *contents = [fileManager contentsOfDirectoryAtURL:fileURL
|
|
includingPropertiesForKeys:nil
|
|
options:0
|
|
error:&error];
|
|
|
|
if (error) {
|
|
OWSFailDebug(@"failed to get contents of attachments folder: %@ with error: %@", self.attachmentsFolder, error);
|
|
return;
|
|
}
|
|
|
|
for (NSURL *url in contents) {
|
|
[fileManager removeItemAtURL:url error:&error];
|
|
if (error) {
|
|
OWSFailDebug(@"failed to remove item at path: %@ with error: %@", url, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
- (CGSize)calculateImageSizePixels
|
|
{
|
|
if ([self isVideoMimeType]) {
|
|
if (![self isValidVideo]) {
|
|
return CGSizeZero;
|
|
}
|
|
return [[self videoStillImage] pixelSize];
|
|
} else if ([self isImageMimeType] || [self getAnimatedMimeType] != TSAnimatedMimeTypeNotAnimated) {
|
|
// imageSizeForFilePath checks validity.
|
|
return [NSData imageSizeForFilePath:self.originalFilePath mimeType:self.contentType];
|
|
} else {
|
|
return CGSizeZero;
|
|
}
|
|
}
|
|
|
|
- (BOOL)shouldHaveImageSize
|
|
{
|
|
return ([self isVideoMimeType] || [self isImageMimeType] ||
|
|
[self getAnimatedMimeType] != TSAnimatedMimeTypeNotAnimated);
|
|
}
|
|
|
|
- (CGSize)imageSizePixels
|
|
{
|
|
if (!self.shouldHaveImageSize) {
|
|
OWSFailDebug(@"Content type does not have image sync.");
|
|
return CGSizeZero;
|
|
}
|
|
|
|
@synchronized(self) {
|
|
if (self.cachedImageWidth && self.cachedImageHeight) {
|
|
return CGSizeMake(self.cachedImageWidth.floatValue, self.cachedImageHeight.floatValue);
|
|
}
|
|
|
|
CGSize imageSizePixels = [self calculateImageSizePixels];
|
|
if (imageSizePixels.width <= 0 || imageSizePixels.height <= 0) {
|
|
return CGSizeZero;
|
|
}
|
|
self.cachedImageWidth = @(imageSizePixels.width);
|
|
self.cachedImageHeight = @(imageSizePixels.height);
|
|
|
|
if (self.canAsyncUpdate) {
|
|
[self applyChangeAsyncToLatestCopyWithChangeBlock:^(TSAttachmentStream *latestInstance) {
|
|
latestInstance.cachedImageWidth = @(imageSizePixels.width);
|
|
latestInstance.cachedImageHeight = @(imageSizePixels.height);
|
|
}];
|
|
}
|
|
|
|
return imageSizePixels;
|
|
}
|
|
}
|
|
|
|
- (CGSize)cachedMediaSize
|
|
{
|
|
OWSAssertDebug(self.shouldHaveImageSize);
|
|
|
|
@synchronized(self) {
|
|
if (self.cachedImageWidth && self.cachedImageHeight) {
|
|
return CGSizeMake(self.cachedImageWidth.floatValue, self.cachedImageHeight.floatValue);
|
|
} else {
|
|
return CGSizeZero;
|
|
}
|
|
}
|
|
}
|
|
|
|
#pragma mark - Update With...
|
|
|
|
- (void)applyChangeAsyncToLatestCopyWithChangeBlock:(void (^)(TSAttachmentStream *))changeBlock
|
|
{
|
|
OWSAssertDebug(changeBlock);
|
|
|
|
NSString *uniqueId = self.uniqueId;
|
|
DatabaseStorageAsyncWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *tx) {
|
|
// We load a new instance before using anyUpdateWithTransaction() since it
|
|
// isn't thread-safe to mutate the current instance async.
|
|
TSAttachmentStream *_Nullable latestInstance = [TSAttachmentStream anyFetchAttachmentStreamWithUniqueId:uniqueId
|
|
transaction:tx];
|
|
if (latestInstance == nil) {
|
|
// This attachment has either not yet been saved or has been deleted; do
|
|
// nothing. This isn't an error per se, but these race conditions should be
|
|
// _very_ rare.
|
|
//
|
|
// An exception is incoming group avatar updates which we don't ever save.
|
|
OWSLogWarn(@"Could not load attachment.");
|
|
return;
|
|
}
|
|
changeBlock(latestInstance);
|
|
[latestInstance anyOverwritingUpdateWithTransaction:tx];
|
|
});
|
|
}
|
|
|
|
#pragma mark -
|
|
|
|
- (NSTimeInterval)calculateAudioDurationSeconds
|
|
{
|
|
OWSAssertDebug([self isAudioMimeType]);
|
|
|
|
if (AppContextObjcBridge.CurrentAppContext.isRunningTests) {
|
|
// Return an arbitrary non-zero value to avoid
|
|
// expected exceptions in AVFoundation.
|
|
return 1;
|
|
}
|
|
|
|
NSError *error;
|
|
AVAudioPlayer *audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:self.originalMediaURL error:&error];
|
|
if (error && [error.domain isEqualToString:NSOSStatusErrorDomain]
|
|
&& (error.code == kAudioFileInvalidFileError || error.code == kAudioFileStreamError_InvalidFile)) {
|
|
// Ignore "invalid audio file" errors.
|
|
return 0;
|
|
}
|
|
if (!error) {
|
|
[audioPlayer prepareToPlay];
|
|
return [audioPlayer duration];
|
|
} else {
|
|
OWSLogError(@"Could not find audio duration: %@", self.originalMediaURL);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
- (NSTimeInterval)audioDurationSeconds
|
|
{
|
|
@synchronized(self) {
|
|
if (self.cachedAudioDurationSeconds) {
|
|
return self.cachedAudioDurationSeconds.doubleValue;
|
|
}
|
|
|
|
NSTimeInterval audioDurationSeconds = [self calculateAudioDurationSeconds];
|
|
self.cachedAudioDurationSeconds = @(audioDurationSeconds);
|
|
|
|
if (self.canAsyncUpdate) {
|
|
[self applyChangeAsyncToLatestCopyWithChangeBlock:^(TSAttachmentStream *latestInstance) {
|
|
latestInstance.cachedAudioDurationSeconds = @(audioDurationSeconds);
|
|
}];
|
|
}
|
|
|
|
return audioDurationSeconds;
|
|
}
|
|
}
|
|
|
|
#pragma mark - Thumbnails
|
|
|
|
- (void)thumbnailImageWithSizeHint:(CGSize)sizeHint
|
|
success:(OWSThumbnailSuccess)success
|
|
failure:(OWSThumbnailFailure)failure
|
|
{
|
|
CGFloat maxDimensionHint = MAX(sizeHint.width, sizeHint.height);
|
|
CGFloat thumbnailDimensionPoints;
|
|
if (maxDimensionHint <= TSAttachmentStream.thumbnailDimensionPointsSmall) {
|
|
thumbnailDimensionPoints = TSAttachmentStream.thumbnailDimensionPointsSmall;
|
|
} else if (maxDimensionHint <= TSAttachmentStream.thumbnailDimensionPointsMedium) {
|
|
thumbnailDimensionPoints = TSAttachmentStream.thumbnailDimensionPointsMedium;
|
|
} else {
|
|
thumbnailDimensionPoints = [TSAttachmentStream thumbnailDimensionPointsLarge];
|
|
}
|
|
|
|
[self thumbnailImageWithThumbnailDimensionPoints:thumbnailDimensionPoints success:success failure:failure];
|
|
}
|
|
|
|
- (void)thumbnailImageWithQuality:(TSAttachmentThumbnailQuality)quality
|
|
success:(OWSThumbnailSuccess)success
|
|
failure:(OWSThumbnailFailure)failure
|
|
{
|
|
CGFloat thumbnailDimensionPoints = [TSAttachmentStream thumbnailDimensionPointsForThumbnailQuality:quality];
|
|
[self thumbnailImageWithThumbnailDimensionPoints:thumbnailDimensionPoints success:success failure:failure];
|
|
}
|
|
|
|
- (void)thumbnailImageWithThumbnailDimensionPoints:(CGFloat)thumbnailDimensionPoints
|
|
success:(OWSThumbnailSuccess)success
|
|
failure:(OWSThumbnailFailure)failure
|
|
{
|
|
[self loadedThumbnailWithThumbnailDimensionPoints:thumbnailDimensionPoints
|
|
success:^(OWSLoadedThumbnail *thumbnail) { DispatchMainThreadSafe(^{ success(thumbnail.image); }); }
|
|
failure:^{ DispatchMainThreadSafe(^{ failure(); }); }];
|
|
}
|
|
|
|
- (void)loadedThumbnailWithThumbnailDimensionPoints:(CGFloat)thumbnailDimensionPoints
|
|
success:(OWSLoadedThumbnailSuccess)success
|
|
failure:(OWSThumbnailFailure)failure
|
|
{
|
|
[self.thumbnailLoadingOperationQueue addOperationWithBlock:^{
|
|
@autoreleasepool {
|
|
if (!self.isValidVisualMedia) {
|
|
// Never thumbnail (or try to use the original of) invalid media.
|
|
OWSFailDebug(@"Invalid image.");
|
|
failure();
|
|
return;
|
|
}
|
|
|
|
CGSize originalSizePoints = self.imageSizePoints;
|
|
if (self.imageSizePixels.width < 1 || self.imageSizePixels.height < 1) {
|
|
failure();
|
|
return;
|
|
}
|
|
|
|
if (originalSizePoints.width <= thumbnailDimensionPoints
|
|
&& originalSizePoints.height <= thumbnailDimensionPoints && self.isImageMimeType) {
|
|
// There's no point in generating a thumbnail if the original is smaller than the
|
|
// thumbnail size. Only do this for images. We still need to generate thumbnails
|
|
// for videos.
|
|
NSString *originalFilePath = self.originalFilePath;
|
|
UIImage *_Nullable originalImage = self.originalImage;
|
|
if (originalImage == nil) {
|
|
OWSFailDebug(@"originalImage was unexpectedly nil");
|
|
failure();
|
|
} else {
|
|
success([[OWSLoadedThumbnail alloc] initWithImage:originalImage filePath:originalFilePath]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
NSString *thumbnailPath = [self pathForThumbnailDimensionPoints:thumbnailDimensionPoints];
|
|
if ([[NSFileManager defaultManager] fileExistsAtPath:thumbnailPath]) {
|
|
UIImage *_Nullable image = [UIImage imageWithContentsOfFile:thumbnailPath];
|
|
if (!image) {
|
|
OWSFailDebug(@"couldn't load image.");
|
|
failure();
|
|
} else {
|
|
success([[OWSLoadedThumbnail alloc] initWithImage:image filePath:thumbnailPath]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
[OWSThumbnailService.shared ensureThumbnailForAttachment:self
|
|
thumbnailDimensionPoints:thumbnailDimensionPoints
|
|
success:success
|
|
failure:^(NSError *error) {
|
|
OWSLogError(@"Failed to create thumbnail: %@", error);
|
|
failure();
|
|
}];
|
|
}
|
|
}];
|
|
return;
|
|
}
|
|
|
|
- (NSOperationQueue *)thumbnailLoadingOperationQueue
|
|
{
|
|
static dispatch_once_t onceToken;
|
|
static NSOperationQueue *operationQueue;
|
|
dispatch_once(&onceToken, ^{
|
|
operationQueue = [NSOperationQueue new];
|
|
operationQueue.name = @"ThumbnailLoading";
|
|
operationQueue.maxConcurrentOperationCount = 4;
|
|
});
|
|
return operationQueue;
|
|
}
|
|
|
|
- (nullable OWSLoadedThumbnail *)loadedThumbnailSyncWithDimensionPoints:(CGFloat)thumbnailDimensionPoints
|
|
{
|
|
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
|
|
|
|
__block OWSLoadedThumbnail *_Nullable asyncLoadedThumbnail = nil;
|
|
[self loadedThumbnailWithThumbnailDimensionPoints:thumbnailDimensionPoints
|
|
success:^(OWSLoadedThumbnail *thumbnail) {
|
|
@synchronized(self) {
|
|
asyncLoadedThumbnail = thumbnail;
|
|
}
|
|
dispatch_semaphore_signal(semaphore);
|
|
}
|
|
failure:^{ dispatch_semaphore_signal(semaphore); }];
|
|
// Wait up to N seconds.
|
|
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)));
|
|
@synchronized(self) {
|
|
return asyncLoadedThumbnail;
|
|
}
|
|
}
|
|
|
|
- (nullable UIImage *)thumbnailImageSyncWithQuality:(TSAttachmentThumbnailQuality)quality
|
|
{
|
|
CGFloat thumbnailDimensionPoints = [TSAttachmentStream thumbnailDimensionPointsForThumbnailQuality:quality];
|
|
OWSLoadedThumbnail *_Nullable loadedThumbnail =
|
|
[self loadedThumbnailSyncWithDimensionPoints:thumbnailDimensionPoints];
|
|
if (!loadedThumbnail) {
|
|
OWSLogInfo(@"Couldn't load %@ thumbnail sync.", NSStringForAttachmentThumbnailQuality(quality));
|
|
return nil;
|
|
}
|
|
return loadedThumbnail.image;
|
|
}
|
|
|
|
- (nullable NSData *)thumbnailDataSmallSync
|
|
{
|
|
OWSLoadedThumbnail *_Nullable loadedThumbnail =
|
|
[self loadedThumbnailSyncWithDimensionPoints:TSAttachmentStream.thumbnailDimensionPointsSmall];
|
|
if (!loadedThumbnail) {
|
|
OWSLogInfo(@"Couldn't load small thumbnail sync.");
|
|
return nil;
|
|
}
|
|
NSError *error;
|
|
NSData *_Nullable data = [loadedThumbnail dataAndReturnError:&error];
|
|
if (error || !data) {
|
|
OWSFailDebug(@"Couldn't load thumbnail data: %@", error);
|
|
return nil;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
- (NSArray<NSString *> *)allSecondaryFilePaths
|
|
{
|
|
NSMutableArray<NSString *> *result = [NSMutableArray new];
|
|
|
|
NSString *thumbnailsDirPath = self.thumbnailsDirPath;
|
|
if ([[NSFileManager defaultManager] fileExistsAtPath:thumbnailsDirPath]) {
|
|
NSError *error;
|
|
NSArray<NSString *> *_Nullable fileNames =
|
|
[[NSFileManager defaultManager] contentsOfDirectoryAtPath:thumbnailsDirPath error:&error];
|
|
if (error || !fileNames) {
|
|
OWSFailDebug(@"contentsOfDirectoryAtPath failed with error: %@", error);
|
|
} else {
|
|
for (NSString *fileName in fileNames) {
|
|
NSString *filePath = [thumbnailsDirPath stringByAppendingPathComponent:fileName];
|
|
[result addObject:filePath];
|
|
}
|
|
}
|
|
}
|
|
|
|
NSString *_Nullable audioWaveformPath = self.audioWaveformPath;
|
|
if (audioWaveformPath != nil && [[NSFileManager defaultManager] fileExistsAtPath:audioWaveformPath]) {
|
|
[result addObject:audioWaveformPath];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
#pragma mark - Update With... Methods
|
|
|
|
- (void)updateAsUploadedWithEncryptionKey:(NSData *)encryptionKey
|
|
digest:(NSData *)digest
|
|
serverId:(UInt64)serverId
|
|
cdnKey:(NSString *)cdnKey
|
|
cdnNumber:(UInt32)cdnNumber
|
|
uploadTimestamp:(unsigned long long)uploadTimestamp
|
|
transaction:(SDSAnyWriteTransaction *)transaction
|
|
{
|
|
OWSAssertDebug(encryptionKey.length > 0);
|
|
OWSAssertDebug(digest.length > 0);
|
|
OWSAssertDebug(serverId > 0 || cdnKey.length > 0);
|
|
OWSAssertDebug(uploadTimestamp > 0);
|
|
|
|
[self anyUpdateAttachmentStreamWithTransaction:transaction
|
|
block:^(TSAttachmentStream *attachment) {
|
|
[attachment setEncryptionKey:encryptionKey];
|
|
[attachment setDigest:digest];
|
|
[attachment setServerId:serverId];
|
|
[attachment setCdnKey:cdnKey];
|
|
[attachment setCdnNumber:cdnNumber];
|
|
[attachment setUploadTimestamp:uploadTimestamp];
|
|
[attachment setIsUploaded:YES];
|
|
}];
|
|
}
|
|
|
|
- (nullable TSAttachmentStream *)cloneAsThumbnail
|
|
{
|
|
if (!self.isValidVisualMedia) {
|
|
return nil;
|
|
}
|
|
|
|
NSString *thumbnailMimeType = [OWSThumbnailService thumbnailMimetypeForContentType:self.contentType];
|
|
NSString *thumbnailFileExtension = [OWSThumbnailService thumbnailFileExtensionForContentType:self.contentType];
|
|
|
|
NSData *_Nullable thumbnailData = self.thumbnailDataSmallSync;
|
|
// Only some media types have thumbnails
|
|
if (!thumbnailData) {
|
|
return nil;
|
|
}
|
|
|
|
// Copy the thumbnail to a new attachment.
|
|
NSString *thumbnailName =
|
|
[NSString stringWithFormat:@"quoted-thumbnail-%@.%@", self.sourceFilename, thumbnailFileExtension];
|
|
TSAttachmentStream *thumbnailAttachment =
|
|
[[TSAttachmentStream alloc] initWithContentType:thumbnailMimeType
|
|
byteCount:(uint32_t)thumbnailData.length
|
|
sourceFilename:thumbnailName
|
|
caption:nil
|
|
attachmentType:TSAttachmentTypeDefault
|
|
albumMessageId:nil];
|
|
|
|
NSError *error;
|
|
BOOL success = [thumbnailAttachment writeData:thumbnailData error:&error];
|
|
if (!success || error) {
|
|
OWSLogError(@"Couldn't copy attachment data for message sent to self: %@.", error);
|
|
return nil;
|
|
}
|
|
|
|
return thumbnailAttachment;
|
|
}
|
|
|
|
// MARK: Protobuf serialization
|
|
|
|
- (nullable SSKProtoAttachmentPointer *)buildProto
|
|
{
|
|
BOOL isValidV1orV2 = self.serverId > 0;
|
|
BOOL isValidV3 = (self.cdnKey.length > 0 && self.cdnNumber > 0);
|
|
OWSAssertDebug(isValidV1orV2 || isValidV3);
|
|
|
|
SSKProtoAttachmentPointerBuilder *builder = [SSKProtoAttachmentPointer builder];
|
|
if (isValidV1orV2) {
|
|
builder.cdnID = self.serverId;
|
|
} else if (isValidV3) {
|
|
builder.cdnKey = self.cdnKey;
|
|
builder.cdnNumber = self.cdnNumber;
|
|
}
|
|
|
|
OWSAssertDebug(self.contentType.length > 0);
|
|
builder.contentType = self.contentType;
|
|
|
|
if (self.sourceFilename.length > 0) {
|
|
builder.fileName = self.sourceFilename;
|
|
}
|
|
if (self.caption && self.caption.length > 0) {
|
|
builder.caption = self.caption;
|
|
}
|
|
|
|
builder.size = self.byteCount;
|
|
builder.key = self.encryptionKey;
|
|
builder.digest = self.digest;
|
|
|
|
if (self.clientUuid != nil) {
|
|
NSUUID *_Nullable clientUuid = [[NSUUID alloc] initWithUUIDString:self.clientUuid];
|
|
|
|
if (clientUuid != nil) {
|
|
builder.clientUuid = [clientUuid asData];
|
|
} else {
|
|
OWSFailDebug(@"TSAttachment clientUuid unexpectedly failed to convert to UUID!");
|
|
}
|
|
}
|
|
|
|
if (self.attachmentType == TSAttachmentTypeVoiceMessage) {
|
|
builder.flags = SSKProtoAttachmentPointerFlagsVoiceMessage;
|
|
} else if (self.attachmentType == TSAttachmentTypeBorderless) {
|
|
builder.flags = SSKProtoAttachmentPointerFlagsBorderless;
|
|
} else if (self.attachmentType == TSAttachmentTypeGIF || self.isAnimatedContent) {
|
|
builder.flags = SSKProtoAttachmentPointerFlagsGif;
|
|
} else {
|
|
builder.flags = 0;
|
|
}
|
|
|
|
if (self.blurHash.length > 0) {
|
|
builder.blurHash = self.blurHash;
|
|
}
|
|
if (self.uploadTimestamp > 0) {
|
|
builder.uploadTimestamp = self.uploadTimestamp;
|
|
}
|
|
|
|
if (self.shouldHaveImageSize) {
|
|
CGSize imageSizePixels = self.imageSizePixels;
|
|
if (imageSizePixels.width < NSIntegerMax && imageSizePixels.height < NSIntegerMax) {
|
|
NSInteger imageWidth = (NSInteger)round(imageSizePixels.width);
|
|
NSInteger imageHeight = (NSInteger)round(imageSizePixels.height);
|
|
if (imageWidth > 0 && imageHeight > 0) {
|
|
builder.width = (UInt32)imageWidth;
|
|
builder.height = (UInt32)imageHeight;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [builder buildInfallibly];
|
|
}
|
|
|
|
@end
|
|
|
|
NS_ASSUME_NONNULL_END
|