TM-SGNL-iOS/Signal/DeviceTransfer/DeviceTransferService+Restore.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

535 lines
24 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalServiceKit
extension DeviceTransferService {
private static let hasBeenRestoredKey = "DeviceTransferHasBeenRestored"
var hasBeenRestored: Bool {
get { CurrentAppContext().appUserDefaults().bool(forKey: DeviceTransferService.hasBeenRestoredKey) }
set { CurrentAppContext().appUserDefaults().set(newValue, forKey: DeviceTransferService.hasBeenRestoredKey) }
}
private static let restorePhaseKey = "DeviceTransferRestorationPhase"
var rawRestorationPhase: Int {
get { CurrentAppContext().appUserDefaults().integer(forKey: DeviceTransferService.restorePhaseKey) }
set { CurrentAppContext().appUserDefaults().set(newValue, forKey: DeviceTransferService.restorePhaseKey) }
}
var restorationPhase: RestorationPhase {
get throws {
try RestorationPhase(rawValue: rawRestorationPhase) ?? {
throw OWSAssertionError("Invalid raw value: \(rawRestorationPhase)")
}()
}
}
private enum LegacyRestorationFlags {
static let pendingRestoreKey = "DeviceTransferHasPendingRestore"
static var hasPendingRestore: Bool {
get { CurrentAppContext().appUserDefaults().bool(forKey: Self.pendingRestoreKey) }
set {
owsAssertDebug(newValue == false) // Future transfers should use the `restorationPhase` key
CurrentAppContext().appUserDefaults().set(newValue, forKey: Self.pendingRestoreKey)
}
}
static let pendingWasTransferredClearKey = "DeviceTransferPendingWasTransferredClear"
static var pendingWasTransferredClear: Bool {
get { CurrentAppContext().appUserDefaults().bool(forKey: Self.pendingWasTransferredClearKey) }
set { CurrentAppContext().appUserDefaults().set(newValue, forKey: Self.pendingWasTransferredClearKey) }
}
static let pendingPromotionFromHotSwapToPrimaryDatabaseKey = "DeviceTransferPendingPromotionFromHotSwapToPrimaryDatabase"
static var pendingPromotionFromHotSwapToPrimaryDatabase: Bool {
get { CurrentAppContext().appUserDefaults().bool(forKey: Self.pendingPromotionFromHotSwapToPrimaryDatabaseKey) }
set {
owsAssertDebug(newValue == false) // Hotswapping databases is deprecated
CurrentAppContext().appUserDefaults().set(newValue, forKey: Self.pendingPromotionFromHotSwapToPrimaryDatabaseKey)
}
}
}
func verifyTransferCompletedSuccessfully(receivedFileIds: [String], skippedFileIds: [String]) -> Bool {
guard let manifest = readManifestFromTransferDirectory() else {
owsFailDebug("Missing manifest file")
return false
}
// Check that there aren't any files that we were
// expecting that are missing.
for file in manifest.files {
guard !skippedFileIds.contains(file.identifier) else { continue }
guard receivedFileIds.contains(file.identifier) else {
owsFailDebug("did not receive file \(file.identifier)")
return false
}
guard OWSFileSystem.fileOrFolderExists(
atPath: URL(
fileURLWithPath: file.identifier,
relativeTo: DeviceTransferService.pendingTransferFilesDirectory
).path
) else {
owsFailDebug("Missing file \(file.identifier)")
return false
}
}
// Check that the appropriate database files were received
guard let database = manifest.database else {
owsFailDebug("missing database proto")
return false
}
guard database.key.count == GRDBKeyFetcher.Constants.kSQLCipherKeySpecLength else {
owsFailDebug("incorrect database key length")
return false
}
guard receivedFileIds.contains(DeviceTransferService.databaseIdentifier) else {
owsFailDebug("did not receive database file")
return false
}
guard OWSFileSystem.fileOrFolderExists(
atPath: URL(
fileURLWithPath: DeviceTransferService.databaseIdentifier,
relativeTo: DeviceTransferService.pendingTransferFilesDirectory
).path
) else {
owsFailDebug("missing database file")
return false
}
guard receivedFileIds.contains(DeviceTransferService.databaseWALIdentifier) else {
owsFailDebug("did not receive database wal file")
return false
}
guard OWSFileSystem.fileOrFolderExists(
atPath: URL(
fileURLWithPath: DeviceTransferService.databaseWALIdentifier,
relativeTo: DeviceTransferService.pendingTransferFilesDirectory
).path
) else {
owsFailDebug("missing database wal file")
return false
}
return true
}
func restoreTransferredDataLegacy() -> Bool {
Logger.info("Attempting to restore transferred data.")
guard LegacyRestorationFlags.hasPendingRestore else {
owsFailDebug("Cannot restore data when there was no pending restore")
return false
}
guard let manifest = readManifestFromTransferDirectory() else {
owsFailDebug("Unexpectedly tried to restore data when there is no valid manifest")
return false
}
guard let database = manifest.database else {
owsFailDebug("manifest is missing database")
return false
}
do {
try GRDBKeyFetcher(keychainStorage: keychainStorage).store(data: database.key)
} catch {
owsFailDebug("failed to restore database key")
return false
}
do {
try updateUserDefaults(manifest: manifest)
} catch {
owsFailDebug("Failed to update user defaults: \(error)")
return false
}
for file in manifest.files + [database.database, database.wal] {
let pendingFilePath = URL(
fileURLWithPath: file.identifier,
relativeTo: DeviceTransferService.pendingTransferFilesDirectory
).path
// We could be receiving a database in any of the directory modes,
// so we force the restore path to be the "primary" database since
// that is generally what we desire. If we're hotswapping, this
// path will be later overridden with the hotswap path.
let newFilePath: String
if DeviceTransferService.databaseIdentifier == file.identifier {
newFilePath = GRDBDatabaseStorageAdapter.databaseFileUrl(directoryMode: .primary).path
} else if DeviceTransferService.databaseWALIdentifier == file.identifier {
newFilePath = GRDBDatabaseStorageAdapter.databaseWalUrl(directoryMode: .primary).path
} else {
newFilePath = URL(
fileURLWithPath: file.relativePath,
relativeTo: DeviceTransferService.appSharedDataDirectory
).path
}
// If we're hot swapping the database, we move the database
// files to a special hotswap directory, since the primary
// database is already open. Trying to overwrite the file
// in situ can result in database corruption if something
// tries to perform a write.
var hotswapFilePath: String?
if DeviceTransferService.databaseIdentifier == file.identifier {
hotswapFilePath = GRDBDatabaseStorageAdapter.databaseFileUrl(directoryMode: .hotswapLegacy).path
} else if DeviceTransferService.databaseWALIdentifier == file.identifier {
hotswapFilePath = GRDBDatabaseStorageAdapter.databaseWalUrl(directoryMode: .hotswapLegacy).path
}
if OWSFileSystem.fileOrFolderExists(atPath: pendingFilePath) {
guard OWSFileSystem.deleteFileIfExists(newFilePath) else {
owsFailDebug("Failed to delete existing file.")
return false
}
do {
try move(pendingFilePath: pendingFilePath, to: newFilePath)
} catch {
owsFailDebug("Failed to move file \(file.identifier); \(error.shortDescription)")
return false
}
} else if let hotswapFilePath, OWSFileSystem.fileOrFolderExists(atPath: hotswapFilePath) {
Logger.info("No longer hot swapping, promoting hotswap database to primary database: \(file.identifier)")
guard OWSFileSystem.deleteFileIfExists(newFilePath) else {
owsFailDebug("Failed to delete existing file.")
return false
}
do {
try move(pendingFilePath: hotswapFilePath, to: newFilePath)
} catch {
owsFailDebug("Failed to promote hotswap database \(file.identifier); \(error.shortDescription)")
return false
}
} else if OWSFileSystem.fileOrFolderExists(atPath: newFilePath) {
Logger.info("Skipping restoration of file that was already restored: \(file.identifier)")
} else if [
DeviceTransferService.databaseIdentifier,
DeviceTransferService.databaseWALIdentifier
].contains(file.identifier) {
owsFailDebug("unable to restore file that is missing")
return false
} else {
// We sometimes don't receive a file because it goes missing on the old
// device between when we generate the manifest and when we perform the
// restoration. Our verification process ensures that the only files that
// could be missing in this way are non-essential files. It's better to
// let the user continue than to lock them out of the app in this state.
Logger.info("Skipping restoration of missing file: \(file.identifier)")
continue
}
}
LegacyRestorationFlags.pendingWasTransferredClear = true
LegacyRestorationFlags.pendingPromotionFromHotSwapToPrimaryDatabase = false
hasBeenRestored = true
resetTransferDirectory(createNewTransferDirectory: true)
return true
}
func resetTransferDirectory(createNewTransferDirectory: Bool) {
do {
try FileManager.default.removeItem(atPath: DeviceTransferService.pendingTransferDirectory.path)
} catch CocoaError.fileReadNoSuchFile, CocoaError.fileNoSuchFile, POSIXError.ENOENT {
// it doesn't exist -- this is fine
} catch {
owsFailDebug("Failed to delete existing transfer directory \(error)")
}
if createNewTransferDirectory {
OWSFileSystem.ensureDirectoryExists(DeviceTransferService.pendingTransferDirectory.path)
}
// If we had a pending restore, we no longer do.
switch try? restorationPhase {
case .noCurrentRestoration, .cleanup: break
default: rawRestorationPhase = RestorationPhase.noCurrentRestoration.rawValue
}
LegacyRestorationFlags.hasPendingRestore = false
}
private func move(pendingFilePath: String, to newFilePath: String) throws {
OWSFileSystem.ensureDirectoryExists((newFilePath as NSString).deletingLastPathComponent)
try OWSFileSystem.moveFilePath(pendingFilePath, toFilePath: newFilePath)
}
private func promoteTransferDatabaseToPrimaryDatabase() -> Bool {
Logger.info("Promoting the hotswap database to the primary database")
let primaryDatabaseDirectoryPath = GRDBDatabaseStorageAdapter.databaseDirUrl(directoryMode: .primary).path
let hotswapDatabaseDirectoryPath = GRDBDatabaseStorageAdapter.databaseDirUrl(directoryMode: .hotswapLegacy).path
if OWSFileSystem.fileOrFolderExists(atPath: hotswapDatabaseDirectoryPath) {
guard OWSFileSystem.deleteFileIfExists(primaryDatabaseDirectoryPath) else {
owsFailDebug("Failed to delete existing file.")
return false
}
do {
try move(pendingFilePath: hotswapDatabaseDirectoryPath, to: primaryDatabaseDirectoryPath)
} catch {
owsFailDebug("Failed to promote hotswap database to primary database: \(error.shortDescription)")
return false
}
} else {
guard OWSFileSystem.fileOrFolderExists(atPath: primaryDatabaseDirectoryPath) else {
owsFailDebug("Missing primary and hotswap database directories.")
return false
}
Logger.info("Missing hotswap database, we may have previously restored. Assuming primary database is correct.")
}
LegacyRestorationFlags.pendingPromotionFromHotSwapToPrimaryDatabase = false
return true
}
func launchCleanup() -> Bool {
Logger.info("hasBeenRestored: \(hasBeenRestored)")
let success: Bool
if hasIncompleteRestoration {
do {
try restoreTransferredData()
success = true
} catch {
owsFailDebug("Failed to finish restoration: \(error)")
success = false
}
} else if LegacyRestorationFlags.hasPendingRestore {
success = restoreTransferredDataLegacy()
} else if LegacyRestorationFlags.pendingPromotionFromHotSwapToPrimaryDatabase {
success = promoteTransferDatabaseToPrimaryDatabase()
} else {
success = true
}
if success {
finalizeRestorationIfNecessary()
}
return success
}
}
extension DeviceTransferService {
enum RestorationPhase: Int {
// Start/Complete: Nothing to do.
case noCurrentRestoration = 0
// Performed by `restoreTransferredData()`
case start
case updateUserDefaults
case moveManifestFiles
case allocateNewDatabaseDirectory
case moveDatabaseFiles
case updateDatabase
// This state represents that there's some one-time cleanup that's left to be done
// Restoration is complete, but every time the app launches `finalizeRestorationIfNecessary`
// will run and transition to `noCurrentRestoration` once successful
case cleanup
var next: RestorationPhase {
RestorationPhase(rawValue: rawValue + 1) ?? .noCurrentRestoration
}
}
var hasIncompleteRestoration: Bool { rawRestorationPhase > 0 }
func restoreTransferredData() throws {
do {
let manifest: DeviceTransferProtoManifest? = readManifestFromTransferDirectory()
// Run through the restoration steps. The deal here is:
// - The phase we're currently on has not been completed yet
// - Each phase must be idempotent and capable of handling arbitrary interruption (i.e. crashes)
// - If a phase completes without error, it should be durable
// - We return once we've hit `noCurrentRestoration` or `cleanup`
var currentPhase = try restorationPhase
while currentPhase != .noCurrentRestoration, currentPhase != .cleanup {
Logger.info("Performing restoration phase: \(currentPhase)")
try performRestorationPhase(currentPhase, manifest: manifest)
Logger.info("Completed restoration phase: \(currentPhase)")
currentPhase = currentPhase.next
rawRestorationPhase = currentPhase.rawValue
}
} catch {
owsFailDebug("Hit error during restoration phase \(rawRestorationPhase): \(error)")
throw error
}
}
private func performRestorationPhase(_ phase: RestorationPhase, manifest: DeviceTransferProtoManifest?) throws {
switch phase {
case .noCurrentRestoration, .cleanup:
owsFailDebug("Unexpected state")
case .start:
// No-op, having a start case jut makes the logs look nice
break
case .updateUserDefaults:
try updateUserDefaults(manifest: manifest)
case .moveManifestFiles:
try moveManifestFiles(manifest: manifest)
case .allocateNewDatabaseDirectory:
allocateNewDatabaseDirectory()
case .moveDatabaseFiles:
try moveDatabaseFiles(manifest: manifest)
case .updateDatabase:
try updateCurrentDatabase(manifest: manifest)
// At this point, we've restored all of the data we need. Just some bits of cleanup left.
hasBeenRestored = true
}
}
private func updateUserDefaults(manifest: DeviceTransferProtoManifest?) throws {
guard let manifest = manifest else {
throw OWSAssertionError("No manifest available")
}
let possibleUserDefaultClasses = [
NSData.self,
NSString.self,
NSNumber.self,
NSDate.self,
NSArray.self,
NSDictionary.self,
]
// TODO: We should codify how we want to use standardDefaults. Either we should
// get rid of them, or expand them to support all of our extensions
for userDefault in manifest.standardDefaults {
guard let unarchivedValue = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: possibleUserDefaultClasses, from: userDefault.encodedValue) else {
owsFailDebug("Failed to unarchive value for key \(userDefault.key)")
continue
}
UserDefaults.standard.set(unarchivedValue, forKey: userDefault.key)
}
// TODO: Do we want to transfer all of our app defaults?
for userDefault in manifest.appDefaults {
guard ![
GRDBDatabaseStorageAdapter.DirectoryMode.primaryFolderNameKey,
GRDBDatabaseStorageAdapter.DirectoryMode.transferFolderNameKey,
DeviceTransferService.hasBeenRestoredKey,
LegacyRestorationFlags.pendingRestoreKey,
LegacyRestorationFlags.pendingPromotionFromHotSwapToPrimaryDatabaseKey,
LegacyRestorationFlags.pendingWasTransferredClearKey
].contains(userDefault.key) else { continue }
guard let unarchivedValue = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: possibleUserDefaultClasses, from: userDefault.encodedValue) else {
owsFailDebug("Failed to unarchive value for key \(userDefault.key)")
continue
}
CurrentAppContext().appUserDefaults().set(unarchivedValue, forKey: userDefault.key)
}
}
private func moveManifestFiles(manifest: DeviceTransferProtoManifest?) throws {
guard let manifest = manifest else {
throw OWSAssertionError("No manifest available")
}
let sourceDir = DeviceTransferService.pendingTransferFilesDirectory
let destDir = DeviceTransferService.appSharedDataDirectory
try manifest.files.forEach { file in
let sourceUrl = URL(fileURLWithPath: file.identifier, relativeTo: sourceDir)
let destUrl = URL(fileURLWithPath: file.relativePath, relativeTo: destDir)
do {
try move(pendingFilePath: sourceUrl.path, to: destUrl.path)
} catch CocoaError.fileWriteFileExists {
Logger.info("Skipping restoration of file that was already restored: \(file.identifier)")
} catch CocoaError.fileNoSuchFile, CocoaError.fileReadNoSuchFile, POSIXError.ENOENT {
// We sometimes don't receive a file because it goes missing on the old
// device between when we generate the manifest and when we perform the
// restoration. Our verification process ensures that the only files that
// could be missing in this way are non-essential files. It's better to
// let the user continue than to lock them out of the app in this state.
Logger.info("Skipping restoration of missing file: \(file.identifier)")
} catch {
throw OWSAssertionError("Failed to move file \(file.identifier)")
}
}
}
// We create the directory but do not touch anything about it until this phase has committed
private func allocateNewDatabaseDirectory() {
GRDBDatabaseStorageAdapter.createNewTransferDirectory()
}
private func moveDatabaseFiles(manifest: DeviceTransferProtoManifest?) throws {
guard let database = manifest?.database else {
throw OWSAssertionError("No manifest database available")
}
let sourceDir = DeviceTransferService.pendingTransferFilesDirectory
let databaseSourceFiles = [database.database, database.wal]
try databaseSourceFiles.forEach { file in
let sourceUrl = URL(fileURLWithPath: file.identifier, relativeTo: sourceDir)
let destUrl: URL
switch file.identifier {
case DeviceTransferService.databaseIdentifier:
destUrl = GRDBDatabaseStorageAdapter.databaseFileUrl(directoryMode: .transfer)
case DeviceTransferService.databaseWALIdentifier:
destUrl = GRDBDatabaseStorageAdapter.databaseWalUrl(directoryMode: .transfer)
default:
throw OWSAssertionError("Unknown file identifier")
}
do {
try move(pendingFilePath: sourceUrl.path, to: destUrl.path)
} catch CocoaError.fileWriteFileExists {
Logger.info("Skipping restoration of database file that was already restored: \(file.identifier)")
} catch {
throw OWSAssertionError("Failed to move file \(file.identifier); \(error.shortDescription)")
}
}
}
private func updateCurrentDatabase(manifest: DeviceTransferProtoManifest?) throws {
guard let database = manifest?.database else {
throw OWSAssertionError("No manifest database available")
}
try GRDBKeyFetcher(keychainStorage: keychainStorage).store(data: database.key)
GRDBDatabaseStorageAdapter.promoteTransferDirectoryToPrimary()
}
@discardableResult
func finalizeRestorationIfNecessary() -> Guarantee<Void> {
resetTransferDirectory(createNewTransferDirectory: false)
let (promise, future) = Guarantee<Void>.pending()
appReadiness.runNowOrWhenAppDidBecomeReadySync {
DependenciesBridge.shared.db.write { tx in
DependenciesBridge.shared.registrationStateChangeManager.setIsTransferComplete(
sendStateUpdateNotification: true,
tx: tx
)
}
// Consult both the modern and legacy restoration flag
let currentPhase = (try? self.restorationPhase) ?? .noCurrentRestoration
if currentPhase == .cleanup || LegacyRestorationFlags.pendingWasTransferredClear {
Logger.info("Performing one-time post-restore cleanup...")
GRDBDatabaseStorageAdapter.removeOrphanedGRDBDirectories()
LegacyRestorationFlags.pendingWasTransferredClear = false
self.rawRestorationPhase = RestorationPhase.noCurrentRestoration.rawValue
Logger.info("Done!")
}
future.resolve()
}
return promise
}
}