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

501 lines
19 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import LibSignalClient
public protocol MessageBackupChatArchiver: MessageBackupProtoArchiver {
typealias ChatId = MessageBackup.ChatId
typealias ArchiveMultiFrameResult = MessageBackup.ArchiveMultiFrameResult<MessageBackup.ThreadUniqueId>
typealias RestoreFrameResult = MessageBackup.RestoreFrameResult<ChatId>
/// Archive all ``TSThread``s (they map to ``BackupProto_Chat``).
///
/// - Returns: ``ArchiveMultiFrameResult.success`` if all frames were written without error, or either
/// partial or complete failure otherwise.
/// How to handle ``ArchiveMultiFrameResult.partialSuccess`` is up to the caller,
/// but typically an error will be shown to the user, but the backup will be allowed to proceed.
/// ``ArchiveMultiFrameResult.completeFailure``, on the other hand, will stop the entire backup,
/// and should be used if some critical or category-wide failure occurs.
func archiveChats(
stream: MessageBackupProtoOutputStream,
context: MessageBackup.ChatArchivingContext
) throws(CancellationError) -> ArchiveMultiFrameResult
/// Restore a single ``BackupProto_Chat`` frame.
///
/// - Returns: ``RestoreFrameResult.success`` if all frames were read without error.
/// How to handle ``RestoreFrameResult.failure`` is up to the caller,
/// but typically an error will be shown to the user, but the restore will be allowed to proceed.
func restore(
_ chat: BackupProto_Chat,
context: MessageBackup.ChatRestoringContext
) -> RestoreFrameResult
}
public class MessageBackupChatArchiverImpl: MessageBackupChatArchiver {
private typealias ArchiveFrameError = MessageBackup.ArchiveFrameError<MessageBackup.ThreadUniqueId>
private let chatStyleArchiver: MessageBackupChatStyleArchiver
private let contactRecipientArchiver: MessageBackupContactRecipientArchiver
private let dmConfigurationStore: DisappearingMessagesConfigurationStore
private let pinnedThreadStore: PinnedThreadStoreWrite
private let threadStore: MessageBackupThreadStore
public init(
chatStyleArchiver: MessageBackupChatStyleArchiver,
contactRecipientArchiver: MessageBackupContactRecipientArchiver,
dmConfigurationStore: DisappearingMessagesConfigurationStore,
pinnedThreadStore: PinnedThreadStoreWrite,
threadStore: MessageBackupThreadStore
) {
self.chatStyleArchiver = chatStyleArchiver
self.contactRecipientArchiver = contactRecipientArchiver
self.dmConfigurationStore = dmConfigurationStore
self.pinnedThreadStore = pinnedThreadStore
self.threadStore = threadStore
}
// MARK: - Archiving
public func archiveChats(
stream: MessageBackupProtoOutputStream,
context: MessageBackup.ChatArchivingContext
) throws(CancellationError) -> ArchiveMultiFrameResult {
var completeFailureError: MessageBackup.FatalArchivingError?
var partialErrors = [ArchiveFrameError]()
func archiveThread(_ thread: TSThread) -> Bool {
var stop = false
autoreleasepool {
let result: ArchiveMultiFrameResult
if let thread = thread as? TSContactThread {
// Check address directly; isNoteToSelf uses global state.
if thread.contactAddress.isEqualToAddress(context.recipientContext.localIdentifiers.aciAddress) {
result = self.archiveNoteToSelfThread(
thread,
stream: stream,
context: context
)
} else {
result = self.archiveContactThread(
thread,
stream: stream,
context: context
)
}
} else if let thread = thread as? TSGroupThread, thread.isGroupV2Thread {
result = self.archiveGroupV2Thread(
thread,
stream: stream,
context: context
)
} else if let thread = thread as? TSGroupThread, thread.isGroupV1Thread {
// Remember which threads were gv1 so we can silently drop their messages.
context.gv1ThreadIds.insert(thread.uniqueThreadIdentifier)
// Skip gv1 threads; count as success.
result = .success
} else {
result = .completeFailure(.fatalArchiveError(.unrecognizedThreadType))
}
switch result {
case .success:
break
case .completeFailure(let error):
completeFailureError = error
stop = true
return
case .partialSuccess(let errors):
partialErrors.append(contentsOf: errors)
}
}
return !stop
}
do {
try threadStore.enumerateNonStoryThreads(context: context, block: { thread in
try Task.checkCancellation()
return archiveThread(thread)
})
} catch let error as CancellationError {
throw error
} catch let error {
return .completeFailure(.fatalArchiveError(.threadIteratorError(error)))
}
if let completeFailureError {
return .completeFailure(completeFailureError)
} else if partialErrors.isEmpty {
return .success
} else {
return .partialSuccess(partialErrors)
}
}
private func archiveNoteToSelfThread(
_ thread: TSContactThread,
stream: MessageBackupProtoOutputStream,
context: MessageBackup.ChatArchivingContext
) -> ArchiveMultiFrameResult {
guard let threadRowId = thread.sqliteRowId else {
return .completeFailure(.fatalArchiveError(
.fetchedThreadMissingRowId
))
}
return archiveThread(
MessageBackup.ChatThread(threadType: .contact(thread), threadRowId: threadRowId),
recipientId: context.recipientContext.localRecipientId,
stream: stream,
context: context
)
}
private func archiveContactThread(
_ thread: TSContactThread,
stream: MessageBackupProtoOutputStream,
context: MessageBackup.ChatArchivingContext
) -> ArchiveMultiFrameResult {
let contactServiceId: ServiceId? = thread.contactUUID.flatMap { try? ServiceId.parseFrom(serviceIdString: $0) }
guard
let contactAddress = MessageBackup.ContactAddress(
serviceId: contactServiceId,
e164: E164(thread.contactPhoneNumber)
)
else {
return .partialSuccess([.archiveFrameError(
.contactThreadMissingAddress,
thread.uniqueThreadIdentifier
)])
}
let recipientAddress = contactAddress.asArchivingAddress()
let recipientId: MessageBackup.RecipientId
if let _recipientId = context.recipientContext[recipientAddress] {
recipientId = _recipientId
} else {
// Try and create a recipient for this orphaned TSContactThread
// that has no corresponding SignalRecipient.
switch contactRecipientArchiver.archiveContactRecipientForOrphanedContactThread(
thread,
address: contactAddress,
stream: stream,
context: context
) {
case .success(let _recipientId):
recipientId = _recipientId
case .failure(let error):
return .partialSuccess([error])
}
}
guard let threadRowId = thread.sqliteRowId else {
return .completeFailure(.fatalArchiveError(
.fetchedThreadMissingRowId
))
}
return archiveThread(
MessageBackup.ChatThread(threadType: .contact(thread), threadRowId: threadRowId),
recipientId: recipientId,
stream: stream,
context: context
)
}
private func archiveGroupV2Thread(
_ thread: TSGroupThread,
stream: MessageBackupProtoOutputStream,
context: MessageBackup.ChatArchivingContext
) -> ArchiveMultiFrameResult {
let recipientAddress = MessageBackup.RecipientArchivingContext.Address.group(
MessageBackup.GroupId(groupModel: thread.groupModel)
)
guard let recipientId = context.recipientContext[recipientAddress] else {
return .partialSuccess([.archiveFrameError(
.referencedRecipientIdMissing(recipientAddress),
thread.uniqueThreadIdentifier
)])
}
guard let threadRowId = thread.sqliteRowId else {
return .completeFailure(.fatalArchiveError(
.fetchedThreadMissingRowId
))
}
return archiveThread(
MessageBackup.ChatThread(threadType: .groupV2(thread), threadRowId: threadRowId),
recipientId: recipientId,
stream: stream,
context: context
)
}
private func archiveThread(
_ thread: MessageBackup.ChatThread,
recipientId: MessageBackup.RecipientId,
stream: MessageBackupProtoOutputStream,
context: MessageBackup.ChatArchivingContext
) -> ArchiveMultiFrameResult {
var partialErrors = [ArchiveFrameError]()
let threadAssociatedData = threadStore.fetchOrDefaultAssociatedData(for: thread.tsThread, context: context)
let thisThreadPinnedOrder: UInt32?
let pinnedThreadIds = pinnedThreadStore.pinnedThreadIds(tx: context.tx)
if let pinnedThreadIndex: Int = pinnedThreadIds.firstIndex(of: thread.tsThread.uniqueId) {
// Add one so we don't start at 0.
thisThreadPinnedOrder = UInt32(clamping: pinnedThreadIndex + 1)
} else {
thisThreadPinnedOrder = nil
}
let versionedExpireTimerToken = dmConfigurationStore.fetchOrBuildDefault(
for: .thread(thread.tsThread),
tx: context.tx
).asVersionedToken
let dontNotifyForMentionsIfMuted: Bool
switch thread.tsThread.mentionNotificationMode {
case .default, .always:
dontNotifyForMentionsIfMuted = false
case .never:
dontNotifyForMentionsIfMuted = true
}
var chat = BackupProto_Chat()
chat.id = context.assignChatId(to: thread.tsThread).value
chat.recipientID = recipientId.value
chat.archived = threadAssociatedData.isArchived
if let thisThreadPinnedOrder {
chat.pinnedOrder = thisThreadPinnedOrder
}
if versionedExpireTimerToken.isEnabled {
chat.expirationTimerMs = UInt64(versionedExpireTimerToken.durationSeconds) * 1000
}
chat.expireTimerVersion = versionedExpireTimerToken.version
if threadAssociatedData.isMuted {
chat.muteUntilMs = threadAssociatedData.mutedUntilTimestamp
}
chat.markedUnread = threadAssociatedData.isMarkedUnread
chat.dontNotifyForMentionsIfMuted = dontNotifyForMentionsIfMuted
let chatStyleResult = chatStyleArchiver.archiveChatStyle(
thread: thread,
context: context.customChatColorContext
)
switch chatStyleResult {
case .success(let chatStyleProto):
if let chatStyleProto {
chat.style = chatStyleProto
}
case .failure(let error):
partialErrors.append(error)
}
let error = Self.writeFrameToStream(
stream,
objectId: thread.tsThread.uniqueThreadIdentifier
) {
var frame = BackupProto_Frame()
frame.item = .chat(chat)
return frame
}
if let error {
partialErrors.append(error)
}
if partialErrors.isEmpty {
return .success
} else {
return .partialSuccess(partialErrors)
}
}
// MARK: - Restoring
public func restore(
_ chat: BackupProto_Chat,
context: MessageBackup.ChatRestoringContext
) -> RestoreFrameResult {
var partialErrors = [MessageBackup.RestoreFrameError<ChatId>]()
let chatThread: MessageBackup.ChatThread
switch context.recipientContext[chat.typedRecipientId] {
case .none:
return .failure([.restoreFrameError(
.invalidProtoData(.recipientIdNotFound(chat.typedRecipientId)),
chat.chatId
)])
case .localAddress:
let noteToSelfThread: TSContactThread
do {
noteToSelfThread = try threadStore.createNoteToSelfThread(
context: context
)
} catch let error {
return .failure([.restoreFrameError(.databaseInsertionFailed(error), chat.chatId)])
}
guard let noteToSelfRowId = noteToSelfThread.sqliteRowId else {
return .failure([.restoreFrameError(
.databaseModelMissingRowId(modelClass: TSContactThread.self),
chat.chatId
)])
}
chatThread = MessageBackup.ChatThread(
threadType: .contact(noteToSelfThread),
threadRowId: noteToSelfRowId
)
case .releaseNotesChannel:
// TODO: [Backups] Implement restoring the Release Notes channel chat.
return .success
case .group(let groupId):
// We don't create the group thread here; that happened when parsing the Group Recipient.
// Instead, just set metadata.
guard
let groupThread = context.recipientContext[groupId],
groupThread.isGroupV2Thread
else {
return .failure([.restoreFrameError(
.referencedGroupThreadNotFound(groupId),
chat.chatId
)])
}
guard let groupThreadRowId = groupThread.sqliteRowId else {
return .failure([.restoreFrameError(
.databaseModelMissingRowId(modelClass: TSGroupThread.self),
chat.chatId
)])
}
chatThread = MessageBackup.ChatThread(
threadType: .groupV2(groupThread),
threadRowId: groupThreadRowId
)
case .contact(let address):
let contactThread: TSContactThread
do {
contactThread = try threadStore.createContactThread(with: address, context: context)
} catch let error {
return .failure([.restoreFrameError(.databaseInsertionFailed(error), chat.chatId)])
}
guard let contactThreadRowId = contactThread.sqliteRowId else {
return .failure([.restoreFrameError(
.databaseModelMissingRowId(modelClass: TSContactThread.self),
chat.chatId
)])
}
chatThread = MessageBackup.ChatThread(
threadType: .contact(contactThread),
threadRowId: contactThreadRowId
)
case .distributionList:
return .failure([.restoreFrameError(
.invalidProtoData(.distributionListUsedAsChatRecipient),
chat.chatId
)])
case .callLink:
return .failure([.restoreFrameError(
.invalidProtoData(.callLinkUsedAsChatRecipient),
chat.chatId
)])
}
context.mapChatId(chat.chatId, to: chatThread, recipientId: chat.typedRecipientId)
var mutedUntilTimestamp: UInt64?
if chat.hasMuteUntilMs {
mutedUntilTimestamp = chat.muteUntilMs
}
do {
try threadStore.createAssociatedData(
for: chatThread.tsThread,
isArchived: chat.archived,
isMarkedUnread: chat.markedUnread,
mutedUntilTimestamp: mutedUntilTimestamp,
context: context
)
} catch let error {
return .failure(partialErrors + [.restoreFrameError(.databaseInsertionFailed(error), chat.chatId)])
}
if chat.hasPinnedOrder {
let newPinnedThreadIds = context.pinnedThreadOrder(
newPinnedThreadId: MessageBackup.ThreadUniqueId(chatThread: chatThread),
newPinnedThreadChatId: chat.chatId,
newPinnedThreadIndex: chat.pinnedOrder
)
pinnedThreadStore.updatePinnedThreadIds(newPinnedThreadIds.map(\.value), tx: context.tx)
}
let expiresInSeconds: UInt32
if chat.hasExpirationTimerMs {
guard let _expiresInSeconds: UInt32 = .msToSecs(chat.expirationTimerMs) else {
return .failure([.restoreFrameError(
.invalidProtoData(.expirationTimerOverflowedLocalType),
chat.chatId
)])
}
expiresInSeconds = _expiresInSeconds
} else {
expiresInSeconds = 0
}
dmConfigurationStore.set(
token: VersionedDisappearingMessageToken(
isEnabled: expiresInSeconds > 0,
durationSeconds: expiresInSeconds,
version: chat.expireTimerVersion
),
for: .thread(chatThread.tsThread),
tx: context.tx
)
do {
try threadStore.update(
thread: chatThread,
dontNotifyForMentionsIfMuted: chat.dontNotifyForMentionsIfMuted,
context: context
)
} catch let error {
return .failure([.restoreFrameError(.databaseInsertionFailed(error), chat.chatId)])
}
let chatStyleToRestore: BackupProto_ChatStyle?
if chat.hasStyle {
chatStyleToRestore = chat.style
} else {
chatStyleToRestore = nil
}
let chatStyleResult = chatStyleArchiver.restoreChatStyle(
chatStyleToRestore,
thread: chatThread,
chatId: chat.chatId,
context: context.customChatColorContext
)
switch chatStyleResult {
case .success:
break
case .partialRestore(let errors):
partialErrors.append(contentsOf: errors)
case .failure(let errors):
partialErrors.append(contentsOf: errors)
return .failure(partialErrors)
}
if partialErrors.isEmpty {
return .success
} else {
return .partialRestore(partialErrors)
}
}
}