Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/DeviceTransfer/DeviceTransferService+Restore.swift
1 views
//
// 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 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 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
    }
}