Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Devices/LinkAndSyncManager.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import LibSignalClient

/// For Link'n'Sync errors thrown on the primary device.
public enum PrimaryLinkNSyncError: Error {
    case cancelled(linkedDeviceId: DeviceId?)
    case errorWaitingForLinkedDevice
    case errorGeneratingBackup
    // Only these two types are "retryable" in that we let the
    // user choose whether to reset provisioning to try again
    // or continue linking without syncing.
    case errorUploadingBackup(RetryHandler)
    case errorMarkingBackupUploaded(RetryHandler)

    /// For handling Link'n'Sync errors thrown on the primary device.
    public protocol RetryHandler {
        /// Tells the linked device to reset itself to be ready for relinking.
        ///
        /// Note that this won't necessarily work; we _try_ to tell the linked
        /// device to reset but many things can happen that prevent this
        /// (including this method swallowing e.g. network errors).
        func tryToResetLinkedDevice() async

        /// Tells the linked device to continue linking without syncing.
        ///
        /// Note that this won't necessarily work; we _try_ to tell the linked
        /// device to reset but many things can happen that prevent this
        /// (including this method swallowing e.g. network errors).
        func tryToContinueWithoutSyncing() async
    }
}

/// Used as the label for OWSProgress.
public enum PrimaryLinkNSyncProgressPhase: String, OWSSequentialProgressStep {
    case waitingForLinking
    case exportingBackup
    case uploadingBackup
    case finishing

    public var progressUnitCount: UInt64 {
        return switch self {
        case .waitingForLinking: 5
        case .exportingBackup: 50
        case .uploadingBackup: 40
        case .finishing: 5
        }
    }
}

/// Link'n'Sync errors thrown on the secondary device.
public enum SecondaryLinkNSyncError: Error, Equatable {
    case primaryFailedBackupExport(continueWithoutSyncing: Bool)
    case errorRestoringBackup
}

/// Used as the label for OWSProgress.
public enum SecondaryLinkNSyncProgressPhase: String, OWSSequentialProgressStep {
    case waitingForBackup
    case downloadingBackup
    case importingBackup

    public var progressUnitCount: UInt64 {
        return switch self {
        case .waitingForBackup: 5
        case .downloadingBackup: 30
        case .importingBackup: 65
        }
    }
}

public protocol LinkAndSyncManager {

    /// **Call this on the primary device!**
    /// Generate an ephemeral backup key on a primary device to be used to link'n'sync a new linked device.
    /// This key should be included in the provisioning message and then used to encrypt the backup proto we send.
    ///
    /// - returns The ephemeral key to use, or nil if link'n'sync should not be used.
    func generateEphemeralBackupKey(aci: Aci) -> MessageRootBackupKey

    /// **Call this on the primary device!**
    /// Once the primary sends the provisioning message to the linked device, call this method
    /// to wait on the linked device to link, generate a backup, and upload it. Once this method returns,
    /// the primary's role is complete and the user can exit.
    ///
    /// Supports cancellation, but note that a network request may be made after
    /// cancellation has occured. Therefore, callers should expect to maybe wait
    /// after cancelling (and indicate this in the UI).
    func waitForLinkingAndUploadBackup(
        ephemeralBackupKey: MessageRootBackupKey,
        tokenId: DeviceProvisioningTokenId,
        progress: OWSSequentialProgressRootSink<PrimaryLinkNSyncProgressPhase>,
    ) async throws(PrimaryLinkNSyncError)

    /// **Call this on the secondary/linked device!**
    /// Once the secondary links on the server, call this method to wait on the primary
    /// to upload a backup, download that backup, and restore data from it.
    /// Once this method returns, provisioning can continue and finish.
    ///
    /// Supports cancellation.
    func waitForBackupAndRestore(
        localIdentifiers: LocalIdentifiers,
        auth: ChatServiceAuth,
        ephemeralBackupKey: MessageRootBackupKey,
        progress: OWSSequentialProgressRootSink<SecondaryLinkNSyncProgressPhase>,
    ) async throws
}

public class LinkAndSyncManagerImpl: LinkAndSyncManager {

