TM-SGNL-iOS/SignalServiceKit/MessageBackup/Archivers/ChatItem/ChatUpdateMessages/MessageBackupSimpleChatUpdateArchiver.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

620 lines
27 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
final class MessageBackupSimpleChatUpdateArchiver {
typealias Details = MessageBackup.InteractionArchiveDetails
typealias ArchiveChatUpdateMessageResult = MessageBackup.ArchiveInteractionResult<Details>
typealias RestoreChatUpdateMessageResult = MessageBackup.RestoreInteractionResult<Void>
private typealias ArchiveFrameError = MessageBackup.ArchiveFrameError<MessageBackup.InteractionUniqueId>
private typealias RestoreFrameError = MessageBackup.RestoreFrameError<MessageBackup.ChatItemId>
private let logger: MessageBackupLogger = .shared
private let interactionStore: MessageBackupInteractionStore
init(interactionStore: MessageBackupInteractionStore) {
self.interactionStore = interactionStore
}
// MARK: -
func archiveSimpleChatUpdate(
infoMessage: TSInfoMessage,
threadInfo: MessageBackup.ChatArchivingContext.CachedThreadInfo,
context: MessageBackup.ChatArchivingContext
) -> ArchiveChatUpdateMessageResult {
func messageFailure(
_ error: ArchiveFrameError.ErrorType,
line: UInt = #line
) -> ArchiveChatUpdateMessageResult {
return .messageFailure([.archiveFrameError(
error,
infoMessage.uniqueInteractionId,
line: line
)])
}
/// To whom we should attribute this update.
enum UpdateAuthor {
/// A recipient ID computed while computing the update type. Useful
/// if the update might appear in either a 1:1 or group thread.
case precomputedRecipientId(MessageBackup.RecipientId)
/// The contact whose 1:1 thread this update appears in. This
/// produces a failure if the update was in fact in a group.
case containingContactThread
/// The local user.
case localUser
}
let updateAuthor: UpdateAuthor
let updateType: BackupProto_SimpleChatUpdate.TypeEnum
switch infoMessage.messageType {
case
.userNotRegistered,
.typeUnsupportedMessage,
.typeGroupQuit,
.addToContactsOffer,
.addUserToProfileWhitelistOffer,
.addGroupToProfileWhitelistOffer,
.syncedThread:
// Skipped legacy types
fallthrough
case .recipientHidden:
// Specifically-skipped and specially-handled type
fallthrough
case
.typeGroupUpdate,
.typeDisappearingMessagesUpdate,
.profileUpdate,
.threadMerge,
.sessionSwitchover,
.learnedProfileName:
// Non-simple chat update types
return .completeFailure(.fatalArchiveError(
.developerError(OWSAssertionError("Unexpected info message type: \(infoMessage.messageType)"))
))
case .verificationStateChange:
guard let verificationStateChangeMessage = infoMessage as? OWSVerificationStateChangeMessage else {
return messageFailure(.verificationStateChangeNotExpectedSDSRecordType)
}
/// This type of info message historically put the user who had the
/// verification-state change in the `recipientAddress` field.
guard let recipientAddress = verificationStateChangeMessage.recipientAddress.asSingleServiceIdBackupAddress() else {
return messageFailure(.verificationStateUpdateInteractionMissingAuthor)
}
guard let recipientId = context.recipientContext[.contact(recipientAddress)] else {
return messageFailure(.referencedRecipientIdMissing(.contact(recipientAddress)))
}
updateAuthor = .precomputedRecipientId(recipientId)
switch verificationStateChangeMessage.verificationState {
case .default, .defaultAcknowledged, .noLongerVerified:
updateType = .identityDefault
case .verified:
updateType = .identityVerified
}
case .phoneNumberChange:
guard let changedNumberUserAci = infoMessage.phoneNumberChangeInfo()?.aci else {
return messageFailure(.phoneNumberChangeInteractionMissingAuthor)
}
let recipientAddress = MessageBackup.ContactAddress(aci: changedNumberUserAci)
guard let recipientId = context.recipientContext[.contact(recipientAddress)] else {
return messageFailure(.referencedRecipientIdMissing(.contact(recipientAddress)))
}
updateAuthor = .precomputedRecipientId(recipientId)
updateType = .changeNumber
case .paymentsActivationRequest:
switch infoMessage.paymentsActivationRequestAuthor(localIdentifiers: context.recipientContext.localIdentifiers) {
case nil:
return messageFailure(.paymentActivationRequestInteractionMissingAuthor)
case .localUser:
updateAuthor = .localUser
case .otherUser(let aci):
let authorAddress = MessageBackup.ContactAddress(aci: aci)
guard let authorRecipientId = context.recipientContext[.contact(authorAddress)] else {
return messageFailure(.referencedRecipientIdMissing(.contact(authorAddress)))
}
updateAuthor = .precomputedRecipientId(authorRecipientId)
}
updateType = .paymentActivationRequest
case .paymentsActivated:
switch infoMessage.paymentsActivatedAuthor(localIdentifiers: context.recipientContext.localIdentifiers) {
case nil:
return messageFailure(.paymentsActivatedInteractionMissingAuthor)
case .localUser:
updateAuthor = .localUser
case .otherUser(let aci):
let authorAddress = MessageBackup.ContactAddress(aci: aci)
guard let authorRecipientId = context.recipientContext[.contact(authorAddress)] else {
return messageFailure(.referencedRecipientIdMissing(.contact(authorAddress)))
}
updateAuthor = .precomputedRecipientId(authorRecipientId)
}
updateType = .paymentsActivated
case .unknownProtocolVersion:
guard let unknownProtocolVersionMessage = infoMessage as? OWSUnknownProtocolVersionMessage else {
return messageFailure(.unknownProtocolVersionNotExpectedSDSRecordType)
}
/// This type of info message historically put the user who sent the
/// unknown-protocol message in the `sender` field, or `nil` if it
/// was ourselves.
if let senderAddress = unknownProtocolVersionMessage.sender?.asSingleServiceIdBackupAddress() {
guard let recipientId = context.recipientContext[.contact(senderAddress)] else {
return messageFailure(.referencedRecipientIdMissing(.contact(senderAddress)))
}
updateAuthor = .precomputedRecipientId(recipientId)
} else {
// This would be because we got a sync transcript of a message
// with an unknown protocol version.
updateAuthor = .localUser
}
updateType = .unsupportedProtocolMessage
case .typeRemoteUserEndedSession:
// Only inserted for 1:1 threads.
updateAuthor = .containingContactThread
updateType = .endSession
case .typeLocalUserEndedSession:
updateAuthor = .localUser
updateType = .endSession
case .userJoinedSignal:
// Only inserted for 1:1 threads.
updateAuthor = .containingContactThread
updateType = .joinedSignal
case .reportedSpam:
// The reported-spam info message doesn't contain any info as to
// what message we reported spam. Regardless, we were the one to
// take this action, so we're the author.
updateAuthor = .localUser
updateType = .reportedSpam
case .blockedOtherUser, .blockedGroup:
// We blocked, so we're the author.
//
// These messages are the same in a Backup, and disambiguated at
// restore-time by the type of chat thread they're in.
updateAuthor = .localUser
updateType = .blocked
case .unblockedOtherUser, .unblockedGroup:
// We unblocked, so we're the author.
//
// These messages are the same in a Backup, and disambiguated at
// restore-time by the type of chat thread they're in.
updateAuthor = .localUser
updateType = .unblocked
case .acceptedMessageRequest:
// We accepted, so we're the author.
updateAuthor = .localUser
updateType = .messageRequestAccepted
}
let updateAuthorRecipientId: MessageBackup.RecipientId
switch updateAuthor {
case .precomputedRecipientId(let recipientId):
updateAuthorRecipientId = recipientId
case .containingContactThread:
switch threadInfo {
case .groupThread:
return messageFailure(.simpleChatUpdateMessageNotInContactThread)
case .contactThread(let authorAddress):
guard let authorAddress else {
return messageFailure(.simpleChatUpdateMessageNotInContactThread)
}
guard let authorRecipientId = context.recipientContext[.contact(authorAddress)] else {
return messageFailure(.referencedRecipientIdMissing(.contact(authorAddress)))
}
updateAuthorRecipientId = authorRecipientId
}
case .localUser:
updateAuthorRecipientId = context.recipientContext.localRecipientId
}
var simpleChatUpdate = BackupProto_SimpleChatUpdate()
simpleChatUpdate.type = updateType
var chatUpdateMessage = BackupProto_ChatUpdateMessage()
chatUpdateMessage.update = .simpleUpdate(simpleChatUpdate)
let interactionArchiveDetails = Details(
author: updateAuthorRecipientId,
directionalDetails: .directionless(BackupProto_ChatItem.DirectionlessMessageDetails()),
dateCreated: infoMessage.timestamp,
expireStartDate: nil,
expiresInMs: nil,
isSealedSender: false,
chatItemType: .updateMessage(chatUpdateMessage),
isSmsPreviouslyRestoredFromBackup: false
)
return .success(interactionArchiveDetails)
}
func archiveSimpleChatUpdate(
errorMessage: TSErrorMessage,
context: MessageBackup.ChatArchivingContext
) -> ArchiveChatUpdateMessageResult {
func messageFailure(
_ error: ArchiveFrameError.ErrorType,
line: UInt = #line
) -> ArchiveChatUpdateMessageResult {
return .messageFailure([.archiveFrameError(
error,
errorMessage.uniqueInteractionId,
line: line
)])
}
/// To whom we should attribute this update.
enum UpdateAuthor {
case localUser
case remoteUser(MessageBackup.ContactAddress)
}
let updateAuthor: UpdateAuthor
let updateType: BackupProto_SimpleChatUpdate.TypeEnum
switch errorMessage.errorType {
case .noSession:
return .skippableChatUpdate(.legacyErrorMessage(.noSession))
case .wrongTrustedIdentityKey:
return .skippableChatUpdate(.legacyErrorMessage(.wrongTrustedIdentityKey))
case .invalidKeyException:
return .skippableChatUpdate(.legacyErrorMessage(.invalidKeyException))
case .missingKeyId:
return .skippableChatUpdate(.legacyErrorMessage(.missingKeyId))
case .invalidMessage:
return .skippableChatUpdate(.legacyErrorMessage(.invalidMessage))
case .duplicateMessage:
return .skippableChatUpdate(.legacyErrorMessage(.duplicateMessage))
case .invalidVersion:
return .skippableChatUpdate(.legacyErrorMessage(.invalidVersion))
case .unknownContactBlockOffer:
return .skippableChatUpdate(.legacyErrorMessage(.unknownContactBlockOffer))
case .groupCreationFailed:
return .skippableChatUpdate(.legacyErrorMessage(.groupCreationFailed))
case .nonBlockingIdentityChange:
/// This type of error message historically put the person with the
/// identity-key change on the `recipientAddress` property.
guard let recipientAddress = errorMessage.recipientAddress?.asSingleServiceIdBackupAddress() else {
return messageFailure(.identityKeyChangeInteractionMissingAuthor)
}
updateType = .identityUpdate
updateAuthor = .remoteUser(recipientAddress)
case .sessionRefresh:
/// We always generate these ourselves.
updateType = .chatSessionRefresh
updateAuthor = .localUser
case .decryptionFailure:
/// This type of error message historically put the person who sent
/// the failed-to-decrypt message in the `sender` property.
guard let recipientAddress = errorMessage.sender?.asSingleServiceIdBackupAddress() else {
return messageFailure(.decryptionErrorInteractionMissingAuthor)
}
updateType = .badDecrypt
updateAuthor = .remoteUser(recipientAddress)
}
let updateAuthorRecipientId: MessageBackup.RecipientId
switch updateAuthor {
case .localUser:
updateAuthorRecipientId = context.recipientContext.localRecipientId
case .remoteUser(let contactAddress):
guard let _updateAuthorRecipientId = context.recipientContext[.contact(contactAddress)] else {
return messageFailure(.referencedRecipientIdMissing(.contact(contactAddress)))
}
updateAuthorRecipientId = _updateAuthorRecipientId
}
var simpleChatUpdate = BackupProto_SimpleChatUpdate()
simpleChatUpdate.type = updateType
var chatUpdateMessage = BackupProto_ChatUpdateMessage()
chatUpdateMessage.update = .simpleUpdate(simpleChatUpdate)
let interactionArchiveDetails = Details(
author: updateAuthorRecipientId,
directionalDetails: .directionless(BackupProto_ChatItem.DirectionlessMessageDetails()),
dateCreated: errorMessage.timestamp,
expireStartDate: nil,
expiresInMs: nil,
isSealedSender: false,
chatItemType: .updateMessage(chatUpdateMessage),
isSmsPreviouslyRestoredFromBackup: false
)
return .success(interactionArchiveDetails)
}
// MARK: -
func restoreSimpleChatUpdate(
_ simpleChatUpdate: BackupProto_SimpleChatUpdate,
chatItem: BackupProto_ChatItem,
chatThread: MessageBackup.ChatThread,
context: MessageBackup.ChatItemRestoringContext
) -> RestoreChatUpdateMessageResult {
func invalidProtoData(
_ error: RestoreFrameError.ErrorType.InvalidProtoDataError,
line: UInt = #line
) -> RestoreChatUpdateMessageResult {
return .messageFailure([.restoreFrameError(
.invalidProtoData(error),
chatItem.id,
line: line
)])
}
enum SimpleChatUpdateInteraction {
case simpleInfoMessage(TSInfoMessageType)
case prebuiltInfoMessage(TSInfoMessage)
case errorMessage(TSErrorMessage)
}
let thread: TSThread = chatThread.tsThread
let simpleChatUpdateInteraction: SimpleChatUpdateInteraction
switch simpleChatUpdate.type {
case .unknown, .UNRECOGNIZED:
return invalidProtoData(.unrecognizedSimpleChatUpdate)
case .joinedSignal:
simpleChatUpdateInteraction = .simpleInfoMessage(.userJoinedSignal)
case .identityUpdate:
guard let verificationRecipient = context.recipientContext[chatItem.authorRecipientId] else {
return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
}
guard case .contact(let contactAddress) = verificationRecipient else {
return invalidProtoData(.verificationStateChangeNotFromContact)
}
simpleChatUpdateInteraction = .errorMessage(.nonblockingIdentityChange(
thread: thread,
timestamp: chatItem.dateSent,
// We'll use the author of this chat item as the user whose
// identity key changed.
address: contactAddress.asInteropAddress(),
// We'll fudge and conservatively say that the identity was not
// previously verified since we don't have it tracked in the
// backup and it only affects the action shown for the message.
wasIdentityVerified: false
))
case .identityVerified, .identityDefault:
guard let verificationRecipient = context.recipientContext[chatItem.authorRecipientId] else {
return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
}
guard case .contact(let contactAddress) = verificationRecipient else {
return invalidProtoData(.verificationStateChangeNotFromContact)
}
let verificationState: OWSVerificationState = switch simpleChatUpdate.type {
case .identityVerified: .verified
case .identityDefault: .default
default: owsFail("Impossible: checked above.")
}
simpleChatUpdateInteraction = .prebuiltInfoMessage(OWSVerificationStateChangeMessage(
thread: thread,
timestamp: chatItem.dateSent,
// We'll use the author of this chat item as the user whose
// verification state changed.
recipientAddress: contactAddress.asInteropAddress(),
verificationState: verificationState,
// We don't know which device this update originated on, so
// we'll pretend it was the local. This only affects the way the
// message is displayed.
isLocalChange: true
))
case .changeNumber:
guard let verificationRecipient = context.recipientContext[chatItem.authorRecipientId] else {
return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
}
guard
case .contact(let contactAddress) = verificationRecipient,
let aci = contactAddress.aci
else {
return invalidProtoData(.phoneNumberChangeNotFromContact)
}
simpleChatUpdateInteraction = .prebuiltInfoMessage(.makeForPhoneNumberChange(
thread: thread,
timestamp: chatItem.dateSent,
aci: aci,
oldNumber: nil,
newNumber: nil
))
case .releaseChannelDonationRequest:
// TODO: [Backups] Add support (and a test case!) for this once we've implemented the Release Notes channel.
logger.warn("Encountered not-yet-supported release-channel-donation-request update")
return .success(())
case .endSession:
guard let senderRecipient = context.recipientContext[chatItem.authorRecipientId] else {
return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
}
let infoMessageType: TSInfoMessageType
switch senderRecipient {
case .localAddress:
infoMessageType = .typeLocalUserEndedSession
case .contact:
infoMessageType = .typeRemoteUserEndedSession
case .releaseNotesChannel, .group, .distributionList, .callLink:
return invalidProtoData(.endSessionNotFromContact)
}
simpleChatUpdateInteraction = .simpleInfoMessage(infoMessageType)
case .chatSessionRefresh:
simpleChatUpdateInteraction = .errorMessage(.sessionRefresh(
thread: thread,
timestamp: chatItem.dateSent
))
case .badDecrypt:
guard let senderRecipient = context.recipientContext[chatItem.authorRecipientId] else {
return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
}
let contactAddress: MessageBackup.InteropAddress
switch senderRecipient {
case .localAddress:
contactAddress = context.recipientContext.localIdentifiers.aciAddress
case .contact(let _contactAddress):
contactAddress = _contactAddress.asInteropAddress()
case .releaseNotesChannel, .group, .distributionList, .callLink:
return invalidProtoData(.decryptionErrorNotFromContact)
}
simpleChatUpdateInteraction = .errorMessage(.failedDecryption(
thread: thread,
timestamp: chatItem.dateSent,
sender: contactAddress
))
case .paymentsActivated:
let senderAci: Aci
switch context.recipientContext[chatItem.authorRecipientId] {
case nil:
return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
case .localAddress:
senderAci = context.recipientContext.localIdentifiers.aci
case .contact(let contactAddress):
guard let aci = contactAddress.aci else { fallthrough }
senderAci = aci
case .releaseNotesChannel, .group, .distributionList, .callLink:
return invalidProtoData(.paymentsActivatedNotFromAci)
}
simpleChatUpdateInteraction = .prebuiltInfoMessage(.paymentsActivatedMessage(
thread: thread,
timestamp: chatItem.dateSent,
senderAci: senderAci
))
case .paymentActivationRequest:
let senderAci: Aci
switch context.recipientContext[chatItem.authorRecipientId] {
case nil:
return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
case .localAddress:
senderAci = context.recipientContext.localIdentifiers.aci
case .contact(let contactAddress):
guard let aci = contactAddress.aci else { fallthrough }
senderAci = aci
case .releaseNotesChannel, .group, .distributionList, .callLink:
return invalidProtoData(.paymentsActivatedNotFromAci)
}
simpleChatUpdateInteraction = .prebuiltInfoMessage(.paymentsActivationRequestMessage(
thread: thread,
timestamp: chatItem.dateSent,
senderAci: senderAci
))
case .unsupportedProtocolMessage:
let senderAddress: SignalServiceAddress?
switch context.recipientContext[chatItem.authorRecipientId] {
case nil:
return invalidProtoData(.recipientIdNotFound(chatItem.authorRecipientId))
case .localAddress:
senderAddress = nil
case .contact(let contactAddress):
senderAddress = contactAddress.asInteropAddress()
case .releaseNotesChannel, .group, .distributionList, .callLink:
return invalidProtoData(.unsupportedProtocolVersionNotFromContact)
}
simpleChatUpdateInteraction = .prebuiltInfoMessage(OWSUnknownProtocolVersionMessage(
thread: thread,
timestamp: chatItem.dateSent,
sender: senderAddress,
// This isn't quite right, but we don't have the required
// protocol version for this message in the backup. Setting it
// to the highest we can insert into the DB ensures this message
// will always show "unknown protocol version", but that's fine.
protocolVersion: UInt(Int64.max)
))
case .reportedSpam:
simpleChatUpdateInteraction = .simpleInfoMessage(.reportedSpam)
case .blocked:
switch chatThread.threadType {
case .contact:
simpleChatUpdateInteraction = .simpleInfoMessage(.blockedOtherUser)
case .groupV2:
simpleChatUpdateInteraction = .simpleInfoMessage(.blockedGroup)
}
case .unblocked:
switch chatThread.threadType {
case .contact:
simpleChatUpdateInteraction = .simpleInfoMessage(.unblockedOtherUser)
case .groupV2:
simpleChatUpdateInteraction = .simpleInfoMessage(.unblockedGroup)
}
case .messageRequestAccepted:
simpleChatUpdateInteraction = .simpleInfoMessage(.acceptedMessageRequest)
}
guard let directionalDetails = chatItem.directionalDetails else {
return .messageFailure([.restoreFrameError(
.invalidProtoData(.chatItemMissingDirectionalDetails),
chatItem.id
)])
}
switch simpleChatUpdateInteraction {
case .simpleInfoMessage(let infoMessageType):
let infoMessage = TSInfoMessage(
thread: thread,
messageType: infoMessageType,
timestamp: chatItem.dateSent
)
do {
try interactionStore.insert(
infoMessage,
in: chatThread,
chatId: chatItem.typedChatId,
directionalDetails: directionalDetails,
context: context
)
} catch let error {
return .messageFailure([.restoreFrameError(.databaseInsertionFailed(error), chatItem.id)])
}
case .prebuiltInfoMessage(let infoMessage):
do {
try interactionStore.insert(
infoMessage,
in: chatThread,
chatId: chatItem.typedChatId,
directionalDetails: directionalDetails,
context: context
)
} catch let error {
return .messageFailure([.restoreFrameError(.databaseInsertionFailed(error), chatItem.id)])
}
case .errorMessage(let errorMessage):
do {
try interactionStore.insert(
errorMessage,
in: chatThread,
chatId: chatItem.typedChatId,
directionalDetails: directionalDetails,
context: context
)
} catch let error {
return .messageFailure([.restoreFrameError(.databaseInsertionFailed(error), chatItem.id)])
}
}
return .success(())
}
}