// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import "TSMessage.h" #import "AppContext.h" #import "MIMETypeUtil.h" #import "NSDate+OWS.h" #import "NSString+SSK.h" #import "TSAttachment.h" #import "TSAttachmentStream.h" #import "TSQuotedMessage.h" #import "TSThread.h" #import #import NS_ASSUME_NONNULL_BEGIN static const NSUInteger OWSMessageSchemaVersion = 4; #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; // The timestamp property is populated by the envelope, // which is created by the sender. // // We typically want to order messages locally by when // they were received & decrypted, not by when they were sent. @property (nonatomic) uint64_t receivedAtTimestamp; @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 { 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]; _receivedAtTimestamp = [NSDate ows_millisecondTimeStamp]; _quotedMessage = quotedMessage; 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]; } if (_receivedAtTimestamp == 0) { // Upgrade from the older "receivedAtDate" and "receivedAt" properties if // necessary. NSDate *receivedAtDate = [coder decodeObjectForKey:@"receivedAtDate"]; if (!receivedAtDate) { receivedAtDate = [coder decodeObjectForKey:@"receivedAt"]; } if (receivedAtDate) { _receivedAtTimestamp = [NSDate ows_millisecondsSince1970ForDate:receivedAtDate]; } } _schemaVersion = OWSMessageSchemaVersion; return self; } - (void)setExpiresInSeconds:(uint32_t)expiresInSeconds { _expiresInSeconds = expiresInSeconds; [self updateExpiresAt]; } - (void)setExpireStartedAt:(uint64_t)expireStartedAt { _expireStartedAt = expireStartedAt; [self updateExpiresAt]; } - (BOOL)shouldStartExpireTimer { __block BOOL result; [self.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) { result = [self shouldStartExpireTimer:transaction]; }]; return result; } - (BOOL)shouldStartExpireTimer:(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; } - (nullable TSAttachment *)attachmentWithTransaction:(YapDatabaseReadTransaction *)transaction { if (!self.hasAttachments) { return nil; } OWSAssert(self.attachmentIds.count == 1); return [TSAttachment fetchObjectWithUniqueID:self.attachmentIds.firstObject transaction: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]; } } // TODO: This method contains view-specific logic and probably belongs in NotificationsManager, not in SSK. - (NSString *)previewTextWithTransaction:(YapDatabaseReadTransaction *)transaction { NSString *_Nullable attachmentDescription = nil; if ([self hasAttachments]) { NSString *attachmentId = self.attachmentIds[0]; TSAttachment *attachment = [TSAttachment fetchObjectWithUniqueID:attachmentId transaction:transaction]; if ([OWSMimeTypeOversizeTextMessage isEqualToString:attachment.contentType]) { // Handle oversize text attachments. if ([attachment isKindOfClass:[TSAttachmentStream class]]) { TSAttachmentStream *attachmentStream = (TSAttachmentStream *)attachment; NSData *_Nullable data = [NSData dataWithContentsOfFile:attachmentStream.filePath]; if (data) { NSString *_Nullable text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; if (text) { return text.filterStringForDisplay; } } } return @""; } else if (attachment) { attachmentDescription = attachment.description; } else { attachmentDescription = NSLocalizedString(@"UNKNOWN_ATTACHMENT_LABEL", @"In Inbox view, last message label for thread with corrupted attachment."); } } NSString *_Nullable bodyDescription = nil; if (self.body.length > 0) { bodyDescription = self.body; } 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 { OWSFail(@"%@ message has neither body nor attachment.", self.logTag); // TODO: We should do better here. return @""; } } // TODO deprecate this and implement something like previewTextWithTransaction: for all TSInteractions - (NSString *)description { __block NSString *result; [self.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) { result = [self previewTextWithTransaction:transaction]; }]; return result; } - (void)removeWithTransaction:(YapDatabaseReadWriteTransaction *)transaction { [super removeWithTransaction:transaction]; for (NSString *attachmentId in self.attachmentIds) { // We need to fetch each attachment, since [TSAttachment removeWithTransaction:] does important work. TSAttachment *_Nullable attachment = [TSAttachment fetchObjectWithUniqueID:attachmentId transaction:transaction]; if (!attachment) { OWSProdLogAndFail(@"%@ couldn't load interaction's attachment for deletion.", self.logTag); continue; } [attachment removeWithTransaction:transaction]; }; // Updates inbox thread preview [self touchThreadWithTransaction:transaction]; } - (void)touchThreadWithTransaction:(YapDatabaseReadWriteTransaction *)transaction { [transaction touchObjectForKey:self.uniqueThreadId inCollection:[TSThread collection]]; } - (BOOL)isExpiringMessage { return self.expiresInSeconds > 0; } - (uint64_t)timestampForSorting { if ([self shouldUseReceiptDateForSorting] && self.receivedAtTimestamp > 0) { return self.receivedAtTimestamp; } else { OWSAssert(self.timestamp > 0); return self.timestamp; } } - (BOOL)shouldUseReceiptDateForSorting { return YES; } - (nullable NSString *)body { return _body.filterStringForDisplay; } - (void)setQuotedMessageThumbnailAttachmentStream:(TSAttachmentStream *)attachmentStream { OWSAssert([attachmentStream isKindOfClass:[TSAttachmentStream class]]); OWSAssert(self.quotedMessage); OWSAssert(self.quotedMessage.quotedAttachments.count == 1); [self.quotedMessage setThumbnailAttachmentStream:attachmentStream]; } #pragma mark - Update With... Methods - (void)updateWithExpireStartedAt:(uint64_t)expireStartedAt transaction:(YapDatabaseReadWriteTransaction *)transaction { OWSAssert(expireStartedAt > 0); [self applyChangeToSelfAndLatestCopy:transaction changeBlock:^(TSMessage *message) { [message setExpireStartedAt:expireStartedAt]; }]; } @end NS_ASSUME_NONNULL_END