Signal-iOS/SignalServiceKit/MessageBackup/MessageBackupManagerImpl.swift
2025-04-02 17:26:03 -07:00

1340 lines
61 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import LibSignalClient
import GRDB
public enum BackupValidationError: Error {
case unknownFields([String])
case validationFailed(message: String, unknownFields: [String])
case ioError(String)
case unknownError
}
public enum BackupImportError: Error {
case unsupportedVersion
}
public class MessageBackupManagerImpl: MessageBackupManager {
private enum Constants {
static let keyValueStoreCollectionName = "MessageBackupManager"
static let keyValueStoreHasReservedBackupKey = "HasReservedBackupKey"
static let keyValueStoreHasReservedMediaBackupKey = "HasReservedMediaBackupKey"
static let keyValueStoreHasRestoredBackupKey = "HasRestoredBackup"
static let supportedBackupVersion: UInt64 = 1
/// The ratio of frames processed for which to sample memory.
static let memorySamplerFrameRatio: Float = FeatureFlags.MessageBackup.detailedBenchLogging ? 0.001 : 0
}
private class NotImplementedError: Error {}
private class BackupError: Error {}
private typealias LoggableErrorAndProto = MessageBackup.LoggableErrorAndProto
private let accountDataArchiver: MessageBackupAccountDataArchiver
private let adHocCallArchiver: MessageBackupAdHocCallArchiver
private let appVersion: AppVersion
private let attachmentDownloadManager: AttachmentDownloadManager
private let attachmentUploadManager: AttachmentUploadManager
private let avatarFetcher: MessageBackupAvatarFetcher
private let backupAttachmentDownloadManager: BackupAttachmentDownloadManager
private let backupAttachmentUploadManager: BackupAttachmentUploadManager
private let backupRequestManager: MessageBackupRequestManager
private let backupStickerPackDownloadStore: BackupStickerPackDownloadStore
private let callLinkRecipientArchiver: MessageBackupCallLinkRecipientArchiver
private let chatArchiver: MessageBackupChatArchiver
private let chatItemArchiver: MessageBackupChatItemArchiver
private let contactRecipientArchiver: MessageBackupContactRecipientArchiver
private let databaseChangeObserver: DatabaseChangeObserver
private let dateProvider: DateProvider
private let dateProviderMonotonic: DateProviderMonotonic
private let db: any DB
private let dbFileSizeProvider: DBFileSizeProvider
private let disappearingMessagesJob: OWSDisappearingMessagesJob
private let distributionListRecipientArchiver: MessageBackupDistributionListRecipientArchiver
private let encryptedStreamProvider: MessageBackupEncryptedProtoStreamProvider
private let errorPresenter: MessageBackupErrorPresenter
private let fullTextSearchIndexer: MessageBackupFullTextSearchIndexer
private let groupRecipientArchiver: MessageBackupGroupRecipientArchiver
private let incrementalTSAttachmentMigrator: IncrementalMessageTSAttachmentMigrator
private let kvStore: KeyValueStore
private let localStorage: AccountKeyStore
private let localRecipientArchiver: MessageBackupLocalRecipientArchiver
private let messageBackupKeyMaterial: MessageBackupKeyMaterial
private let messagePipelineSupervisor: MessagePipelineSupervisor
private let plaintextStreamProvider: MessageBackupPlaintextProtoStreamProvider
private let postFrameRestoreActionManager: MessageBackupPostFrameRestoreActionManager
private let releaseNotesRecipientArchiver: MessageBackupReleaseNotesRecipientArchiver
private let stickerPackArchiver: MessageBackupStickerPackArchiver
public init(
accountDataArchiver: MessageBackupAccountDataArchiver,
adHocCallArchiver: MessageBackupAdHocCallArchiver,
appVersion: AppVersion,
attachmentDownloadManager: AttachmentDownloadManager,
attachmentUploadManager: AttachmentUploadManager,
avatarFetcher: MessageBackupAvatarFetcher,
backupAttachmentDownloadManager: BackupAttachmentDownloadManager,
backupAttachmentUploadManager: BackupAttachmentUploadManager,
backupRequestManager: MessageBackupRequestManager,
backupStickerPackDownloadStore: BackupStickerPackDownloadStore,
callLinkRecipientArchiver: MessageBackupCallLinkRecipientArchiver,
chatArchiver: MessageBackupChatArchiver,
chatItemArchiver: MessageBackupChatItemArchiver,
contactRecipientArchiver: MessageBackupContactRecipientArchiver,
databaseChangeObserver: DatabaseChangeObserver,
dateProvider: @escaping DateProvider,
dateProviderMonotonic: @escaping DateProviderMonotonic,
db: any DB,
dbFileSizeProvider: DBFileSizeProvider,
disappearingMessagesJob: OWSDisappearingMessagesJob,
distributionListRecipientArchiver: MessageBackupDistributionListRecipientArchiver,
encryptedStreamProvider: MessageBackupEncryptedProtoStreamProvider,
errorPresenter: MessageBackupErrorPresenter,
fullTextSearchIndexer: MessageBackupFullTextSearchIndexer,
groupRecipientArchiver: MessageBackupGroupRecipientArchiver,
incrementalTSAttachmentMigrator: IncrementalMessageTSAttachmentMigrator,
localStorage: AccountKeyStore,
localRecipientArchiver: MessageBackupLocalRecipientArchiver,
messageBackupKeyMaterial: MessageBackupKeyMaterial,
messagePipelineSupervisor: MessagePipelineSupervisor,
plaintextStreamProvider: MessageBackupPlaintextProtoStreamProvider,
postFrameRestoreActionManager: MessageBackupPostFrameRestoreActionManager,
releaseNotesRecipientArchiver: MessageBackupReleaseNotesRecipientArchiver,
stickerPackArchiver: MessageBackupStickerPackArchiver
) {
self.accountDataArchiver = accountDataArchiver
self.appVersion = appVersion
self.attachmentDownloadManager = attachmentDownloadManager
self.attachmentUploadManager = attachmentUploadManager
self.avatarFetcher = avatarFetcher
self.backupAttachmentDownloadManager = backupAttachmentDownloadManager
self.backupAttachmentUploadManager = backupAttachmentUploadManager
self.backupRequestManager = backupRequestManager
self.backupStickerPackDownloadStore = backupStickerPackDownloadStore
self.callLinkRecipientArchiver = callLinkRecipientArchiver
self.chatArchiver = chatArchiver
self.chatItemArchiver = chatItemArchiver
self.contactRecipientArchiver = contactRecipientArchiver
self.databaseChangeObserver = databaseChangeObserver
self.dateProvider = dateProvider
self.dateProviderMonotonic = dateProviderMonotonic
self.db = db
self.dbFileSizeProvider = dbFileSizeProvider
self.disappearingMessagesJob = disappearingMessagesJob
self.distributionListRecipientArchiver = distributionListRecipientArchiver
self.encryptedStreamProvider = encryptedStreamProvider
self.errorPresenter = errorPresenter
self.fullTextSearchIndexer = fullTextSearchIndexer
self.groupRecipientArchiver = groupRecipientArchiver
self.incrementalTSAttachmentMigrator = incrementalTSAttachmentMigrator
self.kvStore = KeyValueStore(collection: Constants.keyValueStoreCollectionName)
self.localStorage = localStorage
self.localRecipientArchiver = localRecipientArchiver
self.messageBackupKeyMaterial = messageBackupKeyMaterial
self.messagePipelineSupervisor = messagePipelineSupervisor
self.plaintextStreamProvider = plaintextStreamProvider
self.postFrameRestoreActionManager = postFrameRestoreActionManager
self.releaseNotesRecipientArchiver = releaseNotesRecipientArchiver
self.stickerPackArchiver = stickerPackArchiver
self.adHocCallArchiver = adHocCallArchiver
}
// MARK: - Remote backups
/// Initialize Message Backups by reserving a backup ID and registering a public key used to sign backup auth credentials.
/// These registration calls are safe to call multiple times, but to avoid unecessary network calls, the app will remember if
/// backups have been successfully registered on this device and will no-op in this case.
private func reserveAndRegister(localIdentifiers: LocalIdentifiers, auth: ChatServiceAuth) async throws {
let (hasReservedBackupKey, hasReservedMediaBackupKey) = db.read { tx in
return (
kvStore.getBool(Constants.keyValueStoreHasReservedBackupKey, transaction: tx) ?? false,
kvStore.getBool(Constants.keyValueStoreHasReservedMediaBackupKey, transaction: tx) ?? false
)
}
if hasReservedBackupKey && hasReservedMediaBackupKey {
return
}
// Both reserveBackupId and registerBackupKeys can be called multiple times, so if
// we think the backupId needs to be registered, register the public key at the same time.
let localAci = localIdentifiers.aci
try await backupRequestManager.reserveBackupId(localAci: localAci, auth: auth)
try await backupRequestManager.registerBackupKeys(localAci: localAci, auth: auth)
// Remember this device has registered for backups
await db.awaitableWrite { [weak self] tx in
self?.kvStore.setBool(true, key: Constants.keyValueStoreHasReservedBackupKey, transaction: tx)
self?.kvStore.setBool(true, key: Constants.keyValueStoreHasReservedMediaBackupKey, transaction: tx)
}
}
public func downloadEncryptedBackup(localIdentifiers: LocalIdentifiers, auth: ChatServiceAuth) async throws -> URL {
let backupAuth = try await backupRequestManager.fetchBackupServiceAuth(
for: .messages,
localAci: localIdentifiers.aci,
auth: auth
)
let metadata = try await backupRequestManager.fetchBackupRequestMetadata(auth: backupAuth)
let tmpFileUrl = try await attachmentDownloadManager.downloadBackup(metadata: metadata).awaitable()
// Once protos calm down, this can be enabled to warn/error on failed validation
// try await validateBackup(localIdentifiers: localIdentifiers, fileUrl: tmpFileUrl)
return tmpFileUrl
}
public func uploadEncryptedBackup(
metadata: Upload.EncryptedBackupUploadMetadata,
localIdentifiers: LocalIdentifiers,
auth: ChatServiceAuth
) async throws -> Upload.Result<Upload.EncryptedBackupUploadMetadata> {
// This will return early if this device has already registered the backup ID.
try await reserveAndRegister(localIdentifiers: localIdentifiers, auth: auth)
let backupAuth = try await backupRequestManager.fetchBackupServiceAuth(
for: .messages,
localAci: localIdentifiers.aci,
auth: auth
)
let form = try await backupRequestManager.fetchBackupUploadForm(auth: backupAuth)
return try await attachmentUploadManager.uploadBackup(localUploadMetadata: metadata, form: form)
}
// MARK: - Export
public func exportEncryptedBackup(
localIdentifiers: LocalIdentifiers,
backupKey: BackupKey,
backupPurpose: MessageBackupPurpose,
progress progressSink: OWSProgressSink?
) async throws -> Upload.EncryptedBackupUploadMetadata {
let includedContentFilter = MessageBackup.ArchivingContext.IncludedContentFilter(
minExpirationTimeMs: {
switch backupPurpose {
case .deviceTransfer:
// Don't exclude any messages in "device transfer" backups,
// i.e. Link'n'Syncs.
return 0
case .remoteBackup:
// Skip messages with timers of less than a day.
return .dayInMs
}
}(),
minRemainingTimeUntilExpirationMs: {
switch backupPurpose {
case .deviceTransfer:
// Don't exclude any messages in "device transfer" backups,
// i.e. Link'n'Syncs.
return 0
case .remoteBackup:
// Skip messages with less than a day before they'll expire.
return .dayInMs
}
}(),
shouldIncludePin: true
)
return try await _exportBackup(
localIdentifiers: localIdentifiers,
backupPurpose: backupPurpose,
includedContentFilter: includedContentFilter,
progressSink: progressSink,
benchTitle: "Export encrypted Backup",
openOutputStreamBlock: { exportProgress, tx in
return encryptedStreamProvider.openEncryptedOutputFileStream(
localAci: localIdentifiers.aci,
backupKey: backupKey,
exportProgress: exportProgress,
tx: tx
)
}
)
}
#if TESTABLE_BUILD
public func exportPlaintextBackupForTests(
localIdentifiers: LocalIdentifiers,
progress progressSink: OWSProgressSink?
) async throws -> URL {
guard FeatureFlags.MessageBackup.fileAlpha else {
owsFailDebug("Should not be able to use backups!")
throw NotImplementedError()
}
// For the integration tests, don't filter out any content. The premise
// of the tests is to verify that round-tripping a Backup file is
// idempotent.
let includedContentFilter = MessageBackup.ArchivingContext.IncludedContentFilter(
minExpirationTimeMs: 0,
minRemainingTimeUntilExpirationMs: 0,
shouldIncludePin: true
)
return try await _exportBackup(
localIdentifiers: localIdentifiers,
backupPurpose: .remoteBackup,
includedContentFilter: includedContentFilter,
progressSink: progressSink,
benchTitle: "Export plaintext Backup",
openOutputStreamBlock: { exportProgress, tx in
return plaintextStreamProvider.openPlaintextOutputFileStream(
exportProgress: exportProgress
)
}
)
}
#endif
private func _exportBackup<OutputStreamMetadata>(
localIdentifiers: LocalIdentifiers,
backupPurpose: MessageBackupPurpose,
includedContentFilter: MessageBackup.ArchivingContext.IncludedContentFilter,
progressSink: OWSProgressSink?,
benchTitle: String,
openOutputStreamBlock: (
MessageBackupExportProgress?,
DBReadTransaction
) -> MessageBackup.ProtoStream.OpenOutputStreamResult<OutputStreamMetadata>
) async throws -> OutputStreamMetadata {
let migrateAttachmentsProgressSink: OWSProgressSink?
let exportProgress: MessageBackupExportProgress?
if let progressSink {
migrateAttachmentsProgressSink = await progressSink.addChild(
withLabel: "Export Backup: Migrate Attachments",
unitCount: 5
)
exportProgress = try await .prepare(
sink: await progressSink.addChild(
withLabel: "Export Backup: Export Frames",
unitCount: 95
),
db: db
)
} else {
migrateAttachmentsProgressSink = nil
exportProgress = nil
}
let messageProcessingSuspensionHandle = messagePipelineSupervisor.suspendMessageProcessing(for: .messageBackup)
defer {
messageProcessingSuspensionHandle.invalidate()
}
await migrateAttachmentsBeforeBackup(progress: migrateAttachmentsProgressSink)
let result: Result<OutputStreamMetadata, Error> = await db.awaitableWriteWithTxCompletion { tx in
return self.databaseChangeObserver.disable(tx: tx, during: { tx in
do {
let outputStreamMetadata = try BenchMemory(
title: benchTitle,
memorySamplerRatio: Constants.memorySamplerFrameRatio,
logInProduction: true
) { memorySampler -> OutputStreamMetadata in
let outputStream: MessageBackupProtoOutputStream
let outputStreamMetadataProvider: () throws -> OutputStreamMetadata
switch openOutputStreamBlock(exportProgress, tx) {
case .success(let _outputStream, let _outputStreamMetadataProvider):
outputStream = _outputStream
outputStreamMetadataProvider = _outputStreamMetadataProvider
case .unableToOpenFileStream:
throw OWSAssertionError("Unable to open output file stream!")
}
try self._exportBackup(
outputStream: outputStream,
localIdentifiers: localIdentifiers,
backupPurpose: backupPurpose,
includedContentFilter: includedContentFilter,
currentAppVersion: appVersion.currentAppVersion,
firstAppVersion: appVersion.firstBackupAppVersion ?? appVersion.firstAppVersion,
memorySampler: memorySampler,
tx: tx
)
return try outputStreamMetadataProvider()
}
return .commit(.success(outputStreamMetadata))
} catch let error {
return .rollback(.failure(error))
}
})
}
return try result.get()
}
private func _exportBackup(
outputStream stream: MessageBackupProtoOutputStream,
localIdentifiers: LocalIdentifiers,
backupPurpose: MessageBackupPurpose,
includedContentFilter: MessageBackup.ArchivingContext.IncludedContentFilter,
currentAppVersion: String,
firstAppVersion: String,
memorySampler: MemorySampler,
tx: DBWriteTransaction
) throws {
let bencher = MessageBackup.ArchiveBencher(
dateProviderMonotonic: dateProviderMonotonic,
memorySampler: memorySampler
)
let startTimestampMs = dateProvider().ows_millisecondsSince1970
let backupVersion = Constants.supportedBackupVersion
let purposeString: String = switch backupPurpose {
case .deviceTransfer: "LinkNSync"
case .remoteBackup: "RemoteBackup"
}
var errors = [LoggableErrorAndProto]()
let result = Result<Void, Error>(catching: {
Logger.info("Exporting for \(purposeString) with version \(backupVersion), timestamp \(startTimestampMs)")
try autoreleasepool {
try writeHeader(
stream: stream,
backupVersion: backupVersion,
backupTimeMs: startTimestampMs,
currentAppVersion: currentAppVersion,
firstAppVersion: firstAppVersion,
tx: tx
)
}
try Task.checkCancellation()
let currentBackupAttachmentUploadEra: String?
if MessageBackupMessageAttachmentArchiver.isFreeTierBackup() {
currentBackupAttachmentUploadEra = nil
} else {
currentBackupAttachmentUploadEra = try MessageBackupMessageAttachmentArchiver.currentUploadEra()
}
let customChatColorContext = MessageBackup.CustomChatColorArchivingContext(
backupAttachmentUploadManager: backupAttachmentUploadManager,
bencher: bencher,
currentBackupAttachmentUploadEra: currentBackupAttachmentUploadEra,
includedContentFilter: includedContentFilter,
startTimestampMs: startTimestampMs,
tx: tx
)
try autoreleasepool {
let accountDataResult = accountDataArchiver.archiveAccountData(
stream: stream,
context: customChatColorContext
)
switch accountDataResult {
case .success:
break
case .failure(let error):
errors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true))
throw OWSAssertionError("Failed to archive account data")
}
}
try Task.checkCancellation()
let localRecipientResult = localRecipientArchiver.archiveLocalRecipient(
stream: stream,
bencher: bencher,
localIdentifiers: localIdentifiers,
tx: tx
)
try Task.checkCancellation()
let localRecipientId: MessageBackup.RecipientId
switch localRecipientResult {
case .success(let success):
localRecipientId = success
case .failure(let error):
errors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true))
throw OWSAssertionError("Failed to archive local recipient!")
}
let recipientArchivingContext = MessageBackup.RecipientArchivingContext(
backupAttachmentUploadManager: backupAttachmentUploadManager,
bencher: bencher,
currentBackupAttachmentUploadEra: currentBackupAttachmentUploadEra,
includedContentFilter: includedContentFilter,
localIdentifiers: localIdentifiers,
localRecipientId: localRecipientId,
startTimestampMs: startTimestampMs,
tx: tx
)
try autoreleasepool {
switch releaseNotesRecipientArchiver.archiveReleaseNotesRecipient(
stream: stream,
context: recipientArchivingContext
) {
case .success:
break
case .failure(let error):
errors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true))
throw OWSAssertionError("Failed to archive release notes channel!")
}
}
try Task.checkCancellation()
switch try contactRecipientArchiver.archiveAllContactRecipients(
stream: stream,
context: recipientArchivingContext
) {
case .success:
break
case .partialSuccess(let partialFailures):
errors.append(contentsOf: partialFailures.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false) })
case .completeFailure(let error):
errors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true))
throw BackupError()
}
switch try groupRecipientArchiver.archiveAllGroupRecipients(
stream: stream,
context: recipientArchivingContext
) {
case .success:
break
case .partialSuccess(let partialFailures):
errors.append(contentsOf: partialFailures.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false) })
case .completeFailure(let error):
errors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true))
throw BackupError()
}
switch try distributionListRecipientArchiver.archiveAllDistributionListRecipients(
stream: stream,
context: recipientArchivingContext
) {
case .success:
break
case .partialSuccess(let partialFailures):
errors.append(contentsOf: partialFailures.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false) })
case .completeFailure(let error):
errors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true))
throw BackupError()
}
switch try callLinkRecipientArchiver.archiveAllCallLinkRecipients(
stream: stream,
context: recipientArchivingContext
) {
case .success:
break
case .partialSuccess(let partialFailures):
errors.append(contentsOf: partialFailures.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false) })
case .completeFailure(let error):
errors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true))
throw BackupError()
}
let chatArchivingContext = MessageBackup.ChatArchivingContext(
backupAttachmentUploadManager: backupAttachmentUploadManager,
bencher: bencher,
currentBackupAttachmentUploadEra: currentBackupAttachmentUploadEra,
customChatColorContext: customChatColorContext,
includedContentFilter: includedContentFilter,
recipientContext: recipientArchivingContext,
startTimestampMs: startTimestampMs,
tx: tx
)
let chatArchiveResult = try chatArchiver.archiveChats(
stream: stream,
context: chatArchivingContext
)
switch chatArchiveResult {
case .success:
break
case .partialSuccess(let partialFailures):
errors.append(contentsOf: partialFailures.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false) })
case .completeFailure(let error):
errors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true))
throw BackupError()
}
let chatItemArchiveResult = try chatItemArchiver.archiveInteractions(
stream: stream,
context: chatArchivingContext
)
switch chatItemArchiveResult {
case .success:
break
case .partialSuccess(let partialFailures):
errors.append(contentsOf: partialFailures.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false) })
case .completeFailure(let error):
errors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true))
throw BackupError()
}
let archivingContext = MessageBackup.ArchivingContext(
backupAttachmentUploadManager: backupAttachmentUploadManager,
bencher: bencher,
currentBackupAttachmentUploadEra: currentBackupAttachmentUploadEra,
includedContentFilter: includedContentFilter,
startTimestampMs: startTimestampMs,
tx: tx
)
let stickerPackArchiveResult = try stickerPackArchiver.archiveStickerPacks(
stream: stream,
context: archivingContext
)
switch stickerPackArchiveResult {
case .success:
break
case .partialSuccess(let partialFailures):
errors.append(contentsOf: partialFailures.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false) })
case .completeFailure(let error):
errors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true))
throw BackupError()
}
let adHocCallArchiveResult = try adHocCallArchiver.archiveAdHocCalls(
stream: stream,
context: chatArchivingContext
)
switch adHocCallArchiveResult {
case .success:
break
case .partialSuccess(let partialFailures):
errors.append(contentsOf: partialFailures.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false) })
case .completeFailure(let error):
errors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true))
throw BackupError()
}
try stream.closeFileStream()
tx.addSyncCompletion { [backupAttachmentUploadManager] in
Task {
// TODO: [Backups] this needs to talk to the banner at the top of the chat
// list to show progress.
try await backupAttachmentUploadManager.backUpAllAttachments()
}
}
Logger.info("Finished exporting backup")
bencher.logResults()
})
processErrors(errors: errors, didFail: result.isSuccess.negated, tx: tx)
return try result.get()
}
private func writeHeader(
stream: MessageBackupProtoOutputStream,
backupVersion: UInt64,
backupTimeMs: UInt64,
currentAppVersion: String,
firstAppVersion: String,
tx: DBWriteTransaction
) throws {
var backupInfo = BackupProto_BackupInfo()
backupInfo.version = backupVersion
backupInfo.backupTimeMs = backupTimeMs
backupInfo.currentAppVersion = currentAppVersion
backupInfo.firstAppVersion = firstAppVersion
backupInfo.mediaRootBackupKey = localStorage.getOrGenerateMediaRootBackupKey(tx: tx).serialize().asData
switch stream.writeHeader(backupInfo) {
case .success:
break
case .fileIOError(let error), .protoSerializationError(let error):
throw error
}
}
// MARK: - Import
public func hasRestoredFromBackup(tx: DBReadTransaction) -> Bool {
return kvStore.getBool(
Constants.keyValueStoreHasRestoredBackupKey,
defaultValue: false,
transaction: tx
)
}
public func importEncryptedBackup(
fileUrl: URL,
localIdentifiers: LocalIdentifiers,
backupKey: BackupKey,
progress progressSink: OWSProgressSink?
) async throws {
try await _importBackup(
fileUrl: fileUrl,
localIdentifiers: localIdentifiers,
progressSink: progressSink,
benchTitle: "Import encrypted Backup",
openInputStreamBlock: { fileUrl, frameRestoreProgress, tx in
return encryptedStreamProvider.openEncryptedInputFileStream(
fileUrl: fileUrl,
localAci: localIdentifiers.aci,
backupKey: backupKey,
frameRestoreProgress: frameRestoreProgress,
tx: tx
)
}
)
}
public func importPlaintextBackup(
fileUrl: URL,
localIdentifiers: LocalIdentifiers,
progress progressSink: OWSProgressSink?
) async throws {
guard FeatureFlags.MessageBackup.fileAlpha else {
owsFailDebug("Should not be able to use backups!")
throw NotImplementedError()
}
try await _importBackup(
fileUrl: fileUrl,
localIdentifiers: localIdentifiers,
progressSink: progressSink,
benchTitle: "Import plaintext Backup",
openInputStreamBlock: { fileUrl, frameRestoreProgress, _ in
return plaintextStreamProvider.openPlaintextInputFileStream(
fileUrl: fileUrl,
frameRestoreProgress: frameRestoreProgress
)
}
)
}
private func _importBackup(
fileUrl: URL,
localIdentifiers: LocalIdentifiers,
progressSink: OWSProgressSink?,
benchTitle: String,
openInputStreamBlock: (
URL,
MessageBackupImportFrameRestoreProgress?,
DBReadTransaction
) -> MessageBackup.ProtoStream.OpenInputStreamResult
) async throws {
let migrateAttachmentsProgressSink: OWSProgressSink?
let frameRestoreProgress: MessageBackupImportFrameRestoreProgress?
let recreateIndexesProgress: MessageBackupImportRecreateIndexesProgress?
if let progressSink {
migrateAttachmentsProgressSink = await progressSink.addChild(
withLabel: "Import Backup: Migrate Attachments",
unitCount: 5
)
frameRestoreProgress = try await .prepare(
sink: await progressSink.addChild(
withLabel: "Import Backup: Import Frames",
unitCount: 80
),
fileUrl: fileUrl
)
recreateIndexesProgress = await .prepare(
sink: await progressSink.addChild(
withLabel: "Import Backup: Recreate Indexes",
unitCount: 15
)
)
} else {
migrateAttachmentsProgressSink = nil
frameRestoreProgress = nil
recreateIndexesProgress = nil
}
let messageProcessingSuspensionHandle = messagePipelineSupervisor.suspendMessageProcessing(for: .messageBackup)
defer {
messageProcessingSuspensionHandle.invalidate()
}
await migrateAttachmentsBeforeBackup(progress: migrateAttachmentsProgressSink)
let result: Result<BackupProto_BackupInfo, Error> = await db.awaitableWriteWithTxCompletion { tx in
do {
let backupInfo = try BenchMemory(
title: benchTitle,
memorySamplerRatio: Constants.memorySamplerFrameRatio,
logInProduction: true
) { memorySampler -> BackupProto_BackupInfo in
return try self.databaseChangeObserver.disable(tx: tx) { tx in
let inputStream: MessageBackupProtoInputStream
switch openInputStreamBlock(fileUrl, frameRestoreProgress, tx) {
case .success(let protoStream, _):
inputStream = protoStream
case .fileNotFound:
throw OWSAssertionError("File not found!")
case .unableToOpenFileStream:
throw OWSAssertionError("Unable to open input stream!")
case .hmacValidationFailedOnEncryptedFile:
throw OWSAssertionError("HMAC validation failed!")
}
return try self._importBackup(
inputStream: inputStream,
localIdentifiers: localIdentifiers,
recreateIndexesProgress: recreateIndexesProgress,
memorySampler: memorySampler,
tx: tx
)
}
}
return .commit(.success(backupInfo))
} catch let error {
return .rollback(.failure(error))
}
}
let backupInfo = try result.get()
appVersion.didRestoreFromBackup(
backupCurrentAppVersion: backupInfo.currentAppVersion.nilIfEmpty,
backupFirstAppVersion: backupInfo.firstAppVersion.nilIfEmpty
)
}
private func _importBackup(
inputStream stream: MessageBackupProtoInputStream,
localIdentifiers: LocalIdentifiers,
recreateIndexesProgress: MessageBackupImportRecreateIndexesProgress?,
memorySampler: MemorySampler,
tx: DBWriteTransaction
) throws -> BackupProto_BackupInfo {
let bencher = MessageBackup.RestoreBencher(
dateProviderMonotonic: dateProviderMonotonic,
dbFileSizeProvider: dbFileSizeProvider,
memorySampler: memorySampler
)
guard !hasRestoredFromBackup(tx: tx) else {
throw OWSAssertionError("Restoring from backup twice!")
}
let startTimestampMs = dateProvider().ows_millisecondsSince1970
// Drops all indexes on the `TSInteraction` table before doing the
// import, which dramatically speeds up the import. We'll then recreate
// all these indexes in bulk afterwards.
let interactionIndexes = try bencher.benchPreFrameRestoreAction(.DropInteractionIndexes) {
try dropAllIndexes(
forTable: InteractionRecord.databaseTableName,
tx: tx
)
}
var frameErrors = [LoggableErrorAndProto]()
let result = Result<BackupProto_BackupInfo, Error>(catching: {
var hasMoreFrames = false
var framesRestored: UInt64 = 0
let backupInfo: BackupProto_BackupInfo
switch stream.readHeader() {
case .success(let header, let moreBytesAvailable):
backupInfo = header
hasMoreFrames = moreBytesAvailable
framesRestored += 1
case .invalidByteLengthDelimiter:
throw OWSAssertionError("invalid byte length delimiter on header")
case .emptyFinalFrame:
throw OWSAssertionError("invalid empty header frame")
case .protoDeserializationError(let error):
// Fail if we fail to deserialize the header.
frameErrors.append(LoggableErrorAndProto(
error: MessageBackup.RestoreFrameError.restoreFrameError(
.invalidProtoData(.missingBackupInfoHeader),
MessageBackup.BackupInfoId()
),
wasFrameDropped: true
))
throw error
}
Logger.info("Importing with version \(backupInfo.version), timestamp \(backupInfo.backupTimeMs)")
guard backupInfo.version == Constants.supportedBackupVersion else {
frameErrors.append(LoggableErrorAndProto(
error: MessageBackup.RestoreFrameError.restoreFrameError(
.invalidProtoData(.unsupportedBackupInfoVersion),
MessageBackup.BackupInfoId()
),
wasFrameDropped: true,
protoFrame: backupInfo
))
throw BackupImportError.unsupportedVersion
}
do {
localStorage.setMediaRootBackupKey(try BackupKey(contents: Array(backupInfo.mediaRootBackupKey)), tx: tx)
} catch {
frameErrors.append(LoggableErrorAndProto(
error: MessageBackup.RestoreFrameError.restoreFrameError(
.invalidProtoData(.invalidMediaRootBackupKey),
MessageBackup.BackupInfoId()
),
wasFrameDropped: true,
protoFrame: backupInfo
))
throw error
}
/// Wraps all the various "contexts" we pass to downstream archivers.
struct Contexts {
let chat: MessageBackup.ChatRestoringContext
var chatItem: MessageBackup.ChatItemRestoringContext
let customChatColor: MessageBackup.CustomChatColorRestoringContext
let recipient: MessageBackup.RecipientRestoringContext
let stickerPack: MessageBackup.RestoringContext
init(
localIdentifiers: LocalIdentifiers,
startTimestampMs: UInt64,
tx: DBWriteTransaction
) {
customChatColor = MessageBackup.CustomChatColorRestoringContext(
startTimestampMs: startTimestampMs,
tx: tx
)
recipient = MessageBackup.RecipientRestoringContext(
localIdentifiers: localIdentifiers,
startTimestampMs: startTimestampMs,
tx: tx
)
chat = MessageBackup.ChatRestoringContext(
customChatColorContext: customChatColor,
recipientContext: recipient,
startTimestampMs: startTimestampMs,
tx: tx
)
chatItem = MessageBackup.ChatItemRestoringContext(
chatContext: chat,
recipientContext: recipient,
startTimestampMs: startTimestampMs,
tx: tx
)
stickerPack = MessageBackup.RestoringContext(
startTimestampMs: startTimestampMs,
tx: tx
)
}
}
let contexts = Contexts(
localIdentifiers: localIdentifiers,
startTimestampMs: startTimestampMs,
tx: tx
)
while hasMoreFrames {
try Task.checkCancellation()
try autoreleasepool {
let frame: BackupProto_Frame?
switch stream.readFrame() {
case let .success(_frame, moreBytesAvailable):
frame = _frame
hasMoreFrames = moreBytesAvailable
framesRestored += 1
case .invalidByteLengthDelimiter:
throw OWSAssertionError("invalid byte length delimiter on header")
case .emptyFinalFrame:
frame = nil
hasMoreFrames = false
case .protoDeserializationError(let error):
// fail the whole thing if we fail to deserialize one frame
owsFailDebug("Failed to deserialize proto frame!")
if FeatureFlags.MessageBackup.restoreFailOnAnyError {
throw error
} else {
return
}
}
guard
let frame,
let frameItem = frame.item
else {
if hasMoreFrames {
frameErrors.append(LoggableErrorAndProto(
error: MessageBackup.UnrecognizedEnumError(
enumType: BackupProto_Frame.OneOf_Item.self
),
wasFrameDropped: true
))
}
return
}
try bencher.processFrame { frameBencher in
defer {
frameBencher.didProcessFrame(frame)
}
switch frameItem {
case .recipient(let recipient):
let recipientResult: MessageBackup.RestoreFrameResult<MessageBackup.RecipientId>
switch recipient.destination {
case nil:
recipientResult = .unrecognizedEnum(MessageBackup.UnrecognizedEnumError(
enumType: BackupProto_Recipient.OneOf_Destination.self
))
case .self_p(let selfRecipientProto):
recipientResult = localRecipientArchiver.restoreSelfRecipient(
selfRecipientProto,
recipient: recipient,
context: contexts.recipient
)
case .contact(let contactRecipientProto):
recipientResult = contactRecipientArchiver.restoreContactRecipientProto(
contactRecipientProto,
recipient: recipient,
context: contexts.recipient
)
case .group(let groupRecipientProto):
recipientResult = groupRecipientArchiver.restoreGroupRecipientProto(
groupRecipientProto,
recipient: recipient,
context: contexts.recipient
)
case .distributionList(let distributionListRecipientProto):
recipientResult = distributionListRecipientArchiver.restoreDistributionListRecipientProto(
distributionListRecipientProto,
recipient: recipient,
context: contexts.recipient
)
case .releaseNotes(let releaseNotesRecipientProto):
recipientResult = releaseNotesRecipientArchiver.restoreReleaseNotesRecipientProto(
releaseNotesRecipientProto,
recipient: recipient,
context: contexts.recipient
)
case .callLink(let callLinkRecipientProto):
recipientResult = callLinkRecipientArchiver.restoreCallLinkRecipientProto(
callLinkRecipientProto,
recipient: recipient,
context: contexts.recipient
)
}
switch recipientResult {
case .success:
return
case .unrecognizedEnum(let error):
frameErrors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true, protoFrame: recipient))
return
case .partialRestore(let errors):
frameErrors.append(contentsOf: errors.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false, protoFrame: recipient) })
case .failure(let errors):
frameErrors.append(contentsOf: errors.map { LoggableErrorAndProto(error: $0, wasFrameDropped: true, protoFrame: recipient) })
if FeatureFlags.MessageBackup.restoreFailOnAnyError {
throw BackupError()
}
}
case .chat(let chat):
let chatResult = chatArchiver.restore(
chat,
context: contexts.chat
)
switch chatResult {
case .success:
return
case .unrecognizedEnum(let error):
frameErrors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true, protoFrame: chat))
return
case .partialRestore(let errors):
frameErrors.append(contentsOf: errors.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false, protoFrame: chat) })
case .failure(let errors):
frameErrors.append(contentsOf: errors.map { LoggableErrorAndProto(error: $0, wasFrameDropped: true, protoFrame: chat) })
if FeatureFlags.MessageBackup.restoreFailOnAnyError {
throw BackupError()
}
}
case .chatItem(let chatItem):
let chatItemResult = chatItemArchiver.restore(
chatItem,
context: contexts.chatItem
)
switch chatItemResult {
case .success:
return
case .unrecognizedEnum(let error):
frameErrors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true, protoFrame: chatItem))
return
case .partialRestore(let errors):
frameErrors.append(contentsOf: errors.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false, protoFrame: chatItem) })
case .failure(let errors):
frameErrors.append(contentsOf: errors.map { LoggableErrorAndProto(error: $0, wasFrameDropped: true, protoFrame: chatItem) })
if FeatureFlags.MessageBackup.restoreFailOnAnyError {
throw BackupError()
}
}
case .account(let backupProtoAccountData):
let accountDataResult = accountDataArchiver.restore(
backupProtoAccountData,
chatColorsContext: contexts.customChatColor,
chatItemContext: contexts.chatItem
)
switch accountDataResult {
case .success:
return
case .unrecognizedEnum(let error):
frameErrors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true, protoFrame: backupProtoAccountData))
return
case .partialRestore(let errors):
frameErrors.append(contentsOf: errors.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false, protoFrame: backupProtoAccountData) })
case .failure(let errors):
frameErrors.append(contentsOf: errors.map { LoggableErrorAndProto(error: $0, wasFrameDropped: true, protoFrame: backupProtoAccountData) })
// We always fail if we fail to import account data, even in prod.
throw BackupError()
}
case .stickerPack(let backupProtoStickerPack):
let stickerPackResult = stickerPackArchiver.restore(
backupProtoStickerPack,
context: contexts.stickerPack
)
switch stickerPackResult {
case .success:
return
case .unrecognizedEnum(let error):
frameErrors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true, protoFrame: backupProtoStickerPack))
return
case .partialRestore(let errors):
frameErrors.append(contentsOf: errors.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false, protoFrame: backupProtoStickerPack) })
case .failure(let errors):
frameErrors.append(contentsOf: errors.map { LoggableErrorAndProto(error: $0, wasFrameDropped: true, protoFrame: backupProtoStickerPack) })
if FeatureFlags.MessageBackup.restoreFailOnAnyError {
throw BackupError()
}
}
case .adHocCall(let backupProtoAdHocCall):
let adHocCallResult = adHocCallArchiver.restore(
backupProtoAdHocCall,
context: contexts.chatItem
)
switch adHocCallResult {
case .success:
return
case .unrecognizedEnum(let error):
frameErrors.append(LoggableErrorAndProto(error: error, wasFrameDropped: true, protoFrame: backupProtoAdHocCall))
return
case .partialRestore(let errors):
frameErrors.append(contentsOf: errors.map { LoggableErrorAndProto(error: $0, wasFrameDropped: false, protoFrame: backupProtoAdHocCall) })
case .failure(let errors):
frameErrors.append(contentsOf: errors.map { LoggableErrorAndProto(error: $0, wasFrameDropped: true, protoFrame: backupProtoAdHocCall) })
if FeatureFlags.MessageBackup.restoreFailOnAnyError {
throw BackupError()
}
}
case .notificationProfile:
// Notification profiles are unsupported on iOS and
// we do not even round trip them per spec.
break
case .chatFolder:
// Chat folders are unsupported on iOS and
// we do not even round trip them per spec.
break
}
}
}
}
stream.closeFileStream()
// Now that we've imported successfully, we want to recreate the
// the indexes we temporarily dropped.
recreateIndexesProgress?.willStartIndexRecreation(totalFramesRestored: framesRestored)
try bencher.benchPostFrameRestoreAction(.RecreateInteractionIndexes) {
try createIndexes(
interactionIndexes,
onTable: InteractionRecord.databaseTableName,
tx: tx
)
}
recreateIndexesProgress?.didFinishIndexRecreation()
// Take any necessary post-frame-restore actions.
try postFrameRestoreActionManager.performPostFrameRestoreActions(
recipientActions: contexts.recipient.postFrameRestoreActions,
chatActions: contexts.chat.postFrameRestoreActions,
bencher: bencher,
chatItemContext: contexts.chatItem
)
// Index threads synchronously, since that should be fast.
bencher.benchPostFrameRestoreAction(.IndexThreads) {
fullTextSearchIndexer.indexThreads(tx: tx)
}
// Schedule background message indexing, since that'll be slow.
try fullTextSearchIndexer.scheduleMessagesJob(tx: tx)
// Record that we've restored a Backup!
kvStore.setBool(true, key: Constants.keyValueStoreHasRestoredBackupKey, transaction: tx)
tx.addSyncCompletion { [
avatarFetcher,
backupAttachmentDownloadManager,
disappearingMessagesJob
] in
Task {
// Kick off avatar fetches enqueued during restore.
try await avatarFetcher.runIfNeeded()
}
Task {
// Kick off attachment downloads enqueued during restore.
try await backupAttachmentDownloadManager.restoreAttachmentsIfNeeded()
}
// Start ticking down for disappearing messages.
disappearingMessagesJob.startIfNecessary()
}
Logger.info("Imported with version \(backupInfo.version), timestamp \(backupInfo.backupTimeMs)")
Logger.info("Backup app version: \(backupInfo.currentAppVersion.nilIfEmpty ?? "Missing!")")
Logger.info("Backup first app version: \(backupInfo.firstAppVersion.nilIfEmpty ?? "Missing!")")
bencher.logResults()
return backupInfo
})
processErrors(errors: frameErrors, didFail: result.isSuccess.negated, tx: tx)
return try result.get()
}
// MARK: -
private struct SQLiteIndexInfo {
let tableName: String
let sqlThatCreatedIndex: String
}
private func dropAllIndexes(
forTable tableName: String,
tx: DBWriteTransaction
) throws -> [SQLiteIndexInfo] {
let allIndexesOnTable: [GRDB.IndexInfo] = try tx.database.indexes(on: tableName)
var sqliteIndexInfos = [SQLiteIndexInfo]()
for index in allIndexesOnTable {
if index.name.contains("autoindex") {
// Skip indexes automatically created by SQLite, such as on
// primary keys.
continue
}
guard let sqlThatCreatedIndex = try String.fetchOne(
tx.database,
sql: """
SELECT sql FROM sqlite_master
WHERE type = 'index'
AND name = '\(index.name)'
"""
) else {
throw OWSAssertionError("Failed to get SQL for creating index \(index.name)!")
}
sqliteIndexInfos.append(SQLiteIndexInfo(
tableName: tableName,
sqlThatCreatedIndex: sqlThatCreatedIndex
))
try tx.database.drop(index: index.name)
}
return sqliteIndexInfos
}
private func createIndexes(
_ indexInfos: [SQLiteIndexInfo],
onTable tableName: String,
tx: DBWriteTransaction
) throws {
owsPrecondition(indexInfos.allSatisfy { $0.tableName == tableName })
for indexInfo in indexInfos {
try tx.database.execute(sql: indexInfo.sqlThatCreatedIndex)
}
}
// MARK: -
private func processErrors(
errors: [LoggableErrorAndProto],
didFail: Bool,
tx: DBWriteTransaction
) {
let collapsedErrors = MessageBackup.collapse(errors)
var maxLogLevel = -1
var wasFrameDropped = false
collapsedErrors.forEach { collapsedError in
collapsedError.log()
maxLogLevel = max(maxLogLevel, collapsedError.logLevel.rawValue)
if collapsedError.wasFrameDropped {
wasFrameDropped = true
}
}
if wasFrameDropped {
// Log this specifically so we can do a naive exact text search in debug logs.
Logger.error("Dropped frame(s) on backup export or import!!!")
}
// Only present errors if some error rises above warning.
// (But if one does, present _all_ errors).
if maxLogLevel > MessageBackup.LogLevel.warning.rawValue {
errorPresenter.persistErrors(collapsedErrors, didFail: didFail, tx: tx)
}
}
/// TSAttachments must be migrated to v2 Attachments before we can create or restore backups.
/// Normally this migration happens in the background; force it to run and finish now.
private func migrateAttachmentsBeforeBackup(progress: OWSProgressSink?) async {
let didMigrateAnything = await incrementalTSAttachmentMigrator.runUntilFinished(
ignorePastFailures: true,
progress: progress
)
if
let progress,
!didMigrateAnything
{
// Nothing was migrated, so progress wasn't updated. Complete it!
let source = await progress.addSource(
withLabel: "TSAttachmentMigrator had nothing to do",
unitCount: 1
)
source.complete()
}
}
public func validateEncryptedBackup(
fileUrl: URL,
localIdentifiers: LocalIdentifiers,
backupKey: BackupKey,
backupPurpose: MessageBackupPurpose
) async throws {
let key = try backupKey.asMessageBackupKey(for: localIdentifiers.aci)
let fileSize = OWSFileSystem.fileSize(ofPath: fileUrl.path)?.uint64Value ?? 0
do {
let result = try validateMessageBackup(key: key, purpose: backupPurpose, length: fileSize) {
return try FileHandle(forReadingFrom: fileUrl)
}
if result.fields.count > 0 {
throw BackupValidationError.unknownFields(result.fields)
}
} catch {
switch error {
case let validationError as MessageBackupValidationError:
await errorPresenter.persistValidationError(validationError)
Logger.error("Backup validation failed \(validationError.errorMessage)")
throw BackupValidationError.validationFailed(
message: validationError.errorMessage,
unknownFields: validationError.unknownFields.fields
)
case SignalError.ioError(let description):
Logger.error("Backup validation i/o error: \(description)")
throw BackupValidationError.ioError(description)
default:
Logger.error("Backup validation unknown error: \(error)")
throw BackupValidationError.unknownError
}
}
}
}