// // Copyright 2017 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // #import "TSMessage.h" #import "OWSDisappearingMessagesConfiguration.h" #import "TSQuotedMessage.h" #import "TSThread.h" #import NS_ASSUME_NONNULL_BEGIN static const NSUInteger OWSMessageSchemaVersion = 4; #pragma mark - @interface TSMessage () @property (nonatomic, nullable) NSString *body; @property (nonatomic, nullable) MessageBodyRanges *bodyRanges; @property (nonatomic) uint32_t expiresInSeconds; @property (nonatomic) uint64_t expireStartedAt; @property (nonatomic, nullable) NSNumber *expireTimerVersion; /** * 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; @property (nonatomic, nullable) TSQuotedMessage *quotedMessage; @property (nonatomic, nullable) OWSContact *contactShare; @property (nonatomic, nullable) OWSLinkPreview *linkPreview; @property (nonatomic, nullable) MessageSticker *messageSticker; @property (nonatomic) BOOL isViewOnceMessage; @property (nonatomic) BOOL isViewOnceComplete; @property (nonatomic) BOOL wasRemotelyDeleted; @property (nonatomic, nullable) NSString *storyReactionEmoji; // This property is only intended to be used by GRDB queries. @property (nonatomic, readonly) BOOL storedShouldStartExpireTimer; @property (nonatomic) BOOL isPoll; @end #pragma mark - @implementation TSMessage - (instancetype)initMessageWithBuilder:(TSMessageBuilder *)messageBuilder { self = [super initWithTimestamp:messageBuilder.timestamp receivedAtTimestamp:messageBuilder.receivedAtTimestamp thread:messageBuilder.thread]; if (!self) { return self; } _schemaVersion = OWSMessageSchemaVersion; if (messageBuilder.messageBody.length > 0) { _body = messageBuilder.messageBody; _bodyRanges = messageBuilder.bodyRanges; } _deprecated_attachmentIds = nil; _editState = messageBuilder.editState; _expiresInSeconds = messageBuilder.expiresInSeconds; _expireStartedAt = messageBuilder.expireStartedAt; _expireTimerVersion = messageBuilder.expireTimerVersion; [self updateExpiresAt]; _isSmsMessageRestoredFromBackup = messageBuilder.isSmsMessageRestoredFromBackup; _isViewOnceMessage = messageBuilder.isViewOnceMessage; _isViewOnceComplete = messageBuilder.isViewOnceComplete; _wasRemotelyDeleted = messageBuilder.wasRemotelyDeleted; _storyTimestamp = messageBuilder.storyTimestamp; _storyAuthorUuidString = messageBuilder.storyAuthorAci.serviceIdUppercaseString; _storyReactionEmoji = messageBuilder.storyReactionEmoji; _isGroupStoryReply = messageBuilder.isGroupStoryReply; _quotedMessage = messageBuilder.quotedMessage; _contactShare = messageBuilder.contactShare; _linkPreview = messageBuilder.linkPreview; _messageSticker = messageBuilder.messageSticker; _giftBadge = messageBuilder.giftBadge; _isPoll = messageBuilder.isPoll; return self; } // --- CODE GENERATION MARKER // This snippet is generated by /Scripts/sds_codegen/sds_generate.py. Do not manually edit it, instead run // `sds_codegen.sh`. // clang-format off - (instancetype)initWithGrdbId:(int64_t)grdbId uniqueId:(NSString *)uniqueId receivedAtTimestamp:(uint64_t)receivedAtTimestamp sortId:(uint64_t)sortId timestamp:(uint64_t)timestamp uniqueThreadId:(NSString *)uniqueThreadId body:(nullable NSString *)body bodyRanges:(nullable MessageBodyRanges *)bodyRanges contactShare:(nullable OWSContact *)contactShare deprecated_attachmentIds:(nullable NSArray *)deprecated_attachmentIds editState:(TSEditState)editState expireStartedAt:(uint64_t)expireStartedAt expireTimerVersion:(nullable NSNumber *)expireTimerVersion expiresAt:(uint64_t)expiresAt expiresInSeconds:(unsigned int)expiresInSeconds giftBadge:(nullable OWSGiftBadge *)giftBadge isGroupStoryReply:(BOOL)isGroupStoryReply isPoll:(BOOL)isPoll isSmsMessageRestoredFromBackup:(BOOL)isSmsMessageRestoredFromBackup isViewOnceComplete:(BOOL)isViewOnceComplete isViewOnceMessage:(BOOL)isViewOnceMessage linkPreview:(nullable OWSLinkPreview *)linkPreview messageSticker:(nullable MessageSticker *)messageSticker quotedMessage:(nullable TSQuotedMessage *)quotedMessage storedShouldStartExpireTimer:(BOOL)storedShouldStartExpireTimer storyAuthorUuidString:(nullable NSString *)storyAuthorUuidString storyReactionEmoji:(nullable NSString *)storyReactionEmoji storyTimestamp:(nullable NSNumber *)storyTimestamp wasRemotelyDeleted:(BOOL)wasRemotelyDeleted { self = [super initWithGrdbId:grdbId uniqueId:uniqueId receivedAtTimestamp:receivedAtTimestamp sortId:sortId timestamp:timestamp uniqueThreadId:uniqueThreadId]; if (!self) { return self; } _body = body; _bodyRanges = bodyRanges; _contactShare = contactShare; _deprecated_attachmentIds = deprecated_attachmentIds; _editState = editState; _expireStartedAt = expireStartedAt; _expireTimerVersion = expireTimerVersion; _expiresAt = expiresAt; _expiresInSeconds = expiresInSeconds; _giftBadge = giftBadge; _isGroupStoryReply = isGroupStoryReply; _isPoll = isPoll; _isSmsMessageRestoredFromBackup = isSmsMessageRestoredFromBackup; _isViewOnceComplete = isViewOnceComplete; _isViewOnceMessage = isViewOnceMessage; _linkPreview = linkPreview; _messageSticker = messageSticker; _quotedMessage = quotedMessage; _storedShouldStartExpireTimer = storedShouldStartExpireTimer; _storyAuthorUuidString = storyAuthorUuidString; _storyReactionEmoji = storyReactionEmoji; _storyTimestamp = storyTimestamp; _wasRemotelyDeleted = wasRemotelyDeleted; [self sdsFinalizeMessage]; return self; } // clang-format on // --- CODE GENERATION MARKER - (void)sdsFinalizeMessage { [self updateExpiresAt]; } - (nullable instancetype)initWithCoder:(NSCoder *)coder { self = [super initWithCoder:coder]; if (!self) { return self; } if (_schemaVersion < 2) { // renamed _attachments to _attachmentIds if (!_deprecated_attachmentIds) { _deprecated_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. // // Explanation: 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 (_deprecated_attachmentIds.count > 0) { _body = nil; } } _schemaVersion = OWSMessageSchemaVersion; // Upgrades legacy messages. // // TODO: We can eventually remove this migration since // per-message expiration was never released to // production. NSNumber *_Nullable perMessageExpirationDurationSeconds = [coder decodeObjectForKey:@"perMessageExpirationDurationSeconds"]; if (perMessageExpirationDurationSeconds.unsignedIntegerValue > 0) { _isViewOnceMessage = YES; } NSNumber *_Nullable perMessageExpirationHasExpired = [coder decodeObjectForKey:@"perMessageExpirationHasExpired"]; if (perMessageExpirationHasExpired.boolValue > 0) { _isViewOnceComplete = YES; } return self; } - (void)setExpireStartedAt:(uint64_t)expireStartedAt { if (_expireStartedAt != 0 && _expireStartedAt < expireStartedAt) { return; } uint64_t now = [NSDate ows_millisecondTimeStamp]; if (expireStartedAt > now) { OWSLogWarn(@"using `now` instead of future time"); } _expireStartedAt = MIN(now, expireStartedAt); [self updateExpiresAt]; } // This method will be called after every insert and update, so it needs // to be cheap. - (BOOL)shouldStartExpireTimer { if (self.hasPerConversationExpirationStarted) { // Expiration already started. return YES; } return self.hasPerConversationExpiration; } - (void)updateExpiresAt { if (self.hasPerConversationExpirationStarted) { _expiresAt = _expireStartedAt + (uint64_t)_expiresInSeconds * 1000; } else { _expiresAt = 0; } } #pragma mark - Story Context - (nullable AciObjC *)storyAuthorAci { return [[AciObjC alloc] initWithAciString:self.storyAuthorUuidString]; } - (nullable SignalServiceAddress *)storyAuthorAddress { AciObjC *storyAuthorAci = self.storyAuthorAci; if (storyAuthorAci == nil) { return nil; } return [[SignalServiceAddress alloc] initWithServiceIdObjC:storyAuthorAci]; } - (BOOL)isStoryReply { return self.storyAuthorUuidString != nil; } - (NSString *)debugDescription { return [NSString stringWithFormat:@"%@ with body: %@ has mentions: %@", [self class], self.body, self.bodyRanges.hasMentions ? @"YES" : @"NO"]; } - (void)anyWillInsertWithTransaction:(DBWriteTransaction *)transaction { [super anyWillInsertWithTransaction:transaction]; [self insertMentionsInDatabaseWithTx:transaction]; [self updateStoredShouldStartExpireTimer]; } - (void)anyDidInsertWithTransaction:(DBWriteTransaction *)transaction { [super anyDidInsertWithTransaction:transaction]; [self _anyDidInsertWithTx:transaction]; [self ensurePerConversationExpirationWithTransaction:transaction]; [self touchStoryMessageIfNecessaryWithReplyCountIncrement:ReplyCountIncrementNewReplyAdded transaction:transaction]; } - (void)anyWillUpdateWithTransaction:(DBWriteTransaction *)transaction { [super anyWillUpdateWithTransaction:transaction]; [self updateStoredShouldStartExpireTimer]; } - (void)anyDidUpdateWithTransaction:(DBWriteTransaction *)transaction { [super anyDidUpdateWithTransaction:transaction]; [self _anyDidUpdateWithTx:transaction]; [self ensurePerConversationExpirationWithTransaction:transaction]; [self touchStoryMessageIfNecessaryWithReplyCountIncrement:ReplyCountIncrementNoIncrement transaction:transaction]; } - (void)ensurePerConversationExpirationWithTransaction:(DBWriteTransaction *)transaction { if (self.hasPerConversationExpirationStarted) { // Expiration already started. return; } if (![self shouldStartExpireTimer]) { return; } uint64_t nowMs = [NSDate ows_millisecondTimeStamp]; [SSKEnvironment.shared.disappearingMessagesJobRef startAnyExpirationForMessage:self expirationStartedAt:nowMs transaction:transaction]; } - (void)updateStoredShouldStartExpireTimer { _storedShouldStartExpireTimer = [self shouldStartExpireTimer]; } - (BOOL)hasPerConversationExpiration { return self.expiresInSeconds > 0; } - (BOOL)hasPerConversationExpirationStarted { return _expireStartedAt > 0 && _expiresInSeconds > 0; } - (BOOL)shouldUseReceiptDateForSorting { return YES; } - (nullable NSString *)body { return _body.filterStringForDisplay; } #pragma mark - Update With... Methods - (void)updateWithExpireStartedAt:(uint64_t)expireStartedAt transaction:(DBWriteTransaction *)transaction { OWSAssertDebug(expireStartedAt > 0); OWSAssertDebug(self.expiresInSeconds > 0); [self anyUpdateMessageWithTransaction:transaction block:^(TSMessage *message) { [message setExpireStartedAt:expireStartedAt]; }]; } - (void)updateWithLinkPreview:(OWSLinkPreview *)linkPreview transaction:(DBWriteTransaction *)transaction { OWSAssertDebug(linkPreview); OWSAssertDebug(transaction); [self anyUpdateMessageWithTransaction:transaction block:^(TSMessage *message) { [message setLinkPreview:linkPreview]; }]; } - (void)updateWithQuotedMessage:(TSQuotedMessage *)quotedMessage transaction:(DBWriteTransaction *)transaction { OWSAssertDebug(quotedMessage); OWSAssertDebug(transaction); [self anyUpdateMessageWithTransaction:transaction block:^(TSMessage *message) { [message setQuotedMessage:quotedMessage]; }]; } - (void)updateWithMessageSticker:(MessageSticker *)messageSticker transaction:(DBWriteTransaction *)transaction { OWSAssertDebug(messageSticker); OWSAssertDebug(transaction); [self anyUpdateMessageWithTransaction:transaction block:^(TSMessage *message) { message.messageSticker = messageSticker; }]; } - (void)updateWithContactShare:(OWSContact *)contactShare transaction:(DBWriteTransaction *)transaction { OWSAssertDebug(contactShare); OWSAssertDebug(transaction); [self anyUpdateMessageWithTransaction:transaction block:^(TSMessage *message) { message.contactShare = contactShare; }]; } - (void)updateWithIsPoll:(BOOL)isPoll transaction:(DBWriteTransaction *)transaction { OWSAssertDebug(transaction); [self anyUpdateMessageWithTransaction:transaction block:^(TSMessage *message) { message.isPoll = isPoll; }]; } #ifdef TESTABLE_BUILD // This method is for testing purposes only. - (void)updateWithMessageBody:(nullable NSString *)messageBody transaction:(DBWriteTransaction *)transaction { OWSAssertDebug(transaction); [self anyUpdateMessageWithTransaction:transaction block:^(TSMessage *message) { message.body = messageBody; }]; } #endif #pragma mark - View Once - (void)updateWithViewOnceCompleteAndRemoveRenderableContentWithTransaction:(DBWriteTransaction *)transaction { OWSAssertDebug(transaction); OWSAssertDebug(self.isViewOnceMessage); OWSAssertDebug(!self.isViewOnceComplete); [self removeAllRenderableContentWithTransaction:transaction messageUpdateBlock:^(TSMessage *message) { message.isViewOnceComplete = YES; }]; } #pragma mark - Remote Delete - (void)updateWithRemotelyDeletedAndRemoveRenderableContentWithTransaction:(DBWriteTransaction *)transaction { OWSAssertDebug(transaction); OWSAssertDebug(!self.wasRemotelyDeleted); [self removeAllReactionsWithTransaction:transaction]; [self removeAllRenderableContentWithTransaction:transaction messageUpdateBlock:^(TSMessage *message) { message.wasRemotelyDeleted = YES; }]; } #pragma mark - Remove Renderable Content - (void)removeAllRenderableContentWithTransaction:(DBWriteTransaction *)transaction messageUpdateBlock:(void (^)(TSMessage *message))messageUpdateBlock { // We call removeAllAttachmentsWithTransaction() before // anyUpdateWithTransaction, because anyUpdateWithTransaction's // block can be called twice, once on this instance and once // on the copy from the database. We only want to remove // attachments once. [self removeAllAttachmentsWithTx:transaction]; [self removeAllMentionsWithTransaction:transaction]; [MessageSendLogObjC deleteAllPayloadsForInteraction:self tx:transaction]; [self anyUpdateMessageWithTransaction:transaction block:^(TSMessage *message) { // Remove renderable content. message.body = nil; message.bodyRanges = nil; message.contactShare = nil; message.quotedMessage = nil; message.linkPreview = nil; message.messageSticker = nil; message.storyReactionEmoji = nil; messageUpdateBlock(message); }]; } @end NS_ASSUME_NONNULL_END