// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // #import "TSMessage.h" #import "AppContext.h" #import "MIMETypeUtil.h" #import "OWSDisappearingMessagesConfiguration.h" #import "TSAttachment.h" #import "TSAttachmentStream.h" #import "TSQuotedMessage.h" #import "TSThread.h" #import #import #import #import #import "TSContactThread.h" #import NS_ASSUME_NONNULL_BEGIN static const NSUInteger OWSMessageSchemaVersion = 4; const NSUInteger kOversizeTextMessageSizeThreshold = 2 * 1024; #pragma mark - @interface TSMessage () @property (nonatomic, nullable) NSString *body; @property (nonatomic) uint32_t expiresInSeconds; @property (nonatomic) uint64_t expireStartedAt; /** * The version of the model class's schema last used to serialize this model. Use this to manage data migrations during * object de/serialization. * * e.g. * * - (id)initWithCoder:(NSCoder *)coder * { * self = [super initWithCoder:coder]; * if (!self) { return self; } * if (_schemaVersion < 2) { * _newName = [coder decodeObjectForKey:@"oldName"] * } * ... * _schemaVersion = 2; * } */ @property (nonatomic, readonly) NSUInteger schemaVersion; @end #pragma mark - @implementation TSMessage - (instancetype)initMessageWithTimestamp:(uint64_t)timestamp inThread:(nullable TSThread *)thread messageBody:(nullable NSString *)body attachmentIds:(NSArray *)attachmentIds expiresInSeconds:(uint32_t)expiresInSeconds expireStartedAt:(uint64_t)expireStartedAt quotedMessage:(nullable TSQuotedMessage *)quotedMessage linkPreview:(nullable OWSLinkPreview *)linkPreview openGroupInvitationName:(nullable NSString *)openGroupInvitationName openGroupInvitationURL:(nullable NSString *)openGroupInvitationURL { self = [super initInteractionWithTimestamp:timestamp inThread:thread]; if (!self) { return self; } _schemaVersion = OWSMessageSchemaVersion; _body = body; _attachmentIds = attachmentIds ? [attachmentIds mutableCopy] : [NSMutableArray new]; _expiresInSeconds = expiresInSeconds; _expireStartedAt = expireStartedAt; [self updateExpiresAt]; _quotedMessage = quotedMessage; _linkPreview = linkPreview; _openGroupServerMessageID = 0; _openGroupInvitationName = openGroupInvitationName; _openGroupInvitationURL = openGroupInvitationURL; return self; } - (nullable instancetype)initWithCoder:(NSCoder *)coder { self = [super initWithCoder:coder]; if (!self) { return self; } if (_schemaVersion < 2) { // renamed _attachments to _attachmentIds if (!_attachmentIds) { _attachmentIds = [coder decodeObjectForKey:@"attachments"]; } } if (_schemaVersion < 3) { _expiresInSeconds = 0; _expireStartedAt = 0; _expiresAt = 0; } if (_schemaVersion < 4) { // Wipe out the body field on these legacy attachment messages. // // Explantion: Historically, a message sent from iOS could be an attachment XOR a text message, // but now we support sending an attachment+caption as a single message. // // Other clients have supported sending attachment+caption in a single message for a long time. // So the way we used to handle receiving them was to make it look like they'd sent two messages: // first the attachment+caption (we'd ignore this caption when rendering), followed by a separate // message with just the caption (which we'd render as a simple independent text message), for // which we'd offset the timestamp by a little bit to get the desired ordering. // // Now that we can properly render an attachment+caption message together, these legacy "dummy" text // messages are not only unnecessary, but worse, would be rendered redundantly. For safety, rather // than building the logic to try to find and delete the redundant "dummy" text messages which users // have been seeing and interacting with, we delete the body field from the attachment message, // which iOS users have never seen directly. if (_attachmentIds.count > 0) { _body = nil; } } if (!_attachmentIds) { _attachmentIds = [NSMutableArray new]; } _schemaVersion = OWSMessageSchemaVersion; return self; } - (void)setExpiresInSeconds:(uint32_t)expiresInSeconds { uint32_t maxExpirationDuration = [OWSDisappearingMessagesConfiguration maxDurationSeconds]; _expiresInSeconds = MIN(expiresInSeconds, maxExpirationDuration); [self updateExpiresAt]; } - (void)setExpireStartedAt:(uint64_t)expireStartedAt { if (_expireStartedAt != 0 && _expireStartedAt < expireStartedAt) { return; } uint64_t now = [NSDate ows_millisecondTimeStamp]; _expireStartedAt = MIN(now, expireStartedAt); [self updateExpiresAt]; } - (BOOL)shouldStartExpireTimerWithTransaction:(YapDatabaseReadTransaction *)transaction { return self.isExpiringMessage; } // TODO a downloaded media doesn't start counting until download is complete. - (void)updateExpiresAt { if (_expiresInSeconds > 0 && _expireStartedAt > 0) { _expiresAt = _expireStartedAt + _expiresInSeconds * 1000; } else { _expiresAt = 0; } } - (BOOL)hasAttachments { return self.attachmentIds ? (self.attachmentIds.count > 0) : NO; } - (NSArray *)allAttachmentIds { NSMutableArray *result = [NSMutableArray new]; if (self.attachmentIds.count > 0) { [result addObjectsFromArray:self.attachmentIds]; } if (self.quotedMessage) { [result addObjectsFromArray:self.quotedMessage.thumbnailAttachmentStreamIds]; } if (self.linkPreview.imageAttachmentId) { [result addObject:self.linkPreview.imageAttachmentId]; } return [result copy]; } - (NSArray *)attachmentsWithTransaction:(YapDatabaseReadTransaction *)transaction { NSMutableArray *attachments = [NSMutableArray new]; for (NSString *attachmentId in self.attachmentIds) { TSAttachment *_Nullable attachment = [TSAttachment fetchObjectWithUniqueID:attachmentId transaction:transaction]; if (attachment) { [attachments addObject:attachment]; } } return [attachments copy]; } - (NSArray *)attachmentsWithTransaction:(YapDatabaseReadTransaction *)transaction contentType:(NSString *)contentType { NSArray *attachments = [self attachmentsWithTransaction:transaction]; return [attachments filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(TSAttachment *evaluatedObject, NSDictionary *_Nullable bindings) { return [evaluatedObject.contentType isEqualToString:contentType]; }]]; } - (NSArray *)attachmentsWithTransaction:(YapDatabaseReadTransaction *)transaction exceptContentType:(NSString *)contentType { NSArray *attachments = [self attachmentsWithTransaction:transaction]; return [attachments filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(TSAttachment *evaluatedObject, NSDictionary *_Nullable bindings) { return ![evaluatedObject.contentType isEqualToString:contentType]; }]]; } - (void)removeAttachment:(TSAttachment *)attachment transaction:(YapDatabaseReadWriteTransaction *)transaction; { [attachment removeWithTransaction:transaction]; [self.attachmentIds removeObject:attachment.uniqueId]; [self saveWithTransaction:transaction]; } - (void)addAttachmentWithID:(NSString *)attachmentID in:(YapDatabaseReadWriteTransaction *)transaction { if (!self.attachmentIds) { return; } [self.attachmentIds addObject:attachmentID]; [self saveWithTransaction:transaction]; } - (NSString *)debugDescription { if ([self hasAttachments] && self.body.length > 0) { NSString *attachmentId = self.attachmentIds[0]; return [NSString stringWithFormat:@"Media Message with attachmentId: %@ and caption: '%@'", attachmentId, self.body]; } else if ([self hasAttachments]) { NSString *attachmentId = self.attachmentIds[0]; return [NSString stringWithFormat:@"Media Message with attachmentId: %@", attachmentId]; } else { return [NSString stringWithFormat:@"%@ with body: %@", [self class], self.body]; } } - (nullable TSAttachment *)oversizeTextAttachmentWithTransaction:(YapDatabaseReadTransaction *)transaction { return [self attachmentsWithTransaction:transaction contentType:OWSMimeTypeOversizeTextMessage].firstObject; } - (NSArray *)mediaAttachmentsWithTransaction:(YapDatabaseReadTransaction *)transaction { return [self attachmentsWithTransaction:transaction exceptContentType:OWSMimeTypeOversizeTextMessage]; } - (nullable NSString *)oversizeTextWithTransaction:(YapDatabaseReadTransaction *)transaction { TSAttachment *_Nullable attachment = [self oversizeTextAttachmentWithTransaction:transaction]; if (!attachment) { return nil; } if (![attachment isKindOfClass:TSAttachmentStream.class]) { return nil; } TSAttachmentStream *attachmentStream = (TSAttachmentStream *)attachment; NSData *_Nullable data = [NSData dataWithContentsOfFile:attachmentStream.originalFilePath]; if (!data) { return nil; } NSString *_Nullable text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; if (!text) { return nil; } return text.filterStringForDisplay; } - (nullable NSString *)bodyTextWithTransaction:(YapDatabaseReadTransaction *)transaction { NSString *_Nullable oversizeText = [self oversizeTextWithTransaction:transaction]; if (oversizeText) { return oversizeText; } if (self.body.length > 0) { return self.body.filterStringForDisplay; } return nil; } // TODO: This method contains view-specific logic and probably belongs in NotificationsManager, not in SSK. - (NSString *)previewTextWithTransaction:(YapDatabaseReadTransaction *)transaction { NSString *_Nullable bodyDescription = nil; if (self.body.length > 0) { bodyDescription = self.body; } if (bodyDescription == nil) { TSAttachment *_Nullable oversizeTextAttachment = [self oversizeTextAttachmentWithTransaction:transaction]; if ([oversizeTextAttachment isKindOfClass:[TSAttachmentStream class]]) { TSAttachmentStream *oversizeTextAttachmentStream = (TSAttachmentStream *)oversizeTextAttachment; NSData *_Nullable data = [NSData dataWithContentsOfFile:oversizeTextAttachmentStream.originalFilePath]; if (data) { NSString *_Nullable text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; if (text) { bodyDescription = text.filterStringForDisplay; } } } } NSString *_Nullable attachmentDescription = nil; TSAttachment *_Nullable mediaAttachment = [self mediaAttachmentsWithTransaction:transaction].firstObject; if (mediaAttachment != nil) { attachmentDescription = mediaAttachment.description; } if (attachmentDescription.length > 0 && bodyDescription.length > 0) { // Attachment with caption. if ([CurrentAppContext() isRTL]) { return [[bodyDescription stringByAppendingString:@": "] stringByAppendingString:attachmentDescription]; } else { return [[attachmentDescription stringByAppendingString:@": "] stringByAppendingString:bodyDescription]; } } else if (bodyDescription.length > 0) { return bodyDescription; } else if (attachmentDescription.length > 0) { return attachmentDescription; } else if (self.openGroupInvitationName != nil) { return @"😎 Open group invitation"; } else { // TODO: We should do better here. return @""; } } - (void)removeWithTransaction:(YapDatabaseReadWriteTransaction *)transaction { [super removeWithTransaction:transaction]; for (NSString *attachmentId in self.allAttachmentIds) { // We need to fetch each attachment, since [TSAttachment removeWithTransaction:] does important work. TSAttachment *_Nullable attachment = [TSAttachment fetchObjectWithUniqueID:attachmentId transaction:transaction]; if (!attachment) { continue; } [attachment removeWithTransaction:transaction]; }; } - (BOOL)isExpiringMessage { return self.expiresInSeconds > 0; } - (uint64_t)timestampForLegacySorting { if ([self shouldUseReceiptDateForSorting] && self.receivedAtTimestamp > 0) { return self.receivedAtTimestamp; } else { return self.timestamp; } } - (BOOL)shouldUseReceiptDateForSorting { return YES; } - (nullable NSString *)body { return _body.filterStringForDisplay; } - (void)setQuotedMessageThumbnailAttachmentStream:(TSAttachmentStream *)attachmentStream { [self.quotedMessage setThumbnailAttachmentStream:attachmentStream]; } - (BOOL)isOpenGroupMessage { return (self.openGroupServerMessageID != 0); } #pragma mark - Update With... Methods - (void)updateWithExpireStartedAt:(uint64_t)expireStartedAt transaction:(YapDatabaseReadWriteTransaction *)transaction { [self applyChangeToSelfAndLatestCopy:transaction changeBlock:^(TSMessage *message) { [message setExpireStartedAt:expireStartedAt]; }]; } - (void)updateWithLinkPreview:(OWSLinkPreview *)linkPreview transaction:(YapDatabaseReadWriteTransaction *)transaction { [self applyChangeToSelfAndLatestCopy:transaction changeBlock:^(TSMessage *message) { [message setLinkPreview:linkPreview]; }]; } @end NS_ASSUME_NONNULL_END