// _____ _
// |_ _| |_ _ _ ___ ___ _ __ __ _
// | | | ' \| '_/ -_) -_) ' \/ _` |_
// |_| |_||_|_| \___\___|_|_|_\__,_(_)
//
// Threema iOS Client
// Copyright (c) 2016-2020 Threema GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License, version 3,
// as published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
#import "SenderItemManager.h"
#import "URLSenderItem.h"
#import "UTIConverter.h"
#import "Conversation.h"
#import "BundleUtil.h"
#import "MessageSender.h"
#import "TextMessage.h"
#import "DatabaseManager.h"
#import "EntityManager.h"
#import "MediaConverter.h"
#import "FileMessageSender.h"
#ifdef DEBUG
static const DDLogLevel ddLogLevel = DDLogLevelVerbose;
#else
static const DDLogLevel ddLogLevel = DDLogLevelWarning;
#endif
@interface IntermediateItem : NSObject
@property NSItemProvider *itemProvider;
@property NSString *type;
@property NSString *secondType;
@property NSString *caption;
@end
@implementation IntermediateItem
@end
@interface SenderItemManager ()
@property NSSet *recipientConversations;
@property NSMutableSet *itemsToSend;
@property NSString *textToSend;
@property NSInteger sentItemCount;
@property NSInteger totalSendCount;
@property NSMutableArray *correlationIDs;
@property dispatch_semaphore_t loadItemsSema;
@end
@implementation SenderItemManager
- (instancetype)init
{
self = [super init];
if (self) {
_itemsToSend = [NSMutableSet set];
_containsFileItem = NO;
_shouldCancel = NO;
_sendAsFile = false;
_loadItemsSema = dispatch_semaphore_create(0);
}
return self;
}
- (NSUInteger)itemCount {
NSUInteger count = _itemsToSend.count;
if (_textToSend.length > 0 && ![self canSendCaptions]) {
count++;
}
return count;
}
- (BOOL)isFileItem:(IntermediateItem *)item {
if ([UTIConverter type:item.type conformsTo:UTTYPE_AUDIO]) {
return NO;
} else if ([UTIConverter type:item.type conformsTo:UTTYPE_PLAIN_TEXT]) {
return NO;
} else if ([UTIConverter type:item.type conformsTo:UTTYPE_URL]) {
return NO;
}
return YES;
}
- (void)addItem:(NSItemProvider *)itemProvider forType:(NSString *)type secondType:(NSString *)secondType {
IntermediateItem *item = [IntermediateItem new];
item.itemProvider = itemProvider;
item.type = type;
item.secondType = secondType;
[_itemsToSend addObject:item];
if ([self isFileItem:item]) {
_containsFileItem = YES;
}
}
- (void)addText:(NSString *)text {
_textToSend = text;
}
- (void)sendItemsTo:(NSSet *)conversations {
_recipientConversations = conversations;
NSInteger count = [conversations count] * self.itemCount;
_totalSendCount = count;
_sentItemCount = 0;
_correlationIDs = [[NSMutableArray alloc] initWithCapacity:conversations.count];
for (int i = 0; i < conversations.count; i++) {
_correlationIDs[i] = [ImageURLSenderItemCreator createCorrelationID];
}
if (_textToSend.length > 0) {
if ([self canSendCaptions]) {
IntermediateItem *anyItem = [_itemsToSend anyObject];
anyItem.caption = _textToSend;
} else {
for (Conversation *conversation in _recipientConversations) {
if (_shouldCancel) {
return;
}
[self sendItem:_textToSend toConversation:conversation correlationID:nil];
}
}
}
dispatch_queue_t dispatchQueue = dispatch_queue_create("ch.threema.LoadItemsForShareExtension", NULL);
dispatch_async(dispatchQueue, ^{
for (IntermediateItem *intermediateItem in _itemsToSend) {
[self loadAndSendItem:intermediateItem];
}
});
}
- (void)loadAndSendItem:(IntermediateItem *)intermediateItem {
NSString *type = intermediateItem.type;
if ([type isEqualToString:@"com.apple.live-photo"]) {
type = intermediateItem.secondType;
}
if ([type isEqualToString:@"com.apple.avfoundation.urlasset"]) {
type = intermediateItem.secondType;
}
[intermediateItem.itemProvider loadItemForTypeIdentifier:type options:nil completionHandler:^(id item, NSError *error) {
if (error == nil && _shouldCancel == NO) {
id senderItem = [self loadSenderItem:item ofType:intermediateItem.type secondType:intermediateItem.secondType];
if (senderItem == nil) {
return;
}
if (intermediateItem.caption && [senderItem isKindOfClass:[URLSenderItem class]]) {
((URLSenderItem*)senderItem).caption = intermediateItem.caption;
}
NSArray *recipients = [_recipientConversations allObjects];
for (int i = 0; i < _recipientConversations.count; i++) {
if (_shouldCancel) {
return;
}
[self sendItem:senderItem toConversation:recipients[i] correlationID:_correlationIDs[i]];
}
}
}];
dispatch_semaphore_wait(_loadItemsSema, DISPATCH_TIME_FOREVER);
}
- (BOOL)canSendCaptionInline {
if (_itemsToSend.count == 1) {
IntermediateItem *anyItem = [_itemsToSend anyObject];
if ([UTIConverter type:anyItem.type conformsTo:UTTYPE_GIF_IMAGE]) {
return NO;
}
if ([UTIConverter type:anyItem.type conformsTo:UTTYPE_IMAGE]) {
// Only one image, so we can send the text as an inline caption
return YES;
}
}
return NO;
}
- (BOOL)canSendCaptions {
if (_itemsToSend.count == 1) {
return [self canSendCaptionInline];
}
return false;
}
- (id)loadSenderItem:item ofType:(NSString *)type secondType:(NSString *)secondType {
if ([item isKindOfClass:[NSURL class]]) {
if (secondType != nil) {
return [self prepareUrlItem:item forType:secondType];
}
return [self prepareUrlItem:item forType:type];
} else if ([item isKindOfClass:[NSData class]]) {
return [self prepareDataItem:item forType:type];
} else if ([item isKindOfClass:[NSString class]]) {
return item;
} else if ([item isKindOfClass:[UIImage class]]) {
return [self prepareImageItem:item forType:type];
}
else {
NSString *title = NSLocalizedString(@"error_message_no_items_title", nil);
NSString *message = NSLocalizedString(@"error_message_no_items_message", nil);
[_delegate showAlertWithTitle:title message:message];
return nil;
}
}
- (void)sendItem:(id)senderItem toConversation:(Conversation *)conversation correlationID:(NSString *)correlationID {
if ([senderItem isKindOfClass:[URLSenderItem class]]) {
FileMessageSender *sender = [[FileMessageSender alloc] init];
[sender sendItem:senderItem inConversation:conversation requestId:nil correlationId:correlationID];
sender.uploadProgressDelegate = self;
} else if ([senderItem isKindOfClass:[NSString class]]) {
NSString *message = (NSString *)senderItem;
// make sure DB object is created in main thread (to fetch KVO for keypath "sent")
if (message && message.length) {
dispatch_async(dispatch_get_main_queue(), ^{
[_delegate setProgress:[NSNumber numberWithFloat:0.1] forItem:[self progressItemKey:message conversation:conversation]];
[MessageSender sendMessage:message inConversation:conversation async:YES quickReply:NO requestId:nil onCompletion:^(TextMessage *message, Conversation *conv) {
[self awaitAckForMessageId:message.id];
}];
});
} else {
// increment sent count for unknown types
[_delegate finishedItem:[self progressItemKey:message conversation:conversation]];
_sentItemCount++;
[self checkIsFinished];
}
} else {
NSString *title = NSLocalizedString(@"error_message_no_items_title", nil);
NSString *message = NSLocalizedString(@"error_message_no_items_message", nil);
[_delegate showAlertWithTitle:title message:message];
// increment sent count for unknown types
_sentItemCount++;
[self checkIsFinished];
}
}
- (void)awaitAckForMessageId:(NSData *)messageId {
EntityManager *entityManager = [[EntityManager alloc] init];
BaseMessage *message = [entityManager.entityFetcher ownMessageWithId:messageId];
[message addObserver:self forKeyPath:@"sent" options:0 context:nil];
}
#pragma mark - KVO
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if ([object isKindOfClass:[TextMessage class]]) {
TextMessage *message = (TextMessage *)object;
[_delegate finishedItem:[self progressItemKey:message.text conversation:message.conversation]];
[message removeObserver:self forKeyPath:@"sent"];
[[DatabaseManager dbManager] addDirtyObject:message.conversation];
[[DatabaseManager dbManager] addDirtyObject:message.conversation.lastMessage];
dispatch_semaphore_signal(_loadItemsSema);
_sentItemCount++;
[self checkIsFinished];
}
}
- (id)progressItemKey:(id)item conversation:(Conversation *)conversation {
NSInteger hash = [item hash] + [conversation hash];
return [NSNumber numberWithInteger: hash];
}
- (NSString *)renderHtmlToText:(NSString *)htmlString {
NSString *editedHtmlString = [htmlString stringByReplacingOccurrencesOfString:@"\n" withString:@"\"];
NSAttributedString *attributedString = [[NSAttributedString alloc] initWithData:[editedHtmlString dataUsingEncoding:NSUTF8StringEncoding] options:@{NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType, NSCharacterEncodingDocumentAttribute: @(NSUTF8StringEncoding)} documentAttributes:nil error:nil];
if (attributedString != nil) {
return [attributedString string];
}
return htmlString;
}
- (URLSenderItem *)prepareImageItem:(id)item forType:(NSString *)type {
UIImage *image = (UIImage *)item;
ImageURLSenderItemCreator *creator = [[ImageURLSenderItemCreator alloc] init];
return [creator senderItemFromImage:image];
}
- (URLSenderItem *)prepareDataItem:(id)item forType:(NSString *)type {
NSData *data = (NSData *)item;
NSString *uti = [UTIConverter utiFromMimeType:type];
if ([UTIConverter conformsToImageType:uti]) {
ImageURLSenderItemCreator *creator = [[ImageURLSenderItemCreator alloc] init];
return [creator senderItemFrom:data uti:uti];
} else if ([UTIConverter conformsToMovieType:uti]) {
VideoURLSenderItemCreator *creator = [[VideoURLSenderItemCreator alloc] init];
NSURL *url = [VideoURLSenderItemCreator writeToTemporaryDirectoryWithData:data];
if (url == nil) {
DDLogError(@"Could not create URLSenderItem from media asset");
return nil;
}
return [creator senderItemFrom:url];
}
return [URLSenderItem itemWithData:data fileName:nil type:type renderType:@0 sendAsFile:_sendAsFile];
}
- (id)prepareUrlItem:(id)item forType:(NSString *)type {
NSURL *url = (NSURL *)item;
if (url == nil) {
return false;
}
if ([url.scheme isEqualToString:@"file"]) {
URLSenderItem *senderItem = [URLSenderItemCreator getSenderItemFor:url maxSize:@"large"];
if ([self checkFileConstraints:senderItem]) {
return senderItem;
}
} else {
return url.absoluteString;
}
return nil;
}
- (BOOL)checkFileConstraints:(URLSenderItem *)senderItem {
NSString *errorTitle;
NSString *errorMessage;
if ([UTIConverter type:senderItem.type conformsTo:UTTYPE_MOVIE]) {
if ([MediaConverter isVideoDurationValidAtUrl:senderItem.url] == NO) {
errorTitle = [BundleUtil localizedStringForKey:@"video_too_long_title"];
errorMessage = [NSString stringWithFormat:[BundleUtil localizedStringForKey:@"video_too_long_message"], [MediaConverter videoMaxDurationAtCurrentQuality]];
}
} else {
NSDictionary *fileDictionary = [[NSFileManager defaultManager] attributesOfItemAtPath:senderItem.url.path error:nil];
if ([fileDictionary fileSize] > kMaxFileSize) {
errorTitle = [BundleUtil localizedStringForKey:@"error_constraints_failed"];
errorMessage = [FileMessageSender messageForError:UploadErrorFileTooBig];
}
}
if (errorMessage) {
// cancel everything in case of error
_shouldCancel = YES;
[_delegate showAlertWithTitle:errorTitle message:errorMessage];
return NO;
}
return YES;
}
- (void)checkIsFinished {
if (_sentItemCount == _totalSendCount) {
// delay finish slightly since DB safe of ack might not have finished yet
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(100 * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{
[_delegate setFinished];
});
}
}
#pragma mark - UploadProgressDelegate
- (BOOL)blobMessageSenderUploadShouldCancel:(BlobMessageSender *)blobMessageSender {
return _shouldCancel;
}
- (void)blobMessageSender:(BlobMessageSender *)blobMessageSender uploadProgress:(NSNumber *)progress forMessage:(BaseMessage *)message {
[_delegate setProgress:progress forItem:message.id];
}
- (void)blobMessageSender:(BlobMessageSender *)blobMessageSender uploadSucceededForMessage:(BaseMessage *)message {
dispatch_semaphore_signal(_loadItemsSema);
_sentItemCount++;
[_delegate finishedItem:message.id];
[[DatabaseManager dbManager] addDirtyObject:message.conversation];
[[DatabaseManager dbManager] addDirtyObject:message];
[self checkIsFinished];
}
- (void)blobMessageSender:(BlobMessageSender *)blobMessageSender uploadFailedForMessage:(BaseMessage *)message error:(UploadError)error {
dispatch_semaphore_signal(_loadItemsSema);
_sentItemCount++;
NSString *errorTitle = [BundleUtil localizedStringForKey:@"error_sending_failed"];
NSString *errorMessage = [FileMessageSender messageForError:error];
[_delegate showAlertWithTitle:errorTitle message:errorMessage];
if (error == UploadErrorSendFailed) {
[[DatabaseManager dbManager] addDirtyObject:message.conversation];
[[DatabaseManager dbManager] addDirtyObject:message];
}
}
@end