    private let appContext: AppContext
    private let attachmentDownloadManager: AttachmentDownloadManager
    private let attachmentUploadManager: AttachmentUploadManager
    private let backupArchiveManager: BackupArchiveManager
    private let dateProvider: DateProvider
    private let db: any DB
    private let deviceSleepManager: (any DeviceSleepManager)?
    private let kvStore: KeyValueStore
    private let logger: PrefixedLogger
    private let messagePipelineSupervisor: MessagePipelineSupervisor
    private let networkManager: NetworkManager
    private let tsAccountManager: TSAccountManager

    public init(
        appContext: AppContext,
        attachmentDownloadManager: AttachmentDownloadManager,
        attachmentUploadManager: AttachmentUploadManager,
        backupArchiveManager: BackupArchiveManager,
        dateProvider: @escaping DateProvider,
        db: any DB,
        deviceSleepManager: (any DeviceSleepManager)?,
        messagePipelineSupervisor: MessagePipelineSupervisor,
        networkManager: NetworkManager,
        tsAccountManager: TSAccountManager,
    ) {
        self.appContext = appContext
        self.attachmentDownloadManager = attachmentDownloadManager
        self.attachmentUploadManager = attachmentUploadManager
        self.backupArchiveManager = backupArchiveManager
        self.dateProvider = dateProvider
        self.db = db
        self.deviceSleepManager = deviceSleepManager
        self.kvStore = KeyValueStore(collection: "LinkAndSyncManagerImpl")
        self.logger = PrefixedLogger(prefix: "[LNS]")
        self.messagePipelineSupervisor = messagePipelineSupervisor
        self.networkManager = networkManager
        self.tsAccountManager = tsAccountManager
    }

    public func generateEphemeralBackupKey(aci: Aci) -> MessageRootBackupKey {
        owsAssertDebug(tsAccountManager.registrationStateWithMaybeSneakyTransaction.isPrimaryDevice == true)
        return MessageRootBackupKey(
            backupKey: .generateRandom(),
            aci: aci,
        )
    }

    public func waitForLinkingAndUploadBackup(
        ephemeralBackupKey: MessageRootBackupKey,
        tokenId: DeviceProvisioningTokenId,
        progress: OWSSequentialProgressRootSink<PrimaryLinkNSyncProgressPhase>,
    ) async throws(PrimaryLinkNSyncError) {
        let registeredState: RegisteredState
        do throws(NotRegisteredError) {
            registeredState = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
        } catch {
            // TODO: Throw an error to indicate this failed because we're not registered.
            logger.warn("Couldn't wait for linking because we're no longer registered")
            return
        }
        owsPrecondition(registeredState.isPrimary, "Can't wait for linking unless we're a primary")

        let blockObject = DeviceSleepBlockObject(blockReason: Constants.sleepBlockingDescription)
        await deviceSleepManager?.addBlock(blockObject: blockObject)
        defer {
            Task {
                await deviceSleepManager?.removeBlock(blockObject: blockObject)
            }
        }

        do {
            try checkCancelledOrAppBackgrounded()
        } catch {
            logger.info("Cancelled!")
            throw .cancelled(linkedDeviceId: nil)
        }

        logger.info("Beginning link'n'sync")

        let waitForLinkResponse = try await waitForDeviceToLink(
            tokenId: tokenId,
            progress: progress.child(for: .waitingForLinking),
        )

        func handleCancellation() async {
            // If we cancel after linking, we want to let the
            // linked device know we've cancelled.
            try? await self.reportLinkNSyncBackupResultToServer(
                waitForDeviceToLinkResponse: waitForLinkResponse,
                result: .error(.relinkRequested),
                progress: progress.child(for: .finishing),
            )
        }

        do {
            try checkCancelledOrAppBackgrounded()
        } catch {
            await handleCancellation()
            throw .cancelled(linkedDeviceId: waitForLinkResponse.id)
        }

        let suspendHandler = messagePipelineSupervisor.suspendMessageProcessing(for: .linkNsync)
        defer { suspendHandler.invalidate() }

        do {
            try checkCancelledOrAppBackgrounded()
        } catch {
            await handleCancellation()
            throw .cancelled(linkedDeviceId: waitForLinkResponse.id)
        }

        let backupMetadata: Upload.EncryptedBackupUploadMetadata
        do {
            backupMetadata = try await generateBackup(
                waitForDeviceToLinkResponse: waitForLinkResponse,
                ephemeralBackupKey: ephemeralBackupKey,
                localIdentifiers: registeredState.localIdentifiers,
                progress: progress.child(for: .exportingBackup),
            )
        } catch let error {
            switch error {
            case .cancelled:
                await handleCancellation()
            default:
                // At time of writing, iOS _only_ uses the continueWithoutUpload error;
                // no backups errors succeed on retry and even if they did the user could
                // always themselves unlink and relink after they continue.
                try? await reportLinkNSyncBackupResultToServer(
                    waitForDeviceToLinkResponse: waitForLinkResponse,
                    result: .error(.continueWithoutUpload),
                    progress: progress.child(for: .finishing),
                )
            }
            throw error
        }

        let uploadResult: Upload.Result<Upload.LinkNSyncUploadMetadata>
        do {
            uploadResult = try await uploadEphemeralBackup(
                waitForDeviceToLinkResponse: waitForLinkResponse,
                metadata: backupMetadata,
                progress: progress.child(for: .uploadingBackup),
            )
        } catch let error {
            switch error {
            case .cancelled:
                await handleCancellation()
            default:
                break
            }
            throw error
        }

        try await reportLinkNSyncBackupResultToServer(
            waitForDeviceToLinkResponse: waitForLinkResponse,
            result: .success(cdnNumber: uploadResult.cdnNumber, cdnKey: uploadResult.cdnKey),
            progress: progress.child(for: .finishing),
        )
    }

