/* Copyright 2018-2024 New Vector Ltd. Copyright 2015 OpenMarket Ltd SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ #import "MXKAttachment.h" #import "MXKSwiftHeader.h" @import MatrixSDK; @import MobileCoreServices; #import "MXKTools.h" // The size of thumbnail we request from the server // Note that this is smaller than the ones we upload: when sending, one size // must fit all, including the web which will want relatively high res thumbnails. // We, however, are a mobile client and so would prefer smaller thumbnails, which // we can have if they're being generated by the media repo. static const int kThumbnailWidth = 320; static const int kThumbnailHeight = 240; NSString *const kMXKAttachmentErrorDomain = @"kMXKAttachmentErrorDomain"; NSString *const kMXKAttachmentFileNameBase = @"attatchment"; @interface MXKAttachment () { /** The information on the encrypted content. */ MXEncryptedContentFile *contentFile; /** The information on the encrypted thumbnail. */ MXEncryptedContentFile *thumbnailFile; /** Observe Attachment download */ id onAttachmentDownloadObs; /** The local path used to store the attachment with its original name */ NSString *documentCopyPath; /** The attachment mimetype. */ NSString *mimetype; } @end @implementation MXKAttachment - (instancetype)initWithEvent:(MXEvent*)event andMediaManager:(MXMediaManager*)mediaManager { self = [super init]; if (self) { _mediaManager = mediaManager; // Make a copy as the data can be read at anytime later _eventId = event.eventId; _eventRoomId = event.roomId; _eventSentState = event.sentState; NSDictionary *eventContent = event.content; // Set default thumbnail orientation _thumbnailOrientation = UIImageOrientationUp; if (event.eventType == MXEventTypeSticker) { _type = MXKAttachmentTypeSticker; MXJSONModelSetDictionary(_thumbnailInfo, eventContent[@"info"][@"thumbnail_info"]); } else { // Note: mxEvent.eventType is supposed to be MXEventTypeRoomMessage here. NSString *msgtype = eventContent[kMXMessageTypeKey]; if ([msgtype isEqualToString:kMXMessageTypeImage]) { _type = MXKAttachmentTypeImage; } else if (event.isVoiceMessage) { _type = MXKAttachmentTypeVoiceMessage; } else if ([msgtype isEqualToString:kMXMessageTypeAudio]) { _type = MXKAttachmentTypeAudio; } else if ([msgtype isEqualToString:kMXMessageTypeVideo]) { _type = MXKAttachmentTypeVideo; MXJSONModelSetDictionary(_thumbnailInfo, eventContent[@"info"][@"thumbnail_info"]); } else if ([msgtype isEqualToString:kMXMessageTypeFile]) { _type = MXKAttachmentTypeFile; } else { return nil; } } MXJSONModelSetString(_originalFileName, eventContent[kMXMessageBodyKey]); MXJSONModelSetDictionary(_contentInfo, eventContent[@"info"]); MXJSONModelSetMXJSONModel(contentFile, MXEncryptedContentFile, eventContent[@"file"]); // Retrieve the content url by taking into account the potential encryption. if (contentFile) { _isEncrypted = YES; _contentURL = contentFile.url; MXJSONModelSetMXJSONModel(thumbnailFile, MXEncryptedContentFile, _contentInfo[@"thumbnail_file"]); } else { _isEncrypted = NO; MXJSONModelSetString(_contentURL, eventContent[@"url"]); } mimetype = nil; if (_contentInfo) { MXJSONModelSetString(mimetype, _contentInfo[@"mimetype"]); } _cacheFilePath = [MXMediaManager cachePathForMatrixContentURI:_contentURL andType:mimetype inFolder:_eventRoomId]; _downloadId = [MXMediaManager downloadIdForMatrixContentURI:_contentURL inFolder:_eventRoomId]; // Deduce the thumbnail information from the retrieved data. _mxcThumbnailURI = [self getThumbnailURI]; _thumbnailMimeType = [self getThumbnailMimeType]; _thumbnailCachePath = [self getThumbnailCachePath]; _thumbnailDownloadId = [self getThumbnailDownloadId]; } return self; } - (void)dealloc { [self destroy]; } - (void)destroy { if (onAttachmentDownloadObs) { [[NSNotificationCenter defaultCenter] removeObserver:onAttachmentDownloadObs]; onAttachmentDownloadObs = nil; } // Remove the temporary file created to prepare attachment sharing if (documentCopyPath) { [[NSFileManager defaultManager] removeItemAtPath:documentCopyPath error:nil]; documentCopyPath = nil; } _previewImage = nil; } - (NSString *)getThumbnailURI { if (thumbnailFile) { // there's an encrypted thumbnail: we return the mxc url return thumbnailFile.url; } // Look for a clear thumbnail url return _contentInfo[@"thumbnail_url"]; } - (NSString *)getThumbnailMimeType { return _thumbnailInfo[@"mimetype"]; } - (NSString*)getThumbnailCachePath { if (_mxcThumbnailURI) { return [MXMediaManager cachePathForMatrixContentURI:_mxcThumbnailURI andType:_thumbnailMimeType inFolder:_eventRoomId]; } // In case of an unencrypted image, consider the thumbnail URI deduced from the content URL, except if // the attachment is currently uploading. // Note: When the uploading is in progress, the upload id is stored in the content url (nasty trick). else if (_type == MXKAttachmentTypeImage && !_isEncrypted && _contentURL && ![_contentURL hasPrefix:kMXMediaUploadIdPrefix]) { return [MXMediaManager thumbnailCachePathForMatrixContentURI:_contentURL andType:@"image/jpeg" inFolder:_eventRoomId toFitViewSize:CGSizeMake(kThumbnailWidth, kThumbnailHeight) withMethod:MXThumbnailingMethodScale]; } return nil; } - (NSString *)getThumbnailDownloadId { if (_mxcThumbnailURI) { return [MXMediaManager downloadIdForMatrixContentURI:_mxcThumbnailURI inFolder:_eventRoomId]; } // In case of an unencrypted image, consider the thumbnail URI deduced from the content URL, except if // the attachment is currently uploading. // Note: When the uploading is in progress, the upload id is stored in the content url (nasty trick). else if (_type == MXKAttachmentTypeImage && !_isEncrypted && _contentURL && ![_contentURL hasPrefix:kMXMediaUploadIdPrefix]) { return [MXMediaManager thumbnailDownloadIdForMatrixContentURI:_contentURL inFolder:_eventRoomId toFitViewSize:CGSizeMake(kThumbnailWidth, kThumbnailHeight) withMethod:MXThumbnailingMethodScale]; } return nil; } - (UIImage *)getCachedThumbnail { if (_thumbnailCachePath) { UIImage *thumb = [MXMediaManager getFromMemoryCacheWithFilePath:_thumbnailCachePath]; if (thumb) return thumb; if ([[NSFileManager defaultManager] fileExistsAtPath:_thumbnailCachePath]) { return [MXMediaManager loadThroughCacheWithFilePath:_thumbnailCachePath]; } } return nil; } - (void)getThumbnail:(void (^)(MXKAttachment *, UIImage *))onSuccess failure:(void (^)(MXKAttachment *, NSError *error))onFailure { // Check whether a thumbnail is defined. if (!_thumbnailCachePath) { // there is no thumbnail: if we're an image, return the full size image. Otherwise, nothing we can do. if (_type == MXKAttachmentTypeImage) { [self getImage:onSuccess failure:onFailure]; } else if (onFailure) { onFailure(self, nil); } return; } // Check the current memory cache. UIImage *thumb = [MXMediaManager getFromMemoryCacheWithFilePath:_thumbnailCachePath]; if (thumb) { onSuccess(self, thumb); return; } if (thumbnailFile) { MXWeakify(self); void (^decryptAndCache)(void) = ^{ MXStrongifyAndReturnIfNil(self); NSInputStream *instream = [[NSInputStream alloc] initWithFileAtPath:self.thumbnailCachePath]; NSOutputStream *outstream = [[NSOutputStream alloc] initToMemory]; [MXEncryptedAttachments decryptAttachment:self->thumbnailFile inputStream:instream outputStream:outstream success:^{ UIImage *img = [UIImage imageWithData:[outstream propertyForKey:NSStreamDataWrittenToMemoryStreamKey]]; // Save this image to in-memory cache. [MXMediaManager cacheImage:img withCachePath:self.thumbnailCachePath]; onSuccess(self, img); } failure:^(NSError *err) { if (err) { MXLogDebug(@"Error decrypting attachment! %@", err.userInfo); if (onFailure) onFailure(self, err); return; } }]; }; if ([[NSFileManager defaultManager] fileExistsAtPath:_thumbnailCachePath]) { decryptAndCache(); } else { [_mediaManager downloadEncryptedMediaFromMatrixContentFile:thumbnailFile mimeType:_thumbnailMimeType inFolder:_eventRoomId success:^(NSString *outputFilePath) { decryptAndCache(); } failure:^(NSError *error) { if (onFailure) onFailure(self, error); }]; } } else { if ([[NSFileManager defaultManager] fileExistsAtPath:_thumbnailCachePath]) { onSuccess(self, [MXMediaManager loadThroughCacheWithFilePath:_thumbnailCachePath]); } else if (_mxcThumbnailURI) { [_mediaManager downloadMediaFromMatrixContentURI:_mxcThumbnailURI withType:_thumbnailMimeType inFolder:_eventRoomId success:^(NSString *outputFilePath) { // Here outputFilePath = thumbnailCachePath onSuccess(self, [MXMediaManager loadThroughCacheWithFilePath:outputFilePath]); } failure:^(NSError *error) { if (onFailure) onFailure(self, error); }]; } else { // Here _thumbnailCachePath is defined, so a thumbnail is available. // Because _mxcThumbnailURI is null, this means we have to consider the content uri (see getThumbnailCachePath). [_mediaManager downloadThumbnailFromMatrixContentURI:_contentURL withType:@"image/jpeg" inFolder:_eventRoomId toFitViewSize:CGSizeMake(kThumbnailWidth, kThumbnailHeight) withMethod:MXThumbnailingMethodScale success:^(NSString *outputFilePath) { // Here outputFilePath = thumbnailCachePath onSuccess(self, [MXMediaManager loadThroughCacheWithFilePath:outputFilePath]); } failure:^(NSError *error) { if (onFailure) onFailure(self, error); }]; } } } - (void)getImage:(void (^)(MXKAttachment *, UIImage *))onSuccess failure:(void (^)(MXKAttachment *, NSError *error))onFailure { [self getAttachmentData:^(NSData *data) { UIImage *img = [UIImage imageWithData:data]; if (img) { if (onSuccess) { onSuccess(self, img); } } else { if (onFailure) { NSError *error = [NSError errorWithDomain:kMXKAttachmentErrorDomain code:0 userInfo:@{@"err": @"error_get_image_from_data"}]; onFailure(self, error); } } } failure:^(NSError *error) { if (onFailure) onFailure(self, error); }]; } - (void)getAttachmentData:(void (^)(NSData *))onSuccess failure:(void (^)(NSError *error))onFailure { MXWeakify(self); [self prepare:^{ MXStrongifyAndReturnIfNil(self); if (self.isEncrypted) { // decrypt the encrypted file NSInputStream *instream = [[NSInputStream alloc] initWithFileAtPath:self.cacheFilePath]; NSOutputStream *outstream = [[NSOutputStream alloc] initToMemory]; [MXEncryptedAttachments decryptAttachment:self->contentFile inputStream:instream outputStream:outstream success:^{ onSuccess([outstream propertyForKey:NSStreamDataWrittenToMemoryStreamKey]); } failure:^(NSError *err) { if (err) { MXLogDebug(@"Error decrypting attachment! %@", err.userInfo); return; } }]; } else { onSuccess([NSData dataWithContentsOfFile:self.cacheFilePath]); } } failure:onFailure]; } - (void)decryptToTempFile:(void (^)(NSString *))onSuccess failure:(void (^)(NSError *error))onFailure { MXWeakify(self); [self prepare:^{ MXStrongifyAndReturnIfNil(self); NSString *tempPath = [self getTempFile]; if (!tempPath) { if (onFailure) onFailure([NSError errorWithDomain:kMXKAttachmentErrorDomain code:0 userInfo:@{@"err": @"error_creating_temp_file"}]); return; } NSInputStream *inStream = [NSInputStream inputStreamWithFileAtPath:self.cacheFilePath]; NSOutputStream *outStream = [NSOutputStream outputStreamToFileAtPath:tempPath append:NO]; [MXEncryptedAttachments decryptAttachment:self->contentFile inputStream:inStream outputStream:outStream success:^{ onSuccess(tempPath); } failure:^(NSError *err) { if (err) { if (onFailure) onFailure(err); return; } }]; } failure:onFailure]; } - (NSString *)getTempFile { // create a file with an appropriate extension because iOS detects based on file extension // all over the place NSString *ext = [MXTools fileExtensionFromContentType:mimetype]; NSString *filenameTemplate = [NSString stringWithFormat:@"%@.XXXXXX%@", kMXKAttachmentFileNameBase, ext]; NSString *template = [NSTemporaryDirectory() stringByAppendingPathComponent:filenameTemplate]; const char *templateCstr = [template fileSystemRepresentation]; char *tempPathCstr = (char *)malloc(strlen(templateCstr) + 1); strcpy(tempPathCstr, templateCstr); int fd = mkstemps(tempPathCstr, (int)ext.length); if (!fd) { return nil; } close(fd); NSString *tempPath = [[NSFileManager defaultManager] stringWithFileSystemRepresentation:tempPathCstr length:strlen(tempPathCstr)]; free(tempPathCstr); return tempPath; } + (void)clearCache { NSString *temporaryDirectoryPath = NSTemporaryDirectory(); NSDirectoryEnumerator *enumerator = [NSFileManager.defaultManager enumeratorAtPath:temporaryDirectoryPath]; NSString *filePath; while (filePath = [enumerator nextObject]) { if(![filePath containsString:kMXKAttachmentFileNameBase]) { continue; } NSError *error; BOOL result = [NSFileManager.defaultManager removeItemAtPath:[temporaryDirectoryPath stringByAppendingPathComponent:filePath] error:&error]; if (!result && error) { MXLogErrorDetails(@"[MXKAttachment] Failed deleting temporary file with error", @{ @"error": error ?: @"unknown" }); } } } - (void)prepare:(void (^)(void))onAttachmentReady failure:(void (^)(NSError *error))onFailure { if ([[NSFileManager defaultManager] fileExistsAtPath:_cacheFilePath]) { // Done if (onAttachmentReady) { onAttachmentReady(); } } else { // Trigger download if it is not already in progress MXMediaLoader* loader = [MXMediaManager existingDownloaderWithIdentifier:_downloadId]; if (!loader) { if (_isEncrypted) { loader = [_mediaManager downloadEncryptedMediaFromMatrixContentFile:contentFile mimeType:mimetype inFolder:_eventRoomId]; } else { loader = [_mediaManager downloadMediaFromMatrixContentURI:_contentURL withType:mimetype inFolder:_eventRoomId]; } } if (loader) { MXWeakify(self); // Add observers onAttachmentDownloadObs = [[NSNotificationCenter defaultCenter] addObserverForName:kMXMediaLoaderStateDidChangeNotification object:loader queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXStrongifyAndReturnIfNil(self); MXMediaLoader *loader = (MXMediaLoader*)notif.object; switch (loader.state) { case MXMediaLoaderStateDownloadCompleted: [[NSNotificationCenter defaultCenter] removeObserver:self->onAttachmentDownloadObs]; self->onAttachmentDownloadObs = nil; if (onAttachmentReady) { onAttachmentReady (); } break; case MXMediaLoaderStateDownloadFailed: case MXMediaLoaderStateCancelled: [[NSNotificationCenter defaultCenter] removeObserver:self->onAttachmentDownloadObs]; self->onAttachmentDownloadObs = nil; if (onFailure) { onFailure (loader.error); } break; default: break; } }]; } else if (onFailure) { onFailure (nil); } } } - (void)save:(void (^)(void))onSuccess failure:(void (^)(NSError *error))onFailure { if (_type == MXKAttachmentTypeImage || _type == MXKAttachmentTypeVideo) { MXWeakify(self); if (self.isEncrypted) { [self decryptToTempFile:^(NSString *path) { MXStrongifyAndReturnIfNil(self); NSURL* url = [NSURL fileURLWithPath:path]; [MXMediaManager saveMediaToPhotosLibrary:url isImage:(self.type == MXKAttachmentTypeImage) success:^(NSURL *assetURL){ if (onSuccess) { [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; onSuccess(); } } failure:onFailure]; } failure:onFailure]; } else { [self prepare:^{ MXStrongifyAndReturnIfNil(self); NSURL* url = [NSURL fileURLWithPath:self.cacheFilePath]; [MXMediaManager saveMediaToPhotosLibrary:url isImage:(self.type == MXKAttachmentTypeImage) success:^(NSURL *assetURL){ if (onSuccess) { onSuccess(); } } failure:onFailure]; } failure:onFailure]; } } else { // Not supported if (onFailure) { onFailure(nil); } } } - (void)copy:(void (^)(void))onSuccess failure:(void (^)(NSError *error))onFailure { MXWeakify(self); [self prepare:^{ MXStrongifyAndReturnIfNil(self); if (self.type == MXKAttachmentTypeImage) { [self getImage:^(MXKAttachment *attachment, UIImage *img) { MXKPasteboardManager.shared.pasteboard.image = img; if (onSuccess) { onSuccess(); } } failure:^(MXKAttachment *attachment, NSError *error) { if (onFailure) onFailure(error); }]; } else { MXWeakify(self); [self getAttachmentData:^(NSData *data) { if (data) { MXStrongifyAndReturnIfNil(self); NSString* UTI = (__bridge_transfer NSString *) UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)[self.cacheFilePath pathExtension] , NULL); if (UTI) { [MXKPasteboardManager.shared.pasteboard setData:data forPasteboardType:UTI]; if (onSuccess) { onSuccess(); } } } } failure:onFailure]; } // Unexpected error if (onFailure) { onFailure(nil); } } failure:onFailure]; } - (MXKUTI *)uti { return [[MXKUTI alloc] initWithMimeType:mimetype]; } - (void)prepareShare:(void (^)(NSURL *fileURL))onReadyToShare failure:(void (^)(NSError *error))onFailure { MXWeakify(self); void (^haveFile)(NSString *) = ^(NSString *path) { // Prepare the file URL by considering the original file name (if any) NSURL *fileUrl; MXStrongifyAndReturnIfNil(self); // Check whether the original name retrieved from event body has extension if (self.originalFileName && [self.originalFileName pathExtension].length) { // Copy the cached file to restore its original name // Note: We used previously symbolic link (instead of copy) but UIDocumentInteractionController failed to open Office documents (.docx, .pptx...). self->documentCopyPath = [[MXMediaManager getCachePath] stringByAppendingPathComponent:self.originalFileName]; [[NSFileManager defaultManager] removeItemAtPath:self->documentCopyPath error:nil]; if ([[NSFileManager defaultManager] copyItemAtPath:path toPath:self->documentCopyPath error:nil]) { fileUrl = [NSURL fileURLWithPath:self->documentCopyPath]; [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; } } if (!fileUrl) { // Use the cached file by default fileUrl = [NSURL fileURLWithPath:path]; self->documentCopyPath = path; } onReadyToShare (fileUrl); }; if (self.isEncrypted) { [self decryptToTempFile:^(NSString *path) { haveFile(path); } failure:onFailure]; } else { // First download data if it is not already done [self prepare:^{ haveFile(self.cacheFilePath); } failure:onFailure]; } } - (void)onShareEnded { // Remove the temporary file created to prepare attachment sharing if (documentCopyPath) { [[NSFileManager defaultManager] removeItemAtPath:documentCopyPath error:nil]; documentCopyPath = nil; } } @end