Path: blob/main/Signal/DeviceTransfer/DeviceTransferStatusViewController.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Lottie
import SignalServiceKit
import SignalUI
import SwiftUI
// MARK: - DeviceTransferStatusViewController
class DeviceTransferStatusViewController: HostingController<TransferStatusView> {
override var prefersNavigationBarHidden: Bool { true }
private let coordinator: DeviceTransferCoordinator
init(coordinator: DeviceTransferCoordinator) {
self.coordinator = coordinator
super.init(wrappedView: TransferStatusView(viewModel: coordinator.transferStatusViewModel, isNewDevice: true))
coordinator.confirmCancellation = { [weak self] in
guard let self else { return true }
return await self.confirmCancellation()
}
coordinator.onSuccess = { [weak self] in
let sheet = HeroSheetViewController(
hero: .image(UIImage(named: "transfer_complete")!),
title: OWSLocalizedString(
"TRANSFER_COMPLETE_SHEET_TITLE",
comment: "Title for bottom sheet shown when device transfer completes on the receiving device.",
),
body: OWSLocalizedString(
"TRANSFER_COMPLETE_SHEET_SUBTITLE",
comment: "Subtitle for bottom sheet shown when device transfer completes on the receiving device.",
),
primaryButton: .init(
title: CommonStrings.okayButton,
) { _ in
Task {
SSKEnvironment.shared.notificationPresenterRef.notifyUserToRelaunchAfterTransfer {
Logger.info("Deliberately terminating app post-transfer.")
exit(0)
}
}
self?.dismiss(animated: true)
},
)
self?.present(sheet, animated: true)
}
}
@MainActor
func confirmCancellation() async -> Bool {
Logger.info("")
return await withCheckedContinuation { continuation in
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"DEVICE_TRANSFER_CANCEL_CONFIRMATION_TITLE",
comment: "The title of the dialog asking the user if they want to cancel a device transfer",
),
message: OWSLocalizedString(
"DEVICE_TRANSFER_CANCEL_CONFIRMATION_MESSAGE",
comment: "The message of the dialog asking the user if they want to cancel a device transfer",
),
)
let okAction = ActionSheetAction(
title: OWSLocalizedString(
"DEVICE_TRANSFER_CANCEL_CONFIRMATION_ACTION",
comment: "The stop action of the dialog asking the user if they want to cancel a device transfer",
),
style: .destructive,
) { _ in
continuation.resume(returning: true)
}
actionSheet.addAction(okAction)
let cancelAction = ActionSheetAction(
title: CommonStrings.cancelButton,
style: .cancel,
) { _ in
continuation.resume(returning: false)
}
actionSheet.addAction(cancelAction)
present(actionSheet, animated: true)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Task {
do {
try await coordinator.start()
} catch {
coordinator.onFailure(error)
}
}
}
}
struct TransferStatusView: View {
@ObservedObject var viewModel: TransferStatusViewModel
var isNewDevice: Bool
var body: some View {
VStack(spacing: 10) {
switch viewModel.viewState {
case .indefinite(let indefinite):
Spacer()
// The indefinite states are combined into the same view state
// to maintain the LottieView's identity and prevent the
// animation from restarting when the state changes.
LottieView(animation: .named("circular_indeterminate"))
.playing(loopMode: .loop)
.padding(.bottom, 14)
Text(indefinite.title(isNewDevice: isNewDevice))
.font(.body.bold())
.foregroundStyle(Color.Signal.label)
Text(indefinite.message(isNewDevice: isNewDevice))
.font(.body)
.foregroundStyle(Color.Signal.secondaryLabel)
Spacer()
Button(CommonStrings.cancelButton) {
Task {
await viewModel.propmtUserToCancelTransfer()
}
}
.buttonStyle(Registration.UI.MediumSecondaryButtonStyle())
.padding(.bottom, 32)
case .transferring(let progress):
Text(OWSLocalizedString(
"DEVICE_TRANSFER_STATUS_NEW_DEVICE_TRANSFERRING",
comment: "Title for a progress view displayed during device transfer.",
))
.font(.title2.weight(.semibold))
.foregroundStyle(Color.Signal.label)
.padding(.top, 44)
.padding(.bottom, 2)
Text(OWSLocalizedString(
"DEVICE_TRANSFER_STATUS_NEW_DEVICE_TRANSFERRING_DESCRIPTION",
comment: "Description in the progress view displayed during device transfer.",
))
.font(.body)
.foregroundStyle(Color.Signal.secondaryLabel)
Spacer()
Text("\(progress.formatted(.owsPercent()))")
.font(.body.monospacedDigit())
.padding(.bottom, 12)
LinearProgressView(progress: progress)
.animation(.smooth, value: progress)
.padding(.bottom, 6)
Text(viewModel.progressEstimateLabel)
.foregroundStyle(Color.Signal.secondaryLabel)
Spacer()
Button(CommonStrings.cancelButton) {
Task {
await viewModel.propmtUserToCancelTransfer()
}
}
.buttonStyle(Registration.UI.MediumSecondaryButtonStyle())
.padding(.bottom, 32)
case .error(let error):
Text(OWSLocalizedString(
"DEVICE_TRANSFER_STATUS_NEW_DEVICE_TRANSFER_FAILED_TITLE",
comment: "Title for a progress view displayed after failure of device transfer.",
))
.font(.title2.weight(.semibold))
.foregroundStyle(Color.Signal.label)
.padding(.top, 44)
.padding(.bottom, 2)
Text(OWSLocalizedString(
"DEVICE_TRANSFER_STATUS_NEW_DEVICE_TRANSFER_FAILED_BODY",
comment: "Description in the progress view displayed after failure of device transfer.",
))
.font(.body)
.foregroundStyle(Color.Signal.secondaryLabel)
Spacer()
Button(OWSLocalizedString(
"DEVICE_TRANSFER_TRY_AGAIN_ACTION",
comment: "Action asking user to try again after transfer failure.",
)) {
viewModel.onFailure(error)
}
.buttonStyle(Registration.UI.MediumSecondaryButtonStyle())
.padding(.bottom, 32)
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal)
.multilineTextAlignment(.center)
}
}
// MARK: - Previews
#if DEBUG
@available(iOS 17, *)
#Preview {
let viewModel = TransferStatusViewModel()
viewModel.cancelTransferBlock = { print("onCancel") }
viewModel.onSuccess = { print("onSuccess") }
var task = Task {
try? await viewModel.simulateProgressForPreviews()
}
return TransferStatusView(viewModel: viewModel, isNewDevice: true)
.overlay(alignment: .bottom) {
Button(LocalizationNotNeeded("PREVIEW: Restart")) {
task.cancel()
task = Task {
try? await viewModel.simulateProgressForPreviews()
}
}
.foregroundStyle(Color(UIColor.red))
.opacity(0.7)
}
}
#endif