    @MainActor
    public func waitForBackupAndRestore(
        localIdentifiers: LocalIdentifiers,
        auth: ChatServiceAuth,
        ephemeralBackupKey: MessageRootBackupKey,
        progress: OWSSequentialProgressRootSink<SecondaryLinkNSyncProgressPhase>,
    ) async throws {
        owsAssertDebug(tsAccountManager.registrationStateWithMaybeSneakyTransaction.isPrimaryDevice != true)

        let restoreState = db.read { backupArchiveManager.backupRestoreState(tx: $0) }
        switch restoreState {
        case .finalized:
            // Assume this was from a link'n'sync that was subsequently interrupted
            logger.info("Skipping link'n'sync; already restored from backup")
            return
        case .unfinalized:
            logger.info("Finalizing unfinished link'n'sync")
            let blockObject = DeviceSleepBlockObject(blockReason: Constants.sleepBlockingDescription)
            deviceSleepManager?.addBlock(blockObject: blockObject)
            defer { deviceSleepManager?.removeBlock(blockObject: blockObject) }

            // Immediately finish the first two progresses.
            _ = await progress.child(for: .waitingForBackup)
                .addSource(withLabel: "waitingForBackupSource", unitCount: 0)
            _ = await progress.child(for: .downloadingBackup)
                .addSource(withLabel: "downloadingBackupSource", unitCount: 0)

            do {
                try await backupArchiveManager.finalizeBackupImport(progress: progress.child(for: .importingBackup))
            } catch let error as CancellationError {
                throw error
            } catch {
                owsFailDebug("Unable to finalize link'n'sync backup restore: \(error)", logger: logger)
                throw SecondaryLinkNSyncError.errorRestoringBackup
            }
        case .none:
            break
        }

        let blockObject = DeviceSleepBlockObject(blockReason: Constants.sleepBlockingDescription)
        deviceSleepManager?.addBlock(blockObject: blockObject)
        defer { deviceSleepManager?.removeBlock(blockObject: blockObject) }

        try checkCancelledOrAppBackgrounded()

        let backupUploadResult = try await waitForPrimaryToUploadBackup(
            auth: auth,
            progress: progress.child(for: .waitingForBackup),
        )

        let cdnNumber: UInt32
        let cdnKey: String
        switch backupUploadResult {
        case let .success(_cdnNumber, _cdnKey):
            cdnNumber = _cdnNumber
            cdnKey = _cdnKey
        case .error(let errorResult):
            switch errorResult {
            case .continueWithoutUpload:
                throw SecondaryLinkNSyncError.primaryFailedBackupExport(continueWithoutSyncing: true)
            case .relinkRequested:
                throw SecondaryLinkNSyncError.primaryFailedBackupExport(continueWithoutSyncing: false)
            }
        }

        try checkCancelledOrAppBackgrounded()

        let downloadedFileUrl = try await downloadEphemeralBackup(
            cdnNumber: cdnNumber,
            cdnKey: cdnKey,
            ephemeralBackupKey: ephemeralBackupKey,
            progress: progress.child(for: .downloadingBackup),
        )

        try checkCancelledOrAppBackgrounded()

        try await restoreEphemeralBackup(
            fileUrl: downloadedFileUrl,
            localIdentifiers: localIdentifiers,
            ephemeralBackupKey: ephemeralBackupKey,
            progress: progress.child(for: .importingBackup),
            logger: logger,
        )
    }

