Path: blob/main/Signal/QuickRestore/OutgoingDeviceRestoreViewModel.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import MultipeerConnectivity
import SignalServiceKit
enum DeviceRestoreError: Error {
case invalidRestoreData
case restoreCancelled
case unknownError
}
class OutgoingDeviceRestoreViewModel: ObservableObject, DeviceTransferServiceObserver {
struct RestoreMethodData {
struct PeerConnectionData {
var peerId: MCPeerID
var certificateHash: Data
}
let restoreMethod: QuickRestoreManager.RestoreMethodType
let peerConnectionData: PeerConnectionData?
fileprivate init(restoreMethod: QuickRestoreManager.RestoreMethodType, peerConnectionData: PeerConnectionData?) {
self.restoreMethod = restoreMethod
self.peerConnectionData = peerConnectionData
}
}
private(set) var transferStatusViewModel = TransferStatusViewModel()
private let deviceTransferService: DeviceTransferService
private let quickRestoreManager: QuickRestoreManager
private let provisioningURL: DeviceProvisioningURL
private var deviceConnectedContinuation: AtomicValue<(
continuation: CheckedContinuation<Void, Never>,
peerConnectionData: RestoreMethodData.PeerConnectionData
)?> = AtomicValue(nil, lock: .init())
private var finishTransferContinuation: AtomicValue<
CheckedContinuation<Bool, Never>?,
> = AtomicValue(nil, lock: .init())
init(
deviceTransferService: DeviceTransferService,
quickRestoreManager: QuickRestoreManager,
deviceProvisioningURL: DeviceProvisioningURL,
) {
self.deviceTransferService = deviceTransferService
self.quickRestoreManager = quickRestoreManager
self.provisioningURL = deviceProvisioningURL
transferStatusViewModel.cancelTransferBlock = { [weak self] in
self?.cancelTransfer()
}
}
func confirmTransfer() async -> Bool {
return await LocalDeviceAuthentication().performBiometricAuth() != nil
}
/// This uses the QuickRestore path behind the scenes to bootstrap a device transfer between two devices.
/// 1. Outgoing device scans the QR code, then sends a RegistrationProvisioningMessage to the device that displayed the QR.
/// 2. Outgoing device will wait for the restore method choice from the other device.
/// 3. Confirm the returned choice is 'device transfer' or fail.
/// 4. Parse out the MPC connection information returned in the restore method choice, and return this connection data
func waitForRestoreMethodResponse() async throws(DeviceRestoreError) -> RestoreMethodData {
let restoreMethod: QuickRestoreManager.RestoreMethodType
do {
let token = try await quickRestoreManager.register(deviceProvisioningUrl: provisioningURL)
restoreMethod = try await quickRestoreManager.waitForRestoreMethodChoice(restoreMethodToken: token)
} catch {
Logger.error("Failed to wait for restore method choice: \(error)")
throw DeviceRestoreError.invalidRestoreData
}
guard case let .deviceTransfer(transferData) = restoreMethod else {
return RestoreMethodData(restoreMethod: restoreMethod, peerConnectionData: nil)
}
guard
let stringData = Data(base64EncodedWithoutPadding: transferData),
let urlString = String(data: stringData, encoding: .utf8),
let transferURL = URL(string: urlString)
else {
Logger.error("Attempting to restore using a method other than device transfer")
throw DeviceRestoreError.invalidRestoreData
}
do {
let (peerId, certificateHash) = try deviceTransferService.parseTransferURL(transferURL)
return RestoreMethodData(
restoreMethod: restoreMethod,
peerConnectionData: RestoreMethodData.PeerConnectionData(
peerId: peerId,
certificateHash: certificateHash,
),
)
} catch {
Logger.error("Failed to parse transfer URL: \(error)")
throw DeviceRestoreError.invalidRestoreData
}
}
/// Take the `PeerConnectionData` returned by `waitForConnectionData` and
/// begin listening for the connection described in `PeerConnectionData`.
func waitForDeviceConnection(peerConnectionData: RestoreMethodData.PeerConnectionData) async {
// If in any state but .idle, return
guard case .idle = transferStatusViewModel.state else {
return
}
return await withCheckedContinuation { continuation in
// Update with "Waiting to connect to new iPhone" message
deviceConnectedContinuation.update { existingContinuation in
transferStatusViewModel.state = .starting
existingContinuation = (continuation, peerConnectionData)
}
deviceTransferService.startListeningForNewDevices()
deviceTransferService.addObserver(self)
}
}
/// Once connected to the device described in `PeerConnectionData`
/// begin a device transfer.
func startTransfer(peerConnectionData: RestoreMethodData.PeerConnectionData) throws {
do {
try deviceTransferService.transferAccountToNewDevice(
with: peerConnectionData.peerId,
certificateHash: peerConnectionData.certificateHash,
)
} catch {
stopListeningForTransfer()
Logger.error("Failed transfer to new device")
throw error
}
}
func waitForTransferCompletion() async -> Bool {
return await withCheckedContinuation { continuation in
self.finishTransferContinuation.update {
switch transferStatusViewModel.state {
case .done:
// If the transfer is finished, just return
continuation.resume(returning: true)
return
case .cancelled:
continuation.resume(returning: false)
return
case .error(let error):
Logger.warn("Transfer failed: \(error)")
continuation.resume(returning: false)
case .connecting, .idle, .starting, .transferring:
break
}
$0 = continuation
}
}
}
private func cancelTransfer() {
stopListeningForTransfer()
transferStatusViewModel.state = .cancelled
deviceTransferService.cancelTransferFromOldDevice()
deviceTransferService.stopTransfer()
let continuation = finishTransferContinuation.swap(nil)
continuation?.resume(returning: false)
}
private func stopListeningForTransfer() {
deviceTransferService.removeObserver(self)
deviceTransferService.stopListeningForNewDevices()
}
func deviceTransferServiceDiscoveredNewDevice(peerId: MCPeerID, discoveryInfo: [String: String]?) {
deviceConnectedContinuation.update { existingContinuation in
guard peerId == existingContinuation?.peerConnectionData.peerId else {
// Don't resume the continuation if we got a notification for a different peerId
return
}
transferStatusViewModel.state = .connecting
existingContinuation?.continuation.resume()
// Successfully discovered the expected peer, clear the continuation
existingContinuation = nil
}
}
private var progressObserver: NSKeyValueObservation?
func deviceTransferServiceDidStartTransfer(progress: Progress) {
self.progressObserver = progress.observe(\.fractionCompleted, options: [.new]) { [weak self] _, change in
Task { @MainActor in
let newValue = change.newValue ?? 0
self?.transferStatusViewModel.state = .transferring(newValue)
}
}
}
func deviceTransferServiceDidEndTransfer(error: DeviceTransferService.Error?) {
stopListeningForTransfer()
finishTransferContinuation.update { continuation in
if let error {
transferStatusViewModel.state = .error(error)
continuation?.resume(returning: false)
} else {
transferStatusViewModel.state = .done
continuation?.resume(returning: true)
}
continuation = nil
}
}
func deviceTransferServiceDidRequestAppRelaunch() { }
}