// // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import SignalServiceKit enum TransferState { case idle case starting case connecting case transferring(Double) case done case cancelled case error(DeviceTransferService.Error) } class TransferStatusViewModel: ObservableObject { enum ViewState { enum Indefinite { case starting case connecting case cancelling func title(isNewDevice: Bool) -> String { switch self { case .starting: if isNewDevice { OWSLocalizedString( "DEVICE_TRANSFER_STATUS_NEW_DEVICE_STARTING", comment: "Status message on new device when transfer is starting." ) } else { OWSLocalizedString( "DEVICE_TRANSFER_STATUS_OLD_DEVICE_STARTING", comment: "Status message on old device when transfer is starting." ) } case .connecting: if isNewDevice { OWSLocalizedString( "DEVICE_TRANSFER_STATUS_NEW_DEVICE_CONNECTING", comment: "Status message on new device when connecting to old device." ) } else { OWSLocalizedString( "DEVICE_TRANSFER_STATUS_OLD_DEVICE_CONNECTING", comment: "Status message on new device when connecting to new device." ) } case .cancelling: if isNewDevice { OWSLocalizedString( "DEVICE_TRANSFER_STATUS_NEW_DEVICE_CANCELLING", comment: "Status message on new device when cancelling transfer." ) } else { OWSLocalizedString( "DEVICE_TRANSFER_STATUS_OLD_DEVICE_CANCELLING", comment: "Status message on old device when cancelling transfer." ) } } } func message(isNewDevice: Bool) -> String { if isNewDevice { OWSLocalizedString( "DEVICE_TRANSFER_STATUS_NEW_DEVICE_CONNECTING_MESSAGE", comment: "Description message on new device displayed during device transfer." ) } else { OWSLocalizedString( "DEVICE_TRANSFER_STATUS_OLD_DEVICE_CONNECTING_MESSAGE", comment: "Description message on old device displayed during device transfer." ) } } } case indefinite(Indefinite) case transferring(Double) case error(Error) } @Published var viewState: ViewState = .indefinite(.starting) var state: TransferState = .idle { didSet { switch state { case .idle, .starting: viewState = .indefinite(.starting) case .connecting: viewState = .indefinite(.connecting) case .transferring(let progress): viewState = .transferring(progress) self.progressDidUpdate(currentProgress: progress) case .done: viewState = .transferring(1) case .cancelled: viewState = .indefinite(.cancelling) case .error(let error): viewState = .error(error) return } } } var onCancel: (() -> Void) = {} var onSuccess: (() -> Void) = {} // Take up space so it doesn't pop in when appearing @Published var progressEstimateLabel = " " private var throughputTimer: Timer? private var throughput: Double? private func progressDidUpdate(currentProgress: Double) { guard throughputTimer == nil else { return } var previouslyCompletedPortion = currentProgress throughputTimer = Timer.scheduledTimer( withTimeInterval: 1, repeats: true ) { [weak self] timer in guard let self, case let .transferring(progress) = self.state else { self?.throughput = nil self?.progressEstimateLabel = " " timer.invalidate() self?.throughputTimer = nil return } let progressOverLastSecond = progress - previouslyCompletedPortion let remainingPortion = 1 - progress previouslyCompletedPortion = progress let estimatedTimeRemaining: TimeInterval if let throughput { // Give more weight to the existing average than the new value // to smooth changes in throughput and estimated time remaining. let newAverageThroughput = 0.2 * progressOverLastSecond + 0.8 * throughput self.throughput = newAverageThroughput estimatedTimeRemaining = remainingPortion / newAverageThroughput } else { self.throughput = progressOverLastSecond estimatedTimeRemaining = remainingPortion / progressOverLastSecond } self.progressEstimateLabel = timeEstimateFormatter.string(from: estimatedTimeRemaining) ?? " " } } private let timeEstimateFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute, .second] formatter.unitsStyle = .full formatter.maximumUnitCount = 1 formatter.includesApproximationPhrase = true formatter.includesTimeRemainingPhrase = true return formatter }() } #if DEBUG extension TransferStatusViewModel { @MainActor func simulateProgressForPreviews() async throws { state = .starting try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) state = .connecting try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) state = .transferring(0) var progress: Double = 0 while progress < 1 { progress += 0.011 state = .transferring(progress) try await Task.sleep(nanoseconds: UInt64.random(in: 60...120) * NSEC_PER_MSEC) } state = .done onSuccess() } } #endif