    // MARK: Primary device steps

    private func waitForDeviceToLink(
        tokenId: DeviceProvisioningTokenId,
        progress: OWSProgressSink,
    ) async throws(PrimaryLinkNSyncError) -> Requests.WaitForDeviceToLinkResponse {
        let progressSource = await progress.addSource(
            withLabel: PrimaryLinkNSyncProgressPhase.waitingForLinking.rawValue,
            // Unit count is irrelevant as there's just one child source and we use a timer.
            unitCount: 100,
        )
        return try await progressSource.updatePeriodically(
            estimatedTimeToCompletion: 5,
            work: { () async throws(PrimaryLinkNSyncError) -> Requests.WaitForDeviceToLinkResponse in
                try await self._waitForDeviceToLink(tokenId: tokenId)
            },
        )
    }

    private func _waitForDeviceToLink(
        tokenId: DeviceProvisioningTokenId,
    ) async throws(PrimaryLinkNSyncError) -> Requests.WaitForDeviceToLinkResponse {
        logger.info("Waiting for device to link")
        var numNetworkErrors = 0
        whileLoop: while true {
            do {
                let response = try await networkManager.asyncRequest(
                    Requests.waitForDeviceToLink(tokenId: tokenId),
                )
                switch Requests.WaitForDeviceToLinkResponseCodes(rawValue: response.responseStatusCode) {
                case .success:
                    logger.info("Device linked!")
                    guard
                        let data = response.responseBodyData,
                        let response = try? JSONDecoder().decode(
                            Requests.WaitForDeviceToLinkResponse.self,
                            from: data,
                        )
                    else {
                        throw PrimaryLinkNSyncError.errorWaitingForLinkedDevice
                    }
                    return response
                case .timeout:
                    try checkCancelledOrAppBackgrounded()
                    // retry
                    continue whileLoop
                case .invalidParameters:
                    throw PrimaryLinkNSyncError.errorWaitingForLinkedDevice
                case .rateLimited:
                    try await Task.sleep(
                        nanoseconds: HTTPUtils.retryDelayNanoSeconds(response, defaultRetryTime: Constants.defaultRetryTime),
                    )
                    // retry
                    continue whileLoop
                case nil:
                    owsFailDebug("Unexpected response", logger: logger)
                    throw PrimaryLinkNSyncError.errorWaitingForLinkedDevice
                }
            } catch let error as PrimaryLinkNSyncError {
                throw error
            } catch is CancellationError {
                throw .cancelled(linkedDeviceId: nil)
            } catch {
                if error.isNetworkFailureOrTimeout {
                    numNetworkErrors += 1
                    if numNetworkErrors <= 3 {
                        // retry
                        continue
                    }
                }
                throw .errorWaitingForLinkedDevice
            }
        }
    }

    private func generateBackup(
        waitForDeviceToLinkResponse: Requests.WaitForDeviceToLinkResponse,
        ephemeralBackupKey: MessageRootBackupKey,
        localIdentifiers: LocalIdentifiers,
        progress: OWSProgressSink,
    ) async throws(PrimaryLinkNSyncError) -> Upload.EncryptedBackupUploadMetadata {
        do {
            let metadata = try await backupArchiveManager.exportEncryptedBackup(
                localIdentifiers: localIdentifiers,
                backupPurpose: .linkNsync(ephemeralKey: ephemeralBackupKey.backupKey, aci: localIdentifiers.aci),
                progress: progress,
                logger: logger,
            )
            return metadata
        } catch let error {
            if error is CancellationError {
                throw .cancelled(linkedDeviceId: waitForDeviceToLinkResponse.id)
            }
            owsFailDebug("Unable to generate link'n'sync backup: \(error)", logger: logger)
            throw .errorGeneratingBackup
        }
    }

