809 lines
32 KiB
Swift
809 lines
32 KiB
Swift
//
|
|
// 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,
|
|
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,
|
|
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
|
|
}
|
|
}
|
|
}
|