/* Copyright 2015 OpenMarket Ltd Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ #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[@"msgtype"]; 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:kMXMessageTypeLocation]) { // Not supported yet // _type = MXKAttachmentTypeLocation; return nil; } else if ([msgtype isEqualToString:kMXMessageTypeFile]) { _type = MXKAttachmentTypeFile; } else { return nil; } } MXJSONModelSetString(_originalFileName, eventContent[@"body"]); 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) { MXLogError(@"[MXKAttachment] Failed deleting temporary file with error: %@", error); } } } - (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