    private func uploadEphemeralBackup(
        waitForDeviceToLinkResponse: Requests.WaitForDeviceToLinkResponse,
        metadata: Upload.EncryptedBackupUploadMetadata,
        progress: OWSProgressSink,
    ) async throws(PrimaryLinkNSyncError) -> Upload.Result<Upload.LinkNSyncUploadMetadata> {
        do {
            return try await attachmentUploadManager.uploadLinkNSyncAttachment(
                dataSource: DataSourcePath(fileUrl: metadata.fileUrl, ownership: .owned),
                progress: progress,
            )
        } catch {
            if error is CancellationError {
                throw .cancelled(linkedDeviceId: waitForDeviceToLinkResponse.id)
            } else {
                throw .errorUploadingBackup(PrimaryLinkNSyncErrorRetryHandler(
                    waitForDeviceToLinkResponse: waitForDeviceToLinkResponse,
                    linkNSyncManager: self,
                ))
            }
        }
    }

    private func reportLinkNSyncBackupResultToServer(
        waitForDeviceToLinkResponse: Requests.WaitForDeviceToLinkResponse,
        result: Requests.ExportAndUploadBackupResult,
        progress: OWSProgressSink,
    ) async throws(PrimaryLinkNSyncError) -> Void {
        // Do this in a detachedtask; we want to report a status
        // to the server even if the user cancels the current task.
        let task = Task.detached(priority: Task.currentPriority) {
            let progressSource = await progress.addSource(
                withLabel: PrimaryLinkNSyncProgressPhase.finishing.rawValue,
                // Unit count is irrelevant as there's just one child source and we use a timer.
                unitCount: 100,
            )
            return try await progressSource.updatePeriodically(
                estimatedTimeToCompletion: 3,
                work: { () async throws(PrimaryLinkNSyncError) -> Void in
                    try await self._markEphemeralBackupUploaded(
                        waitForDeviceToLinkResponse: waitForDeviceToLinkResponse,
                        result: result,
                    )
                },
            )
        }
        // Task.detached doesn't support typed errors until iOS 18;
        // we have to manually unwrap.
        do {
            try await task.value
        } catch let error as PrimaryLinkNSyncError {
            throw error
        } catch {
            owsFailDebug("Invalid error!", logger: logger)
            throw .errorMarkingBackupUploaded(PrimaryLinkNSyncErrorRetryHandler(
                waitForDeviceToLinkResponse: waitForDeviceToLinkResponse,
                linkNSyncManager: self,
            ))
        }
    }

    private func _markEphemeralBackupUploaded(
        waitForDeviceToLinkResponse: Requests.WaitForDeviceToLinkResponse,
        result: Requests.ExportAndUploadBackupResult,
    ) async throws(PrimaryLinkNSyncError) -> Void {
        do {
            let response = try await networkManager.asyncRequest(
                Requests.reportLinkNSyncBackupResultToServer(
                    waitForDeviceToLinkResponse: waitForDeviceToLinkResponse,
                    result: result,
                ),
            )

            guard response.responseStatusCode == 204 || response.responseStatusCode == 200 else {
                throw PrimaryLinkNSyncError.errorMarkingBackupUploaded(PrimaryLinkNSyncErrorRetryHandler(
                    waitForDeviceToLinkResponse: waitForDeviceToLinkResponse,
                    linkNSyncManager: self,
                ))
            }
        } catch let error {
            if error is CancellationError {
                throw .cancelled(linkedDeviceId: waitForDeviceToLinkResponse.id)
            } else {
                throw .errorMarkingBackupUploaded(PrimaryLinkNSyncErrorRetryHandler(
                    waitForDeviceToLinkResponse: waitForDeviceToLinkResponse,
                    linkNSyncManager: self,
                ))
            }
        }
    }

    private final class PrimaryLinkNSyncErrorRetryHandler: PrimaryLinkNSyncError.RetryHandler {

        let waitForDeviceToLinkResponse: Requests.WaitForDeviceToLinkResponse
        let linkNSyncManager: LinkAndSyncManagerImpl

        init(
            waitForDeviceToLinkResponse: Requests.WaitForDeviceToLinkResponse,
            linkNSyncManager: LinkAndSyncManagerImpl,
        ) {
            self.waitForDeviceToLinkResponse = waitForDeviceToLinkResponse
            self.linkNSyncManager = linkNSyncManager
        }

        func tryToResetLinkedDevice() async {
            try? await linkNSyncManager._markEphemeralBackupUploaded(
                waitForDeviceToLinkResponse: waitForDeviceToLinkResponse,
                result: .error(.relinkRequested),
            )
        }

