// // 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, ) 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, ) 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, ) 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 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, ) 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, 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 { 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, progress: OWSProgressSink, ) async throws -> URL { return try await attachmentDownloadManager.downloadEncryptedTransientAttachment( downloadMetadata: AttachmentDownloads.DownloadMetadata( cdnNumber: cdnNumber, source: .transitTier(cdnKey: cdnKey), ), expectedDownloadSize: nil, 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 } } }