Signal-iOS/SignalServiceKit/CloudBackup/CloudBackupManagerImpl.swift

936 lines
38 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import LibSignalClient
public class NotImplementedError: Error {}
public class CloudBackupManagerImpl: CloudBackupManager {
private let blockingManager: CloudBackup.Shims.BlockingManager
private let dateProvider: DateProvider
private let db: DB
private let dmConfigurationStore: DisappearingMessagesConfigurationStore
private let groupsV2: GroupsV2
private let profileManager: CloudBackup.Shims.ProfileManager
private let recipientHidingManager: RecipientHidingManager
private let signalRecipientFetcher: CloudBackup.Shims.SignalRecipientFetcher
private let storyFinder: CloudBackup.Shims.StoryFinder
private let streamProvider: CloudBackupOutputStreamProvider
private let tsAccountManager: TSAccountManager
private let tsInteractionFetcher: CloudBackup.Shims.TSInteractionFetcher
private let tsThreadFetcher: CloudBackup.Shims.TSThreadFetcher
public init(
blockingManager: CloudBackup.Shims.BlockingManager,
dateProvider: @escaping DateProvider,
db: DB,
dmConfigurationStore: DisappearingMessagesConfigurationStore,
groupsV2: GroupsV2,
profileManager: CloudBackup.Shims.ProfileManager,
recipientHidingManager: RecipientHidingManager,
signalRecipientFetcher: CloudBackup.Shims.SignalRecipientFetcher,
storyFinder: CloudBackup.Shims.StoryFinder,
streamProvider: CloudBackupOutputStreamProvider,
tsAccountManager: TSAccountManager,
tsInteractionFetcher: CloudBackup.Shims.TSInteractionFetcher,
tsThreadFetcher: CloudBackup.Shims.TSThreadFetcher
) {
self.blockingManager = blockingManager
self.dateProvider = dateProvider
self.db = db
self.dmConfigurationStore = dmConfigurationStore
self.groupsV2 = groupsV2
self.profileManager = profileManager
self.recipientHidingManager = recipientHidingManager
self.signalRecipientFetcher = signalRecipientFetcher
self.storyFinder = storyFinder
self.streamProvider = streamProvider
self.tsAccountManager = tsAccountManager
self.tsInteractionFetcher = tsInteractionFetcher
self.tsThreadFetcher = tsThreadFetcher
}
public func createBackup() async throws -> URL {
guard FeatureFlags.cloudBackupFileAlpha else {
owsFailDebug("Should not be able to use backups!")
throw NotImplementedError()
}
return try await db.awaitableWrite { tx in
// The mother of all write transactions. Eventually we want to use
// a read tx, and use explicit locking to prevent other things from
// happening in the meantime (e.g. message processing) but for now
// hold the single write lock and call it a day.
return try self._createBackup(tx: tx)
}
}
public func importBackup(fileUrl: URL) async throws {
guard FeatureFlags.cloudBackupFileAlpha else {
owsFailDebug("Should not be able to use backups!")
throw NotImplementedError()
}
try await db.awaitableWrite { tx in
// This has to open one big write transaction; the alternative is
// to chunk them into separate writes. Nothing else should be happening
// in the app anyway.
do {
try self._importBackup(fileUrl, tx: tx)
} catch let error {
owsFailDebug("Failed! \(error)")
throw error
}
}
}
private func _createBackup(tx: DBWriteTransaction) throws -> URL {
let stream: CloudBackupOutputStream
switch streamProvider.openOutputFileStream() {
case .success(let streamResult):
stream = streamResult
case .failure(let error):
throw error
}
try writeHeader(stream: stream, tx: tx)
let (nextRecipientProtoId, addressMap) = try writeRecipients(stream: stream, tx: tx)
let groupIdMap = try writeGroups(nextRecipientProtoId: nextRecipientProtoId, stream: stream, tx: tx)
let chatIdMap = try writeThreads(addressMap: addressMap, groupIdMap: groupIdMap, stream: stream, tx: tx)
try writeMessages(chatMap: chatIdMap, addressMap: addressMap, stream: stream, tx: tx)
return stream.closeFileStream()
}
private func writeHeader(stream: CloudBackupOutputStream, tx: DBWriteTransaction) throws {
let backupInfo = try BackupProtoBackupInfo.builder(
version: 1,
backupTime: dateProvider().ows_millisecondsSince1970
).build()
try stream.writeHeader(backupInfo)
}
private func writeRecipients(
stream: CloudBackupOutputStream,
tx: DBReadTransaction
) throws -> (UInt64, [SignalServiceAddress: UInt64]) {
var currentRecipientProtoId: UInt64 = 1
var addressMap = [SignalServiceAddress: UInt64]()
let whitelistedAddresses = Set(profileManager.allWhitelistedRegisteredAddresses(tx: tx))
let blockedAddresses = blockingManager.blockedAddresses(tx: tx)
var firstError: Error?
guard let localAddress = tsAccountManager.localIdentifiers(tx: tx)?.aciAddress else {
throw OWSAssertionError("No local address!")
}
// Write the local recipient first.
let selfBuilder = BackupProtoSelfRecipient.builder()
let selfProto = try selfBuilder.build()
let selfRecipientBuilder = BackupProtoRecipient.builder(id: currentRecipientProtoId)
addressMap[localAddress] = currentRecipientProtoId
currentRecipientProtoId += 1
selfRecipientBuilder.setSelfRecipient(selfProto)
let selfRecipientProto = try selfRecipientBuilder.build()
let selfFrameBuilder = BackupProtoFrame.builder()
selfFrameBuilder.setRecipient(selfRecipientProto)
let selfFrame = try selfFrameBuilder.build()
try stream.writeFrame(selfFrame)
signalRecipientFetcher.enumerateAll(tx: tx) { recipient in
do {
let recipientAddress = recipient.address
let recipientBuilder = BackupProtoRecipient.builder(
id: currentRecipientProtoId
)
addressMap[recipient.address] = currentRecipientProtoId
currentRecipientProtoId += 1
var unregisteredAtTimestamp: UInt64 = 0
if !recipient.isRegistered {
unregisteredAtTimestamp = (
recipient.unregisteredAtTimestamp ?? SignalRecipient.Constants.distantPastUnregisteredTimestamp
)
}
// TODO: instead of doing per-recipient fetches, we should bulk load
// some of these fetched fields into memory to avoid db round trips.
let contactBuilder = BackupProtoContact.builder(
blocked: blockedAddresses.contains(recipientAddress),
hidden: self.recipientHidingManager.isHiddenRecipient(recipient, tx: tx),
unregisteredTimestamp: unregisteredAtTimestamp,
profileSharing: whitelistedAddresses.contains(recipient.address),
hideStory: recipient.aci.map { self.storyFinder.isStoryHidden(forAci: $0, tx: tx) ?? false } ?? false
)
contactBuilder.setRegistered(recipient.isRegistered ? .registered : .notRegistered)
recipient.aci.map(\.rawUUID.data).map(contactBuilder.setAci)
recipient.pni.map(\.rawUUID.data).map(contactBuilder.setPni)
recipient.address.e164.map(\.uint64Value).map(contactBuilder.setE164)
// TODO: username?
let profile = self.profileManager.getUserProfile(for: recipientAddress, tx: tx)
profile?.profileKey.map(\.keyData).map(contactBuilder.setProfileKey(_:))
profile?.unfilteredGivenName.map(contactBuilder.setProfileGivenName(_:))
profile?.unfilteredFamilyName.map(contactBuilder.setProfileFamilyName(_:))
// TODO: joined name?
let contact = try contactBuilder.build()
recipientBuilder.setContact(contact)
let protoRecipient = try recipientBuilder.build()
let frameBuilder = BackupProtoFrame.builder()
frameBuilder.setRecipient(protoRecipient)
let frame = try frameBuilder.build()
try stream.writeFrame(frame)
} catch let error {
owsFailDebug("Failed to write recipient!")
firstError = firstError ?? error
}
}
if let firstError {
throw firstError
}
return (currentRecipientProtoId, addressMap)
}
private func writeGroups(
nextRecipientProtoId: UInt64,
stream: CloudBackupOutputStream,
tx: DBReadTransaction
) throws -> [Data: UInt64] {
var currentRecipientProtoId = nextRecipientProtoId
var idMap = [Data: UInt64]()
var firstError: Error?
try tsThreadFetcher.enumerateAllGroupThreads(tx: tx) { groupThread in
do {
guard groupThread.isGroupV2Thread, let groupsV2Model = groupThread.groupModel as? TSGroupModelV2 else {
return
}
let groupSecretParams = try GroupSecretParams(contents: [UInt8](groupsV2Model.secretParamsData))
let groupMasterKey = try groupSecretParams.getMasterKey().serialize().asData
// TODO: instead of doing per-thread fetches, we should bulk load
// some of these fetched fields into memory to avoid db round trips.
let groupBuilder = BackupProtoGroup.builder(
masterKey: groupMasterKey,
whitelisted: self.profileManager.isThread(inProfileWhitelist: groupThread, tx: tx),
hideStory: self.storyFinder.isStoryHidden(forGroupThread: groupThread, tx: tx) ?? false
)
switch groupThread.storyViewMode {
case .disabled:
groupBuilder.setStorySendMode(.disabled)
case .explicit:
groupBuilder.setStorySendMode(.enabled)
default:
groupBuilder.setStorySendMode(.default)
}
let groupProto = try groupBuilder.build()
let recipientBuilder = BackupProtoRecipient.builder(
id: currentRecipientProtoId
)
idMap[groupThread.groupId] = currentRecipientProtoId
currentRecipientProtoId += 1
recipientBuilder.setGroup(groupProto)
let recipientProto = try recipientBuilder.build()
let frameBuilder = BackupProtoFrame.builder()
frameBuilder.setRecipient(recipientProto)
let frame = try frameBuilder.build()
try stream.writeFrame(frame)
} catch let error {
owsFailDebug("Failed to write recipient!")
firstError = firstError ?? error
}
}
if let firstError {
throw firstError
}
return idMap
}
private func writeThreads(
addressMap: [SignalServiceAddress: UInt64],
groupIdMap: [Data: UInt64],
stream: CloudBackupOutputStream,
tx: DBReadTransaction
) throws -> [String: UInt64] {
var currentChatId: UInt64 = 1
var idMap = [String: UInt64]()
var firstError: Error?
tsThreadFetcher.enumerateAll(tx: tx) { thread in
do {
guard thread is TSGroupThread || thread is TSContactThread else {
return
}
let recipientProtoId: UInt64
if
let groupId = (thread as? TSGroupThread)?.groupId,
let id = groupIdMap[groupId]
{
recipientProtoId = id
} else if
let contactAddress = (thread as? TSContactThread)?.contactAddress,
let id = addressMap[contactAddress]
{
recipientProtoId = id
} else {
owsFailDebug("Missing proto recipient id!")
return
}
let threadAssociatedData = self.tsThreadFetcher.fetchOrDefaultThreadAssociatedData(for: thread, tx: tx)
let chatBuilder = BackupProtoChat.builder(
id: currentChatId,
recipientID: recipientProtoId,
archived: threadAssociatedData.isArchived,
pinned: self.tsThreadFetcher.isThreadPinned(thread),
// TODO: should this be millis? or seconds?
expirationTimer: UInt64(self.dmConfigurationStore.durationSeconds(for: thread, tx: tx)),
muteUntil: threadAssociatedData.mutedUntilTimestamp,
markedUnread: threadAssociatedData.isMarkedUnread,
// TODO: this is commented out on storageService? ignoring for now.
dontNotifyForMentionsIfMuted: false
)
idMap[thread.uniqueId] = currentChatId
currentChatId += 1
let chatProto = try chatBuilder.build()
let frameBuilder = BackupProtoFrame.builder()
frameBuilder.setChat(chatProto)
let frame = try frameBuilder.build()
try stream.writeFrame(frame)
} catch let error {
firstError = firstError ?? error
}
}
if let firstError {
throw firstError
}
return idMap
}
private func writeMessages(
chatMap: [String: UInt64],
addressMap: [SignalServiceAddress: UInt64],
stream: CloudBackupOutputStream,
tx: DBReadTransaction
) throws {
guard let localAddress = tsAccountManager.localIdentifiers(tx: tx)?.aciAddress else {
owsFailDebug("No local address!")
return
}
var firstError: Error?
tsInteractionFetcher.enumerateAllTextOnlyMessages(tx: tx) { message in
do {
if message.wasRemotelyDeleted {
// TODO: handle remotely deleted messages
return
}
guard let chatId = chatMap[message.uniqueThreadId] else {
owsFailDebug("Message missing chat")
return
}
let authorAddress = (message as? TSIncomingMessage)?.authorAddress ?? localAddress
guard let authorId = addressMap[authorAddress] else {
owsFailDebug("missing author id!")
return
}
let chatItemBuilder = BackupProtoChatItem.builder(
chatID: chatId,
authorID: authorId,
dateSent: message.timestamp,
dateReceived: message.receivedAtTimestamp,
sms: false
)
// TODO: don't include messages expiring within 24hr
if message.expireStartedAt > 0 {
chatItemBuilder.setExpireStart(message.expireStartedAt)
}
if message.expiresAt > 0 {
chatItemBuilder.setExpiresIn(message.expiresAt)
}
switch message.editState {
case .latestRevisionRead, .latestRevisionUnread, .none:
break
case .pastRevision:
// TODO: include message edits
return
}
if let incomingMessage = message as? TSIncomingMessage {
let incomingMessageProtoBuilder = BackupProtoChatItemIncomingMessageDetails.builder(
dateServerSent: incomingMessage.serverDeliveryTimestamp,
read: incomingMessage.wasRead,
sealedSender: incomingMessage.wasReceivedByUD
)
let incomingMessageProto = try incomingMessageProtoBuilder.build()
chatItemBuilder.setIncoming(incomingMessageProto)
} else if let outgoingMessage = message as? TSOutgoingMessage {
let outgoingMessageProtoBuilder = BackupProtoChatItemOutgoingMessageDetails.builder()
try outgoingMessage.recipientAddressStates?.keys.forEach { address in
guard let sendState = outgoingMessage.recipientState(for: address) else {
return
}
guard let recipientId = addressMap[address] else {
owsFailDebug("Missing recipient for message!")
return
}
var isNetworkFailure = false
var isIdentityKeyMismatchFailure = false
let protoDeliveryStatus: BackupProtoSendStatusStatus
let statusTimestamp: UInt64
switch sendState.state {
case OWSOutgoingMessageRecipientState.sent:
if let readTimestamp = sendState.readTimestamp {
protoDeliveryStatus = .read
statusTimestamp = readTimestamp.uint64Value
} else if let viewedTimestamp = sendState.viewedTimestamp {
protoDeliveryStatus = .viewed
statusTimestamp = viewedTimestamp.uint64Value
} else if let deliveryTimestamp = sendState.deliveryTimestamp {
protoDeliveryStatus = .delivered
statusTimestamp = deliveryTimestamp.uint64Value
} else {
protoDeliveryStatus = .sent
statusTimestamp = message.timestamp
}
case OWSOutgoingMessageRecipientState.failed:
// TODO: identify specific errors. for now call everything network.
isNetworkFailure = true
isIdentityKeyMismatchFailure = false
protoDeliveryStatus = .failed
statusTimestamp = message.timestamp
case OWSOutgoingMessageRecipientState.sending, OWSOutgoingMessageRecipientState.pending:
protoDeliveryStatus = .pending
statusTimestamp = message.timestamp
case OWSOutgoingMessageRecipientState.skipped:
protoDeliveryStatus = .skipped
statusTimestamp = message.timestamp
}
let sendStatusBuilder: BackupProtoSendStatusBuilder = BackupProtoSendStatus.builder(
recipientID: recipientId,
networkFailure: isNetworkFailure,
identityKeyMismatch: isIdentityKeyMismatchFailure,
sealedSender: sendState.wasSentByUD.negated,
timestamp: statusTimestamp
)
sendStatusBuilder.setDeliveryStatus(protoDeliveryStatus)
let sendStatus = try sendStatusBuilder.build()
outgoingMessageProtoBuilder.addSendStatus(sendStatus)
}
let outgoingMessageProto = try outgoingMessageProtoBuilder.build()
chatItemBuilder.setOutgoing(outgoingMessageProto)
}
guard let body = message.body else {
// TODO: handle non simple text messages.
return
}
let standardMessageBuilder = BackupProtoStandardMessage.builder()
let textBuilder = BackupProtoText.builder(body: body)
for bodyRange in message.bodyRanges?.toProtoBodyRanges() ?? [] {
let bodyRangeProtoBuilder = BackupProtoBodyRange.builder()
bodyRangeProtoBuilder.setStart(bodyRange.start)
bodyRangeProtoBuilder.setLength(bodyRange.length)
if let mentionAci = bodyRange.mentionAci {
bodyRangeProtoBuilder.setMentionAci(mentionAci)
} else if let style = bodyRange.style {
switch style {
case .none:
bodyRangeProtoBuilder.setStyle(.none)
case .bold:
bodyRangeProtoBuilder.setStyle(.bold)
case .italic:
bodyRangeProtoBuilder.setStyle(.italic)
case .spoiler:
bodyRangeProtoBuilder.setStyle(.spoiler)
case .strikethrough:
bodyRangeProtoBuilder.setStyle(.strikethrough)
case .monospace:
bodyRangeProtoBuilder.setStyle(.monospace)
}
}
let bodyRangeProto = try bodyRangeProtoBuilder.build()
textBuilder.addBodyRanges(bodyRangeProto)
}
let textProto = try textBuilder.build()
standardMessageBuilder.setText(textProto)
// TODO: reactions
let standardMessageProto = try standardMessageBuilder.build()
chatItemBuilder.setStandardMessage(standardMessageProto)
let chatItemProto = try chatItemBuilder.build()
let frameBuilder = BackupProtoFrame.builder()
frameBuilder.setChatItem(chatItemProto)
let frame = try frameBuilder.build()
try stream.writeFrame(frame)
} catch let error {
firstError = firstError ?? error
}
}
if let firstError {
throw firstError
}
}
private func _importBackup(_ fileUrl: URL, tx: DBWriteTransaction) throws {
let stream: CloudBackupInputStream
switch streamProvider.openInputFileStream(fileURL: fileUrl) {
case .success(let streamResult):
stream = streamResult
case .failure(let error):
throw error
}
defer {
stream.closeFileStream()
}
let header = try stream.readHeader()
guard let backupInfo = header.object else {
return
}
Logger.info("Reading backup with version: \(backupInfo.version) backed up at \(backupInfo.backupTime)")
var aciMap = [UInt64: Aci]()
var pniMap = [UInt64: Pni]()
var addressMap = [UInt64: SignalServiceAddress]()
var groupIdMap = [UInt64: Data]()
var threadUniqueIdMap = [UInt64: String]()
var hasMoreFrames = header.moreBytesAvailable
while hasMoreFrames {
let frame = try stream.readFrame()
hasMoreFrames = frame.moreBytesAvailable
if let recipient = frame.object?.recipient {
if let contact = recipient.contact {
try handleReadContact(
contact,
recipientProtoId: recipient.id,
aciMap: &aciMap,
pniMap: &pniMap,
addressMap: &addressMap,
tx: tx
)
} else if let group = recipient.group {
try handleReadGroup(
group,
recipientProtoId: recipient.id,
groupIdMap: &groupIdMap,
tx: tx
)
}
} else if let chat = frame.object?.chat {
try handleReadChat(
chat,
addressMap: addressMap,
groupIdMap: groupIdMap,
threadUniqueIdMap: &threadUniqueIdMap,
tx: tx
)
} else if let chatItem = frame.object?.chatItem {
try handleReadChatItem(
chatItem: chatItem,
aciMap: aciMap,
pniMap: pniMap,
threadUniqueIdMap: threadUniqueIdMap,
tx: tx
)
}
}
return stream.closeFileStream()
}
private func handleReadContact(
_ contactProto: BackupProtoContact,
recipientProtoId: UInt64,
aciMap: inout [UInt64: Aci],
pniMap: inout [UInt64: Pni],
addressMap: inout [UInt64: SignalServiceAddress],
tx: DBWriteTransaction
) throws {
let isRegistered: Bool?
let unregisteredTimestamp: UInt64?
switch contactProto.registered {
case .none, .unknown:
isRegistered = nil
unregisteredTimestamp = nil
case .registered:
isRegistered = true
unregisteredTimestamp = nil
case .notRegistered:
isRegistered = false
unregisteredTimestamp = contactProto.unregisteredTimestamp
}
let aci: Aci? = contactProto.aci.map(UUID.from(data:))?.map(\.0).map(Aci.init(fromUUID:))
let pni: Pni? = contactProto.pni.map(UUID.from(data:))?.map(\.0).map(Pni.init(fromUUID:))
if let aci {
aciMap[recipientProtoId] = aci
}
if let pni {
pniMap[recipientProtoId] = pni
}
var recipient = SignalRecipient.proofOfConcept_forBackup(
aci: aci,
pni: pni,
phoneNumber: E164(contactProto.e164),
isRegistered: isRegistered,
unregisteredAtTimestamp: unregisteredTimestamp
)
// This is bad, but needed because the import can happen at any time
// and we don't wipe the db. in the future, we will only do this restore
// during registration/linking, with an empty database.
if let existingRecipient = signalRecipientFetcher.recipient(for: recipient.address, tx: tx) {
recipient = existingRecipient
if isRegistered == true, !recipient.isRegistered {
signalRecipientFetcher.markAsRegisteredAndSave(recipient, tx: tx)
} else if isRegistered == false, recipient.isRegistered, let unregisteredTimestamp {
signalRecipientFetcher.markAsUnregisteredAndSave(recipient, at: unregisteredTimestamp, tx: tx)
}
} else {
try signalRecipientFetcher.insert(recipient, tx: tx)
}
addressMap[recipientProtoId] = recipient.address
if contactProto.profileSharing {
// Add to the whitelist.
profileManager.addToWhitelist(recipient.address, tx: tx)
}
if contactProto.blocked {
blockingManager.addBlockedAddress(recipient.address, tx: tx)
}
if contactProto.hidden {
try recipientHidingManager.addHiddenRecipient(recipient, wasLocallyInitiated: false, tx: tx)
}
if contactProto.hideStory, let aci {
let storyContext = storyFinder.getOrCreateStoryContextAssociatedData(for: aci, tx: tx)
storyFinder.setStoryContextHidden(storyContext, tx: tx)
}
profileManager.setProfileGivenName(
givenName: contactProto.profileGivenName,
familyName: contactProto.profileFamilyName,
profileKey: contactProto.profileKey,
address: recipient.address,
tx: tx
)
}
private func handleReadGroup(
_ groupProto: BackupProtoGroup,
recipientProtoId: UInt64,
groupIdMap: inout [UInt64: Data],
tx: DBWriteTransaction
) throws {
let masterKey = groupProto.masterKey
guard groupsV2.isValidGroupV2MasterKey(masterKey) else {
owsFailDebug("Invalid master key.")
return
}
let groupContextInfo: GroupV2ContextInfo
do {
groupContextInfo = try groupsV2.groupV2ContextInfo(forMasterKeyData: masterKey)
} catch {
owsFailDebug("Invalid master key.")
return
}
let groupId = groupContextInfo.groupId
var needsUpdate = false
let groupThread: TSGroupThread
if let localThread = tsThreadFetcher.fetch(groupId: groupId, tx: tx) {
let localStorySendMode = localThread.storyViewMode.storageServiceMode
switch (groupProto.storySendMode, localThread.storyViewMode) {
case (.disabled, .disabled), (.enabled, .explicit), (.default, _), (nil, _):
// Nothing to change.
break
case (.disabled, _):
tsThreadFetcher.updateWithStorySendEnabled(false, groupThread: localThread, tx: tx)
case (.enabled, _):
tsThreadFetcher.updateWithStorySendEnabled(true, groupThread: localThread, tx: tx)
}
groupThread = localThread
} else {
// TODO: creating groups is async and scheduled in GroupsV2. Punt for now.
return
}
groupIdMap[recipientProtoId] = groupId
if groupProto.whitelisted {
profileManager.addToWhitelist(groupThread, tx: tx)
}
if groupProto.hideStory {
let storyContext = storyFinder.getOrCreateStoryContextAssociatedData(forGroupThread: groupThread, tx: tx)
storyFinder.setStoryContextHidden(storyContext, tx: tx)
}
}
private func handleReadChat(
_ chatProto: BackupProtoChat,
addressMap: [UInt64: SignalServiceAddress],
groupIdMap: [UInt64: Data],
threadUniqueIdMap: inout [UInt64: String],
tx: DBWriteTransaction
) throws {
let thread: TSThread
if let groupId = groupIdMap[chatProto.recipientID] {
// We don't create the group thread here; that happened when parsing the Group.
// Instead, just set metadata.
guard let groupThread = tsThreadFetcher.fetch(groupId: groupId, tx: tx) else {
return
}
thread = groupThread
} else if let address = addressMap[chatProto.recipientID] {
thread = tsThreadFetcher.getOrCreateContactThread(with: address, tx: tx)
} else {
owsFailDebug("Missing recipient for chat!")
return
}
threadUniqueIdMap[chatProto.id] = thread.uniqueId
var associatedDataNeedsUpdate = false
var isArchived: Bool?
var isMarkedUnread: Bool?
var mutedUntilTimestamp: UInt64?
// TODO: should probably unarchive if set to false?
if chatProto.archived {
associatedDataNeedsUpdate = true
isArchived = true
}
if chatProto.markedUnread {
associatedDataNeedsUpdate = true
isMarkedUnread = true
}
if chatProto.muteUntil != 0 {
associatedDataNeedsUpdate = true
mutedUntilTimestamp = chatProto.muteUntil
}
if associatedDataNeedsUpdate {
let threadAssociatedData = tsThreadFetcher.fetchOrDefaultThreadAssociatedData(for: thread, tx: tx)
tsThreadFetcher.updateAssociatedData(
threadAssociatedData,
isArchived: isArchived,
isMarkedUnread: isMarkedUnread,
mutedUntilTimestamp: mutedUntilTimestamp,
tx: tx
)
}
if chatProto.pinned {
do {
try tsThreadFetcher.pinThread(thread, tx: tx)
} catch {
// TODO: we might pin a thread thats already pinned.
// Ignore this error, but ideally catch others.
}
}
if chatProto.expirationTimer != 0 {
// TODO: should this be millis? or seconds?
dmConfigurationStore.set(
token: .init(isEnabled: true, durationSeconds: UInt32(chatProto.expirationTimer)),
for: .thread(thread),
tx: tx
)
}
}
private func handleReadChatItem(
chatItem: BackupProtoChatItem,
aciMap: [UInt64: Aci],
pniMap: [UInt64: Pni],
threadUniqueIdMap: [UInt64: String],
tx: DBWriteTransaction
) throws {
guard let standardMessage = chatItem.standardMessage else {
// TODO: handle other message types
return
}
guard
let threadUniqueId = threadUniqueIdMap[chatItem.chatID],
let thread = tsThreadFetcher.fetch(threadUniqueId: threadUniqueId, tx: tx)
else {
owsFailDebug("Missing thread for message!")
return
}
let bodyRanges: MessageBodyRanges?
if let bodyRangesProto = standardMessage.text?.bodyRanges, !bodyRangesProto.isEmpty {
var bodyMentions = [NSRange: Aci]()
var bodyStyles = [NSRangedValue<MessageBodyRanges.SingleStyle>]()
for bodyRange in bodyRangesProto {
let range = NSRange(location: Int(bodyRange.start), length: Int(bodyRange.length))
if bodyRange.hasMentionAci, let mentionAci = Aci.parseFrom(aciString: bodyRange.mentionAci) {
bodyMentions[range] = mentionAci
} else if bodyRange.hasStyle {
let swiftStyle: MessageBodyRanges.SingleStyle
switch bodyRange.style {
case .some(.none), nil:
continue
case .bold:
swiftStyle = .bold
case .italic:
swiftStyle = .italic
case .monospace:
swiftStyle = .monospace
case .spoiler:
swiftStyle = .spoiler
case .strikethrough:
swiftStyle = .strikethrough
}
bodyStyles.append(.init(swiftStyle, range: range))
}
}
bodyRanges = .init(mentions: bodyMentions, styles: bodyStyles)
} else {
bodyRanges = nil
}
if let incomingMessage = chatItem.incoming {
guard let authorAci = aciMap[chatItem.authorID] else {
owsFailDebug("Missing author for message!")
return
}
let messageBuilder = TSIncomingMessageBuilder.builder(
thread: thread,
timestamp: chatItem.dateReceived,
authorAci: .init(authorAci),
// TODO: this needs to be added to the proto
sourceDeviceId: 1,
messageBody: standardMessage.text?.body,
bodyRanges: bodyRanges,
attachmentIds: nil,
// TODO: handle edit states
editState: .none,
// TODO: expose + set expire start time
expiresInSeconds: UInt32(chatItem.expiresIn),
quotedMessage: nil,
contactShare: nil,
linkPreview: nil,
messageSticker: nil,
serverTimestamp: nil,
serverDeliveryTimestamp: chatItem.dateSent,
serverGuid: nil,
wasReceivedByUD: incomingMessage.sealedSender.negated,
isViewOnceMessage: false,
storyAuthorAci: nil,
storyTimestamp: nil,
storyReactionEmoji: nil,
giftBadge: nil,
paymentNotification: nil
)
let message = messageBuilder.build()
tsInteractionFetcher.insert(message, tx: tx)
} else if let outgoingMessage = chatItem.outgoing {
let messageBuilder = TSOutgoingMessageBuilder.builder(
thread: thread,
timestamp: chatItem.dateSent,
messageBody: standardMessage.text?.body,
bodyRanges: bodyRanges,
attachmentIds: nil,
// TODO: is this seconds or ms?
expiresInSeconds: UInt32(chatItem.expiresIn),
expireStartedAt: chatItem.expireStart,
isVoiceMessage: false,
groupMetaMessage: .unspecified,
quotedMessage: nil,
contactShare: nil,
linkPreview: nil,
messageSticker: nil,
isViewOnceMessage: false,
changeActionsProtoData: nil,
additionalRecipients: nil,
skippedRecipients: nil,
storyAuthorAci: nil,
storyTimestamp: nil,
storyReactionEmoji: nil,
giftBadge: nil
)
let message = tsInteractionFetcher.insertMessageWithBuilder(messageBuilder, tx: tx)
for sendStatus in outgoingMessage.sendStatus {
let recipient: ServiceId
if let aci = aciMap[sendStatus.recipientID] {
recipient = aci
} else if let pni = pniMap[sendStatus.recipientID] {
recipient = pni
} else {
continue
}
if let deliveryStatus = sendStatus.deliveryStatus {
tsInteractionFetcher.update(
message,
withRecipient: recipient,
status: deliveryStatus,
timestamp: sendStatus.timestamp,
wasSentByUD: sendStatus.sealedSender.negated,
tx: tx
)
}
}
// TODO: mark the message as sent and whatnot.
}
}
}