        func tryToContinueWithoutSyncing() async {
            try? await linkNSyncManager._markEphemeralBackupUploaded(
                waitForDeviceToLinkResponse: waitForDeviceToLinkResponse,
                result: .error(.continueWithoutUpload),
            )
        }
    }

    // MARK: Linked device steps

    private func waitForPrimaryToUploadBackup(
        auth: ChatServiceAuth,
        progress: OWSProgressSink,
    ) async throws -> Requests.ExportAndUploadBackupResult {
        let progressSource = await progress.addSource(
            withLabel: SecondaryLinkNSyncProgressPhase.waitingForBackup.rawValue,
            // Unit count is irrelevant as there's just one child source and we use a timer.
            unitCount: 100,
        )
        return try await progressSource.updatePeriodically(
            estimatedTimeToCompletion: 40,
            work: { () async throws -> Requests.ExportAndUploadBackupResult in
                try await self._waitForPrimaryToUploadBackup(auth: auth)
            },
        )
    }

    private func _waitForPrimaryToUploadBackup(
        auth: ChatServiceAuth,
    ) async throws -> Requests.ExportAndUploadBackupResult {
        return try await Retry.performWithBackoff(
            maxAttempts: 4,
            preferredBackoffBlock: { $0.httpResponseHeaders?.retryAfterTimeInterval },
            isRetryable: { $0.isNetworkFailureOrTimeout || $0.httpStatusCode == 429 },
        ) { () async throws -> Requests.ExportAndUploadBackupResult in
            while true {
                let startDate = MonotonicDate()
                try checkCancelledOrAppBackgrounded()
                let response = try await networkManager.asyncRequest(Requests.waitForLinkNSyncBackupUpload(auth: auth))
                switch Requests.WaitForLinkNSyncBackupUploadResponseCodes(rawValue: response.responseStatusCode) {
                case .success:
                    let rawResponse = try JSONDecoder().decode(
                        Requests.WaitForLinkNSyncBackupUploadRawResponse.self,
                        from: response.responseBodyData ?? Data(),
                    )
                    if let cdnNumber = rawResponse.cdn, let cdnKey = rawResponse.key {
                        return .success(cdnNumber: cdnNumber, cdnKey: cdnKey)
                    }
                    if let error = rawResponse.error {
                        return .error(error)
                    }
                    owsFailDebug("Unexpected server response!", logger: logger)
                    return .error(.continueWithoutUpload)
                case .timeout:
                    let elapsedTime = (MonotonicDate() - startDate).seconds
                    // Avoid tight loops by waiting for a minimum delay before retrying.
                    assert(Constants.longPollRequestTimeoutSeconds >= 60)
                    try await Task.sleep(nanoseconds: (60 - elapsedTime).clampedNanoseconds)
                    continue
                case nil:
                    throw response.asError()
                }
            }
        }
    }

    private func downloadEphemeralBackup(
        cdnNumber: UInt32,
        cdnKey: String,
        ephemeralBackupKey: MessageRootBackupKey,
        progress: OWSProgressSink,
    ) async throws -> URL {
        return try await attachmentDownloadManager.downloadTransientAttachment(
            metadata: AttachmentDownloads.DownloadMetadata(
                mimeType: MimeType.applicationOctetStream.rawValue,
                cdnNumber: cdnNumber,
                encryptionKey: ephemeralBackupKey.serialize(),
                source: .linkNSyncBackup(cdnKey: cdnKey),
            ),
            progress: progress,
        )
    }

    private func restoreEphemeralBackup(
        fileUrl: URL,
        localIdentifiers: LocalIdentifiers,
        ephemeralBackupKey: MessageRootBackupKey,
        progress: OWSProgressSink,
        logger: PrefixedLogger,
    ) async throws {
        do {
            try await backupArchiveManager.importEncryptedBackup(
                fileUrl: fileUrl,
                localIdentifiers: localIdentifiers,
                isPrimaryDevice: false,
                source: .linkNsync(ephemeralKey: ephemeralBackupKey.backupKey, aci: localIdentifiers.aci),
                progress: progress,
                logger: logger,
            )
        } catch {
            logger.warn("Unable to restore link'n'sync backup: \(error)")
            switch error {
            case BackupImportError.unsupportedVersion:
                throw error
            default:
                throw SecondaryLinkNSyncError.errorRestoringBackup
            }
        }
    }

