754 lines
30 KiB
Swift
754 lines
30 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 dateProvider: DateProvider
|
|
private let db: DB
|
|
private let dmConfigurationStore: DisappearingMessagesConfigurationStore
|
|
private let recipientArchiver: CloudBackupRecipientArchiver
|
|
private let streamProvider: CloudBackupProtoStreamProvider
|
|
private let tsAccountManager: TSAccountManager
|
|
private let tsInteractionFetcher: CloudBackup.Shims.TSInteractionFetcher
|
|
private let tsThreadFetcher: CloudBackup.Shims.TSThreadFetcher
|
|
|
|
public init(
|
|
dateProvider: @escaping DateProvider,
|
|
db: DB,
|
|
dmConfigurationStore: DisappearingMessagesConfigurationStore,
|
|
recipientArchiver: CloudBackupRecipientArchiver,
|
|
streamProvider: CloudBackupProtoStreamProvider,
|
|
tsAccountManager: TSAccountManager,
|
|
tsInteractionFetcher: CloudBackup.Shims.TSInteractionFetcher,
|
|
tsThreadFetcher: CloudBackup.Shims.TSThreadFetcher
|
|
) {
|
|
self.dateProvider = dateProvider
|
|
self.db = db
|
|
self.dmConfigurationStore = dmConfigurationStore
|
|
self.recipientArchiver = recipientArchiver
|
|
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: CloudBackupProtoOutputStream
|
|
switch streamProvider.openOutputFileStream() {
|
|
case .success(let streamResult):
|
|
stream = streamResult
|
|
case .unableToOpenFileStream:
|
|
throw OWSAssertionError("Unable to open output stream")
|
|
}
|
|
|
|
guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else {
|
|
throw OWSAssertionError("No local identifiers!")
|
|
}
|
|
let recipientArchivingContext = CloudBackup.RecipientArchivingContext(
|
|
localIdentifiers: localIdentifiers
|
|
)
|
|
|
|
try writeHeader(stream: stream, tx: tx)
|
|
|
|
let recipientArchiveResult = recipientArchiver.archiveRecipients(
|
|
stream: stream,
|
|
context: recipientArchivingContext,
|
|
tx: tx
|
|
)
|
|
switch recipientArchiveResult {
|
|
case .success:
|
|
break
|
|
case .partialSuccess(let partialFailures):
|
|
// TODO: how many failures is too many?
|
|
Logger.warn("Failed to serialize \(partialFailures.count) recipients")
|
|
case .completeFailure(let error):
|
|
throw error
|
|
}
|
|
let chatIdMap = try writeThreads(
|
|
recipientContext: recipientArchivingContext,
|
|
stream: stream,
|
|
tx: tx
|
|
)
|
|
try writeMessages(
|
|
recipientContext: recipientArchivingContext,
|
|
chatMap: chatIdMap,
|
|
stream: stream,
|
|
tx: tx
|
|
)
|
|
|
|
return stream.closeFileStream()
|
|
}
|
|
|
|
private func writeHeader(stream: CloudBackupProtoOutputStream, tx: DBWriteTransaction) throws {
|
|
let backupInfo = try BackupProtoBackupInfo.builder(
|
|
version: 1,
|
|
backupTimeMs: dateProvider().ows_millisecondsSince1970
|
|
).build()
|
|
switch stream.writeHeader(backupInfo) {
|
|
case .success:
|
|
break
|
|
case .fileIOError(let error), .protoSerializationError(let error):
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private func writeThreads(
|
|
recipientContext: CloudBackup.RecipientArchivingContext,
|
|
stream: CloudBackupProtoOutputStream,
|
|
tx: DBReadTransaction
|
|
) throws -> [String: UInt64] {
|
|
var currentChatId: UInt64 = 1
|
|
var idMap = [String: UInt64]()
|
|
|
|
var firstError: Error?
|
|
|
|
var pinnedOrder: UInt32 = 0
|
|
|
|
tsThreadFetcher.enumerateAll(tx: tx) { thread in
|
|
do {
|
|
guard thread is TSGroupThread || thread is TSContactThread else {
|
|
return
|
|
}
|
|
let contactAddress = (thread as? TSContactThread)?.contactAddress
|
|
|
|
let recipientProtoId: CloudBackup.RecipientId
|
|
if
|
|
let groupId = (thread as? TSGroupThread)?.groupId,
|
|
let id = recipientContext[.group(groupId)]
|
|
{
|
|
recipientProtoId = id
|
|
} else if
|
|
let contactAddress = (thread as? TSContactThread)?.contactAddress,
|
|
let id = recipientContext[contactAddress]
|
|
{
|
|
recipientProtoId = id
|
|
} else {
|
|
owsFailDebug("Missing proto recipient id!")
|
|
return
|
|
}
|
|
|
|
let threadAssociatedData = self.tsThreadFetcher.fetchOrDefaultThreadAssociatedData(for: thread, tx: tx)
|
|
|
|
let thisThreadPinnedOrder: UInt32
|
|
if self.tsThreadFetcher.isThreadPinned(thread) {
|
|
pinnedOrder += 1
|
|
thisThreadPinnedOrder = pinnedOrder
|
|
} else {
|
|
// Hardcoded 0 for unpinned.
|
|
thisThreadPinnedOrder = 0
|
|
}
|
|
|
|
let chatBuilder = BackupProtoChat.builder(
|
|
id: currentChatId,
|
|
recipientID: recipientProtoId.value,
|
|
archived: threadAssociatedData.isArchived,
|
|
// TODO: proper pinned thread ordering
|
|
pinnedOrder: thisThreadPinnedOrder,
|
|
// TODO: should this be millis? or seconds?
|
|
expirationTimerMs: UInt64(self.dmConfigurationStore.durationSeconds(for: thread, tx: tx)),
|
|
muteUntilMs: 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()
|
|
|
|
switch stream.writeFrame(frame) {
|
|
case .success:
|
|
break
|
|
case .fileIOError(let error), .protoSerializationError(let error):
|
|
throw error
|
|
}
|
|
|
|
} catch let error {
|
|
firstError = firstError ?? error
|
|
}
|
|
}
|
|
|
|
if let firstError {
|
|
throw firstError
|
|
}
|
|
|
|
return idMap
|
|
}
|
|
|
|
private func writeMessages(
|
|
recipientContext: CloudBackup.RecipientArchivingContext,
|
|
chatMap: [String: UInt64],
|
|
stream: CloudBackupProtoOutputStream,
|
|
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 = recipientContext[authorAddress] else {
|
|
owsFailDebug("missing author id!")
|
|
return
|
|
}
|
|
|
|
let chatItemBuilder = BackupProtoChatItem.builder(
|
|
chatID: chatId,
|
|
authorID: authorId.value,
|
|
dateSent: message.timestamp,
|
|
sms: false
|
|
)
|
|
// TODO: don't include messages expiring within 24hr
|
|
if message.expireStartedAt > 0 {
|
|
chatItemBuilder.setExpireStartMs(message.expireStartedAt)
|
|
}
|
|
if message.expiresAt > 0 {
|
|
chatItemBuilder.setExpiresInMs(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(
|
|
dateReceived: incomingMessage.receivedAtTimestamp,
|
|
dateServerSent: incomingMessage.serverDeliveryTimestamp,
|
|
read: incomingMessage.wasRead,
|
|
sealedSender: incomingMessage.wasReceivedByUD.negated
|
|
)
|
|
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 = recipientContext[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.value,
|
|
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 rawMentionAci = bodyRange.mentionAci,
|
|
let mentionUuid = UUID(uuidString: rawMentionAci)
|
|
{
|
|
bodyRangeProtoBuilder.setMentionAci(Aci(fromUUID: mentionUuid).serviceIdBinary.asData)
|
|
} 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()
|
|
switch stream.writeFrame(frame) {
|
|
case .success:
|
|
break
|
|
case .fileIOError(let error), .protoSerializationError(let error):
|
|
throw error
|
|
}
|
|
|
|
} catch let error {
|
|
firstError = firstError ?? error
|
|
}
|
|
}
|
|
|
|
if let firstError {
|
|
throw firstError
|
|
}
|
|
}
|
|
|
|
private func _importBackup(_ fileUrl: URL, tx: DBWriteTransaction) throws {
|
|
let stream: CloudBackupProtoInputStream
|
|
switch streamProvider.openInputFileStream(fileURL: fileUrl) {
|
|
case .success(let streamResult):
|
|
stream = streamResult
|
|
case .fileNotFound:
|
|
throw OWSAssertionError("file not found!")
|
|
case .unableToOpenFileStream:
|
|
throw OWSAssertionError("unable to open input stream")
|
|
}
|
|
|
|
defer {
|
|
stream.closeFileStream()
|
|
}
|
|
|
|
let backupInfo: BackupProtoBackupInfo
|
|
var hasMoreFrames = false
|
|
switch stream.readHeader() {
|
|
case .success(let header, let moreBytesAvailable):
|
|
backupInfo = header
|
|
hasMoreFrames = moreBytesAvailable
|
|
case .invalidByteLengthDelimiter:
|
|
throw OWSAssertionError("invalid byte length delimiter on header")
|
|
case .protoDeserializationError(let error):
|
|
// Fail if we fail to deserialize the header.
|
|
throw error
|
|
}
|
|
|
|
Logger.info("Reading backup with version: \(backupInfo.version) backed up at \(backupInfo.backupTimeMs)")
|
|
|
|
let recipientContext = CloudBackup.RecipientRestoringContext()
|
|
var threadUniqueIdMap = [UInt64: String]()
|
|
|
|
while hasMoreFrames {
|
|
let frame: BackupProtoFrame
|
|
switch stream.readFrame() {
|
|
case let .success(_frame, moreBytesAvailable):
|
|
frame = _frame
|
|
hasMoreFrames = moreBytesAvailable
|
|
case .invalidByteLengthDelimiter:
|
|
throw OWSAssertionError("invalid byte length delimiter on header")
|
|
case .protoDeserializationError(let error):
|
|
// TODO: should we fail the whole thing if we fail to deserialize one frame?
|
|
throw error
|
|
}
|
|
if let recipient = frame.recipient {
|
|
let recipientResult = recipientArchiver.restore(
|
|
recipient,
|
|
context: recipientContext,
|
|
tx: tx
|
|
)
|
|
switch recipientResult {
|
|
case .success:
|
|
continue
|
|
case .failure(_, let error):
|
|
// TODO: maybe track which IDs failed to attribute later failures
|
|
// that reference this ID.
|
|
switch error {
|
|
case .databaseInsertionFailed(let dbError):
|
|
throw dbError
|
|
case .invalidProtoData:
|
|
throw OWSAssertionError("Invalid proto data!")
|
|
case .identifierNotFound:
|
|
throw OWSAssertionError("Recipients are the root objects, should be impossible!")
|
|
case .unknownFrameType:
|
|
throw OWSAssertionError("Found unrecognized frame type")
|
|
}
|
|
}
|
|
} else if let chat = frame.chat {
|
|
try handleReadChat(
|
|
chat,
|
|
recipientContext: recipientContext,
|
|
threadUniqueIdMap: &threadUniqueIdMap,
|
|
tx: tx
|
|
)
|
|
} else if let chatItem = frame.chatItem {
|
|
try handleReadChatItem(
|
|
chatItem: chatItem,
|
|
recipientContext: recipientContext,
|
|
threadUniqueIdMap: threadUniqueIdMap,
|
|
tx: tx
|
|
)
|
|
}
|
|
}
|
|
|
|
return stream.closeFileStream()
|
|
}
|
|
|
|
private func handleReadChat(
|
|
_ chatProto: BackupProtoChat,
|
|
recipientContext: CloudBackup.RecipientRestoringContext,
|
|
threadUniqueIdMap: inout [UInt64: String],
|
|
tx: DBWriteTransaction
|
|
) throws {
|
|
let thread: TSThread
|
|
switch recipientContext[chatProto.recipientId] {
|
|
case .none:
|
|
owsFailDebug("Missing recipient for chat!")
|
|
return
|
|
case .noteToSelf:
|
|
// TODO: handle note to self chat, create the tsThread
|
|
return
|
|
case .group(let groupId):
|
|
// 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
|
|
case let .contact(aci, pni, e164):
|
|
let address = SignalServiceAddress(serviceId: aci ?? pni, phoneNumber: e164?.stringValue)
|
|
thread = tsThreadFetcher.getOrCreateContactThread(with: address, tx: tx)
|
|
}
|
|
|
|
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.muteUntilMs != 0 {
|
|
associatedDataNeedsUpdate = true
|
|
mutedUntilTimestamp = chatProto.muteUntilMs
|
|
}
|
|
|
|
if associatedDataNeedsUpdate {
|
|
let threadAssociatedData = tsThreadFetcher.fetchOrDefaultThreadAssociatedData(for: thread, tx: tx)
|
|
tsThreadFetcher.updateAssociatedData(
|
|
threadAssociatedData,
|
|
isArchived: isArchived,
|
|
isMarkedUnread: isMarkedUnread,
|
|
mutedUntilTimestamp: mutedUntilTimestamp,
|
|
tx: tx
|
|
)
|
|
}
|
|
// TODO: recover pinned chat ordering
|
|
if chatProto.pinnedOrder != 0 {
|
|
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.expirationTimerMs != 0 {
|
|
// TODO: should this be millis? or seconds?
|
|
dmConfigurationStore.set(
|
|
token: .init(isEnabled: true, durationSeconds: UInt32(chatProto.expirationTimerMs)),
|
|
for: .thread(thread),
|
|
tx: tx
|
|
)
|
|
}
|
|
}
|
|
|
|
private func handleReadChatItem(
|
|
chatItem: BackupProtoChatItem,
|
|
recipientContext: CloudBackup.RecipientRestoringContext,
|
|
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
|
|
let rawMentionAci = bodyRange.mentionAci,
|
|
let mentionAci = try? Aci.parseFrom(serviceIdBinary: rawMentionAci)
|
|
{
|
|
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 {
|
|
|
|
let authorAci: Aci
|
|
switch recipientContext[chatItem.authorRecipientId] {
|
|
case .contact(let aci, _, _):
|
|
guard let aci else {
|
|
fallthrough
|
|
}
|
|
authorAci = aci
|
|
default:
|
|
// Messages can only come from Acis.
|
|
owsFailDebug("Missing author for message!")
|
|
return
|
|
}
|
|
|
|
let messageBuilder = TSIncomingMessageBuilder.builder(
|
|
thread: thread,
|
|
timestamp: incomingMessage.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.expiresInMs),
|
|
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.expiresInMs),
|
|
expireStartedAt: chatItem.expireStartMs,
|
|
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
|
|
switch recipientContext[chatItem.authorRecipientId] {
|
|
case .contact(let aci, let pni, _):
|
|
guard let serviceId: ServiceId = aci ?? pni else {
|
|
fallthrough
|
|
}
|
|
recipient = serviceId
|
|
default:
|
|
// Recipients can only be Acis or Pnis.
|
|
// TODO: what about e164s?
|
|
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.
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate extension CloudBackup.RecipientArchivingContext {
|
|
|
|
subscript(address: SignalServiceAddress) -> CloudBackup.RecipientId? {
|
|
// swiftlint:disable:next implicit_getter
|
|
get {
|
|
if
|
|
let aci = address.serviceId as? Aci,
|
|
let id = self[.contactAci(aci)]
|
|
{
|
|
return id
|
|
} else if
|
|
let pni = address.serviceId as? Pni,
|
|
let id = self[.contactPni(pni)]
|
|
{
|
|
return id
|
|
} else if
|
|
let e164 = address.e164,
|
|
let id = self[.contactE164(e164)]
|
|
{
|
|
return id
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|