    fileprivate enum Constants {
        static let sleepBlockingDescription = "Link'n'Sync"

        static let enabledOnPrimaryKey = "enabledOnPrimaryKey"

        static let longPollRequestTimeoutSeconds: UInt32 = 60 * 5
        static let defaultRetryTime: TimeInterval = 15
    }

    // MARK: - Helpers

    private func checkCancelledOrAppBackgrounded() throws {
        guard appContext.isAppForegroundAndActive() else {
            throw CancellationError()
        }
        try Task.checkCancellation()
    }

    // MARK: - Requests

    private enum Requests {

        struct WaitForDeviceToLinkResponse: Codable {
            /// The deviceId of the linked device
            let id: DeviceId
            /// The name of the linked device.
            let name: String
            /// The timestamp the linked device was last seen on the server.
            let lastSeen: UInt64
            /// The registration ID of the linked device.
            let registrationId: UInt32
        }

        enum WaitForDeviceToLinkResponseCodes: Int {
            case success = 200
            /// The timeout elapsed without the device linking; clients can request again.
            case timeout = 204
            case invalidParameters = 400
            case rateLimited = 429
        }

        static func waitForDeviceToLink(
            tokenId: DeviceProvisioningTokenId,
        ) -> TSRequest {
            var urlComponents = URLComponents(string: "v1/devices/wait_for_linked_device/\(tokenId.id)")!
            urlComponents.queryItems = [URLQueryItem(
                name: "timeout",
                value: "\(LinkAndSyncManagerImpl.Constants.longPollRequestTimeoutSeconds)",
            )]
            var request = TSRequest(
                url: urlComponents.url!,
                method: "GET",
                parameters: nil,
            )
            request.applyRedactionStrategy(.redactURL())
            // The timeout is server side; apply wiggle room for our local clock.
            request.timeoutInterval = 10 + TimeInterval(Constants.longPollRequestTimeoutSeconds)
            return request
        }

        enum ExportErrorType: String, Codable {
            /// The primary requests the linked device restart the linking process.
            case relinkRequested = "RELINK_REQUESTED"
            /// The primary experienced an unretryable error and wants the linked device
            /// continue without restoring from a backup.
            case continueWithoutUpload = "CONTINUE_WITHOUT_UPLOAD"
        }

        enum ExportAndUploadBackupResult {
            case success(cdnNumber: UInt32, cdnKey: String)
            case error(ExportErrorType)
        }

        static func reportLinkNSyncBackupResultToServer(
            waitForDeviceToLinkResponse: WaitForDeviceToLinkResponse,
            result: ExportAndUploadBackupResult,
        ) -> TSRequest {
            var request = TSRequest(
                url: URL(string: "v1/devices/transfer_archive")!,
                method: "PUT",
                parameters: [
                    "destinationDeviceId": waitForDeviceToLinkResponse.id.uint32Value,
                    "destinationDeviceRegistrationId": waitForDeviceToLinkResponse.registrationId,
                    "transferArchive": {
                        switch result {
                        case .success(let cdnNumber, let cdnKey):
                            return [
                                "cdn": cdnNumber,
                                "key": cdnKey,
                            ]
                        case .error(let exportErrorType):
                            return [
                                "error": exportErrorType.rawValue,
                            ]
                        }
                    }(),
                ],
            )
            request.applyRedactionStrategy(.redactURL())
            return request
        }

        struct WaitForLinkNSyncBackupUploadRawResponse: Codable {
            /// The cdn number
            let cdn: UInt32?
            /// The cdn key
            let key: String?
            let error: ExportErrorType?
        }

        enum WaitForLinkNSyncBackupUploadResponseCodes: Int {
            case success = 200
            /// The timeout elapsed without any upload; clients can request again.
            case timeout = 204
        }

        static func waitForLinkNSyncBackupUpload(auth: ChatServiceAuth) -> TSRequest {
            var urlComponents = URLComponents(string: "v1/devices/transfer_archive")!
            urlComponents.queryItems = [URLQueryItem(
                name: "timeout",
                value: "\(Constants.longPollRequestTimeoutSeconds)",
            )]
            var request = TSRequest(
                url: urlComponents.url!,
                method: "GET",
                parameters: nil,
            )
            request.auth = .identified(auth)
            request.applyRedactionStrategy(.redactURL())
            // The timeout is server side; apply wiggle room for our local clock.
            request.timeoutInterval = 10 + TimeInterval(Constants.longPollRequestTimeoutSeconds)
            return request
        }
    }
}