Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/AppSettings/Linked Devices/LinkedDevicesView.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Combine
import LibSignalClient
import LocalAuthentication
import SignalServiceKit
import SignalUI
import SwiftUI

// This has a long and awful name so that if the condition is ever changed,
// the text shown to internal users about it can be changed too.
private var shouldShowDeviceIdsBecauseUserIsInternal: Bool { DebugFlags.internalSettings }

// MARK: - LinkedDevicesViewModel

@MainActor
class LinkedDevicesViewModel: ObservableObject {

    @Published fileprivate var editMode: EditMode = .inactive
    @Published fileprivate var displayableDevices: [DisplayableDevice] = []
    @Published fileprivate var isLoading: Bool = false

    fileprivate enum Presentation {
        case newDeviceToast(deviceName: String, didSync: Bool)
        case linkDeviceAuthentication
        case renameDevice(displayableDevice: DisplayableDevice)
        case unlinkDeviceConfirmation(displayableDevice: DisplayableDevice)
        case updateFailureAlert(Error)
        case unlinkFailureAlert(device: OWSDevice, error: Error)
        case activityIndicator(UIViewController)
        case linkedDeviceEducation
        case linkAndSyncFailureAlert(PrimaryLinkNSyncError)
    }

    fileprivate var present = PassthroughSubject<Presentation, Never>()

    private enum NewDeviceExpectation {
        case link
        case linkAndSync
    }

    private var subscriptions = Set<AnyCancellable>()
    private var pollingRefreshTimer: Timer?
    private var oldDeviceList: [DisplayableDevice] = []
    private var newDeviceExpectation: NewDeviceExpectation? {
        didSet {
            shouldShowFinishLinkingSheet = newDeviceExpectation != nil
        }
    }

    private var deviceIdToIgnore: DeviceId?
    fileprivate var shouldShowFinishLinkingSheet = false

    private let backupArchiveErrorPresenter: BackupArchiveErrorPresenter
    private let databaseChangeObserver: DatabaseChangeObserver
    private let db: any DB
    private let deviceService: OWSDeviceService
    private let deviceStore: OWSDeviceStore
    private let identityManager: OWSIdentityManager

#if DEBUG
    private let isPreview: Bool
#endif

    init(isPreview: Bool = false) {
#if DEBUG
        self.isPreview = isPreview
#endif
        backupArchiveErrorPresenter = DependenciesBridge.shared.backupArchiveErrorPresenter
        databaseChangeObserver = DependenciesBridge.shared.databaseChangeObserver
        db = DependenciesBridge.shared.db
        deviceService = DependenciesBridge.shared.deviceService
        deviceStore = DependenciesBridge.shared.deviceStore
        identityManager = DependenciesBridge.shared.identityManager

        databaseChangeObserver.appendDatabaseChangeDelegate(self)
    }

    func refreshDevices() async {
        if displayableDevices.isEmpty {
            self.isLoading = true
        }

#if DEBUG
        if isPreview {
            try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
            withAnimation {
                self.displayableDevices = [
                    .init(device: .previewItem(id: DeviceId(validating: 1)!, name: "iPad")),
                    .init(device: .previewItem(id: DeviceId(validating: 2)!, name: "macOS")),
                ]
            }
            self.isLoading = false
            return
        }
#endif

        do {
            let didAddOrRemove = try await deviceService.refreshDevices()

            if didAddOrRemove {
                pollingRefreshTimer?.invalidate()
                pollingRefreshTimer = nil
            }
        } catch let error where error.isNetworkFailureOrTimeout {
            // Ignore
        } catch let error {
            present.send(.updateFailureAlert(error))
        }

        if newDeviceExpectation == nil {
            self.isLoading = false
        }
    }

    private func updateDeviceList() {
        var displayableDevices = db.read { transaction -> [DisplayableDevice] in
            return deviceStore.fetchAll(tx: transaction)
                .filter { !$0.deviceId.isPrimary }
                .map { DisplayableDevice(device: $0) }
        }

        if let deviceIdToIgnore {
            displayableDevices.removeAll { device in
                return device.id == deviceIdToIgnore
            }
        }

        displayableDevices.sort { lhs, rhs in
            lhs.device.createdAt < rhs.device.createdAt
        }

        if oldDeviceList != displayableDevices {
            if
                let newDeviceExpectation,
                let newDevice = displayableDevices.last,
                newDevice != oldDeviceList.last
            {
                present.send(.newDeviceToast(
                    deviceName: newDevice.displayName,
                    didSync: newDeviceExpectation == .linkAndSync,
                ))
                self.newDeviceExpectation = nil
                withAnimation {
                    self.isLoading = false
                }
            }

            oldDeviceList = displayableDevices
        }

        withAnimation {
            self.displayableDevices = displayableDevices

            if displayableDevices.isEmpty {
                self.editMode = .inactive
            }
        }

        self.clearDeliveredNewLinkedDevicesNotificationsAndMegaphone()
    }

    func unlinkDevice(_ device: OWSDevice) {
#if DEBUG
        guard !isPreview else {
            withAnimation {
                displayableDevices.removeAll { $0.device.deviceId == device.deviceId }
            }
            return
        }
#endif
        Task { [deviceService] in
            do {
                try await deviceService.unlinkDevice(device)
            } catch let error {
                return await MainActor.run {
                    present.send(.unlinkFailureAlert(device: device, error: error))
                }
            }

            Logger.info("Removing unlinked device with deviceId: \(device.deviceId)")

            await db.awaitableWrite { tx in
                deviceStore.remove(device, tx: tx)
            }

            await MainActor.run {
                updateDeviceList()
            }
        }
    }

    func renameDevice(
        _ displayableDevice: DisplayableDevice,
        to newName: String,
    ) async throws {
        try await deviceService.renameDevice(
            device: displayableDevice.device,
            newName: newName,
        )
    }

    // MARK: DisplayableDevice

    struct DisplayableDevice: Hashable, Identifiable {
        let device: OWSDevice

        var id: DeviceId { device.deviceId }
        var displayName: String { device.displayName }
        var createdAt: Date { device.createdAt }

        static func ==(lhs: DisplayableDevice, rhs: DisplayableDevice) -> Bool {
            lhs.id == rhs.id
                && lhs.displayName == rhs.displayName
                && lhs.createdAt == rhs.createdAt
        }

        func hash(into hasher: inout Hasher) {
            hasher.combine(id)
            hasher.combine(displayName)
            hasher.combine(createdAt)
        }
    }
}

// MARK: DatabaseChangeDelegate

extension LinkedDevicesViewModel: DatabaseChangeDelegate {
    func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
        guard databaseChanges.didUpdate(tableName: OWSDevice.databaseTableName) else {
            return
        }

        updateDeviceList()
    }

    func databaseChangesDidUpdateExternally() {
        updateDeviceList()
    }

    func databaseChangesDidReset() {
        updateDeviceList()
    }
}

// MARK: LinkDeviceViewControllerDelegate

extension LinkedDevicesViewModel: LinkDeviceViewControllerDelegate {
    func didFinishLinking(
        _ linkNSyncData: LinkNSyncData?,
        from linkDeviceViewController: LinkDeviceViewController,
    ) {
        self.deviceIdToIgnore = nil
        self.scheduleNewLinkedDeviceNotification()

        guard let linkNSyncData else {
            linkDeviceViewController.popToLinkedDeviceList { [weak self] in
                self?.expectMoreDevices()
            }
            return
        }

        // Don't wait for the view pop to start the linking process
        let linkAndSyncProgressModal = BackupRestoreProgressModal(style: .linkAndSync)
        linkDeviceViewController.popToLinkedDeviceList { [weak self] in
            self?.present.send(.activityIndicator(linkAndSyncProgressModal))
        }

        let linkNSyncTask = Task { @MainActor in
            let progress = await OWSSequentialProgress<PrimaryLinkNSyncProgressPhase>.createSink { progress in
                await MainActor.run {
                    linkAndSyncProgressModal.viewModel.updatePrimaryLinkingProgress(progress: progress)
                }
            }
            do {
                try await DependenciesBridge.shared.linkAndSyncManager.waitForLinkingAndUploadBackup(
                    ephemeralBackupKey: linkNSyncData.ephemeralBackupKey,
                    tokenId: linkNSyncData.tokenId,
                    progress: progress,
                )
                Task { @MainActor in
                    await linkAndSyncProgressModal.completeAndDismiss()
                }
            } catch {
                linkAndSyncProgressModal.dismiss(animated: true) {
                    guard let error = error as? PrimaryLinkNSyncError else {
                        owsFailDebug("Unexpected error!")
                        return
                    }
                    switch error {
                    case let .cancelled(linkedDeviceId):
                        // Don't show anything
                        self.deviceIdToIgnore = linkedDeviceId
                        return
                    case
                        .errorWaitingForLinkedDevice,
                        .errorUploadingBackup,
                        .errorMarkingBackupUploaded,
                        .errorGeneratingBackup:
                        self.present.send(.linkAndSyncFailureAlert(error))
                    }
                }
                self.expectMoreDevices()
                return
            }
            self.newDeviceExpectation = .linkAndSync
            await self.refreshDevices()
        }
        linkAndSyncProgressModal.backupTask = linkNSyncTask
    }

    fileprivate func expectMoreDevices() {
        newDeviceExpectation = .link
        editMode = .inactive

        pollingRefreshTimer?.invalidate()
        pollingRefreshTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] timer in
            MainActor.assumeIsolated {
                guard let self, self.newDeviceExpectation != nil else {
                    timer.invalidate()
                    return
                }

                Task {
                    await self.refreshDevices()
                }
            }
        }
    }

    private func scheduleNewLinkedDeviceNotification() {
        let deviceLinkTimestamp = Date()
        let notificationDelay = TimeInterval.random(in: .hour...(.hour * 3))
        db.write { tx in
            deviceStore.setMostRecentlyLinkedDeviceDetails(
                linkedTime: deviceLinkTimestamp,
                notificationDelay: notificationDelay,
                tx: tx,
            )
        }
        SSKEnvironment.shared.notificationPresenterRef.scheduleNotifyForNewLinkedDevice(deviceLinkTimestamp: deviceLinkTimestamp)
    }

    private func clearDeliveredNewLinkedDevicesNotificationsAndMegaphone() {
        let details = db.read { tx in
            deviceStore.mostRecentlyLinkedDeviceDetails(tx: tx)
        }

        // Only clear them if the delivery time for the notification and
        // megaphone has passed, otherwise it would just clear right away
        // after linking.
        if let details, Date() > details.shouldRemindUserAfter {
            db.write { tx in
                deviceStore.clearMostRecentlyLinkedDeviceDetails(tx: tx)
                ExperienceUpgradeManager.clearExperienceUpgrade(
                    .newLinkedDeviceNotification,
                    transaction: tx,
                )
            }
        }

        SSKEnvironment.shared.notificationPresenterRef.clearDeliveredNewLinkedDevicesNotifications()
    }
}

// MARK: - LinkedDevicesHostingController

class LinkedDevicesHostingController: HostingContainer<LinkedDevicesView> {
    private let viewModel: LinkedDevicesViewModel

    private var subscriptions = Set<AnyCancellable>()

    private weak var finishLinkingSheet: HeroSheetViewController?

    init(isPreview: Bool = false) {
        self.viewModel = LinkedDevicesViewModel(isPreview: isPreview)

        super.init(wrappedView: LinkedDevicesView(viewModel: viewModel))

        OWSTableViewController2.removeBackButtonText(viewController: self)

        viewModel.present.sink { [weak self] presentation in
            guard let self else { return }
            switch presentation {
            case let .newDeviceToast(deviceName, didSync):
                if let finishLinkingSheet {
                    finishLinkingSheet.dismiss(animated: true) {
                        self.showNewDeviceToast(deviceName: deviceName, didSync: didSync)
                    }
                } else {
                    self.showNewDeviceToast(deviceName: deviceName, didSync: didSync)
                }
            case let .updateFailureAlert(error):
                self.showUpdateFailureAlert(error: error)
            case .linkDeviceAuthentication:
                self.didTapLinkDeviceButton()
            case let .renameDevice(displayableDevice):
                self.showRenameDeviceView(device: displayableDevice)
            case let .unlinkDeviceConfirmation(displayableDevice):
                self.showUnlinkDeviceConfirmAlert(displayableDevice: displayableDevice)
            case let .unlinkFailureAlert(device, error):
                self.showUnlinkFailedAlert(device: device, error: error)
            case let .activityIndicator(modal):
                self.present(modal, animated: true)
            case .linkedDeviceEducation:
                self.present(LinkedDevicesEducationSheet(), animated: true)
            case let .linkAndSyncFailureAlert(error):
                switch error {
                case .errorMarkingBackupUploaded(let retryHandler), .errorUploadingBackup(let retryHandler):
                    self.showLinkAndSyncRetryableFailureAlert(errorRetryHandler: retryHandler)
                case .errorWaitingForLinkedDevice:
                    self.showLinkAndSyncUnretryableFailureAlert(contactSupportEmailFilter: nil)
                case .errorGeneratingBackup:
                    self.showLinkAndSyncUnretryableFailureAlert(contactSupportEmailFilter: .backupExportFailed)
                case .cancelled:
                    break
                }
            }
        }.store(in: &subscriptions)

        viewModel.$editMode
            .sink { [weak self] editMode in
                self?.updateNavigationItems(editMode: editMode)
            }
            .store(in: &subscriptions)

        self.title = OWSLocalizedString(
            "LINKED_DEVICES_TITLE",
            comment: "Menu item and navbar title for the device manager",
        )
    }

    private func updateNavigationItems(editMode: EditMode? = nil) {
        // @Published sends the new value before the view model
        // itself updates, so its value needs to be passed in.
        let editMode = editMode ?? viewModel.editMode

        navigationItem.rightBarButtonItem = .systemItem(
            editMode.isEditing ? .done : .edit,
        ) { [weak viewModel] in
            withAnimation {
                viewModel?.editMode = editMode.isEditing ? .inactive : .active
            }
        }
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        if viewModel.shouldShowFinishLinkingSheet {
            // Only show the sheet once even if viewDidAppear is
            // called multiple times while waiting for the link.
            viewModel.shouldShowFinishLinkingSheet = false

            let sheet = HeroSheetViewController(
                hero: .image(UIImage(named: "linked-devices")!),
                title: OWSLocalizedString(
                    "LINK_NEW_DEVICE_FINISH_ON_OTHER_DEVICE_SHEET_TITLE",
                    comment: "Title for a sheet when a user has started linking a device informing them to finish the process on that other device",
                ),
                body: OWSLocalizedString(
                    "LINK_NEW_DEVICE_FINISH_ON_OTHER_DEVICE_SHEET_BODY",
                    comment: "Body text for a sheet when a user has started linking a device informing them to finish the process on that other device",
                ),
                primaryButton: .dismissing(title: CommonStrings.continueButton),
            )
            self.finishLinkingSheet = sheet

            // Presenting it in viewDidAppear prevents the background dimming
            DispatchQueue.main.async {
                self.present(sheet, animated: true)
            }
        }
    }

    // MARK: Device linking

    private func showNewDeviceToast(deviceName: String, didSync: Bool) {
        let title: String = if didSync {
            OWSLocalizedString(
                "DEVICE_LIST_UPDATE_NEW_DEVICE_SYNCED_TOAST",
                comment: "Message appearing on a toast indicating a new device was successfully linked and synced.",
            )
        } else {
            OWSLocalizedString(
                "DEVICE_LIST_UPDATE_NEW_DEVICE_TOAST",
                comment: "Message appearing on a toast indicating a new device was successfully linked. Embeds {{ device name }}",
            )
        }

        presentToast(text: String.nonPluralLocalizedStringWithFormat(title, deviceName))
    }

    private func showUpdateFailureAlert(error: Error) {
        AssertIsOnMainThread()

        let alertTitle = OWSLocalizedString(
            "DEVICE_LIST_UPDATE_FAILED_TITLE",
            comment: "Alert title that can occur when viewing device manager.",
        )
        let alert = ActionSheetController(
            title: alertTitle,
            message: error.userErrorDescription,
        )
        alert.addAction(
            ActionSheetAction(
                title: CommonStrings.retryButton,
                style: .default,
            ) { [weak self] _ in
                Task {
                    await self?.viewModel.refreshDevices()
                }
            },
        )
        alert.addAction(OWSActionSheets.dismissAction)

        presentActionSheet(alert)
    }

    private func showLinkNewDeviceView(skipEducationSheet: Bool = false) {
        AssertIsOnMainThread()

        func presentLinkView(_ linkView: LinkDeviceViewController) {
            linkView.delegate = viewModel
            navigationController?.pushViewController(linkView, animated: true)
        }

        self.ows_askForCameraPermissions { granted in
            guard granted else {
                return
            }

            presentLinkView(LinkDeviceViewController(
                skipEducationSheet: skipEducationSheet,
            ))
        }
    }

    private func didTapLinkDeviceButton() {
        let localDeviceAuth = LocalDeviceAuthentication()
        let localDeviceAuthAttemptToken: LocalDeviceAuthentication.AttemptToken

        switch localDeviceAuth.checkCanAttempt() {
        case .success(let attemptToken):
            localDeviceAuthAttemptToken = attemptToken
        case .failure(.notRequired):
            showLinkNewDeviceView()
            return
        case .failure(.canceled):
            return
        case .failure(.genericError(let localizedErrorMessage)):
            showError(message: localizedErrorMessage)
            return
        }

        let sheet = HeroSheetViewController(
            hero: .image(UIImage(named: "phone-lock")!),
            title: OWSLocalizedString(
                "LINK_NEW_DEVICE_AUTHENTICATION_INFO_SHEET_TITLE",
                comment: "Title for a sheet when a user tries to link a device informing them that they will need to authenticate their device",
            ),
            body: OWSLocalizedString(
                "LINK_NEW_DEVICE_AUTHENTICATION_INFO_SHEET_BODY",
                comment: "Body text for a sheet when a user tries to link a device informing them that they will need to authenticate their device",
            ),
            primaryButton: .init(
                title: CommonStrings.continueButton,
            ) { [weak self] _ in
                self?.dismiss(animated: true)
                Task {
                    await self?.authenticateThenShowLinkNewDeviceView(
                        localDeviceAuth: localDeviceAuth,
                        localDeviceAuthAttemptToken: localDeviceAuthAttemptToken,
                    )
                }
            },
        )

        self.present(sheet, animated: true)
    }

    private func showLinkAndSyncRetryableFailureAlert(errorRetryHandler: PrimaryLinkNSyncError.RetryHandler) {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "LINK_NEW_DEVICE_SYNC_FAILED_TITLE",
                comment: "Title for a sheet indicating that a newly linked device failed to sync messages.",
            ),
            message: OWSLocalizedString(
                "LINK_NEW_DEVICE_SYNC_FAILED_RETRYABLE_MESSAGE",
                comment: "Message for a sheet indicating that a newly linked device failed to sync messages with a retryable error.",
            ),
        )
        actionSheet.addAction(
            .init(
                title: CommonStrings.retryButton,
                style: .cancel,
            ) { [weak self] _ in
                Task {
                    await errorRetryHandler.tryToResetLinkedDevice()
                    await MainActor.run {
                        self?.showLinkNewDeviceView(skipEducationSheet: true)
                    }
                }
            },
        )
        actionSheet.addAction(
            .init(
                title: OWSLocalizedString(
                    "LINK_NEW_DEVICE_SYNC_FAILED_CONTINUE_BUTTON",
                    comment: "Button for a sheet indicating that a newly linked device failed to sync messages, to link without transferring.",
                ),
            ) { _ in
                Task {
                    await errorRetryHandler.tryToContinueWithoutSyncing()
                }
            },
        )
        actionSheet.onDismiss = { [weak self] in
            self?.viewModel.expectMoreDevices()
        }
        presentActionSheet(actionSheet)
    }

    private func showLinkAndSyncUnretryableFailureAlert(
        contactSupportEmailFilter: ContactSupportActionSheet.EmailFilter?,
    ) {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "LINK_NEW_DEVICE_SYNC_FAILED_TITLE",
                comment: "Title for a sheet indicating that a newly linked device failed to sync messages.",
            ),
            message: OWSLocalizedString(
                "LINK_NEW_DEVICE_SYNC_FAILED_MESSAGE",
                comment: "Message for a sheet indicating that a newly linked device failed to sync messages.",
            ),
        )
        if let contactSupportEmailFilter {
            actionSheet.addAction(ActionSheetAction(title: CommonStrings.contactSupport) { [weak self] _ in
                guard let self else { return }

                ContactSupportActionSheet.present(
                    emailFilter: contactSupportEmailFilter,
                    logDumper: .fromGlobals(),
                    fromViewController: self,
                )
            })
        }
        actionSheet.addAction(.init(title: CommonStrings.learnMore) { _ in
            CurrentAppContext().open(URL.Support.linkedDevices, completion: nil)
        })
        actionSheet.addAction(ActionSheetAction(title: CommonStrings.continueButton, style: .cancel))

        actionSheet.onDismiss = { [weak self] in
            self?.viewModel.expectMoreDevices()
            DependenciesBridge.shared.backupArchiveErrorPresenter.presentOverTopmostViewController(completion: {})
        }
        presentActionSheet(actionSheet)
    }

    // MARK: Authentication

    private func authenticateThenShowLinkNewDeviceView(
        localDeviceAuth: LocalDeviceAuthentication,
        localDeviceAuthAttemptToken: LocalDeviceAuthentication.AttemptToken,
    ) async {
        switch await localDeviceAuth.attempt(token: localDeviceAuthAttemptToken) {
        case .success, .failure(.notRequired):
            showLinkNewDeviceView()
        case .failure(.canceled):
            break
        case .failure(.genericError(let localizedErrorMessage)):
            showError(message: localizedErrorMessage)
        }
    }

    private func showError(message: String) {
        Logger.error(message)
        OWSActionSheets.showActionSheet(
            title: DeviceAuthenticationErrorMessage.errorSheetTitle,
            message: message,
            fromViewController: self,
        )
    }

    // MARK: Renaming

    private func showRenameDeviceView(device: LinkedDevicesViewModel.DisplayableDevice) {
        let viewController = EditDeviceNameViewController(
            oldName: device.displayName,
        ) { [weak viewModel] newName in
            try await viewModel?.renameDevice(device, to: newName)
            self.presentToast(text: OWSLocalizedString(
                "LINKED_DEVICES_RENAME_SUCCESS_MESSAGE",
                value: "Device name updated",
                comment: "Message on a toast indicating the device was renamed.",
            ))
        }
        self.navigationController?.pushViewController(viewController, animated: true)
    }

    // MARK: Unlinking

    private func showUnlinkDeviceConfirmAlert(displayableDevice: LinkedDevicesViewModel.DisplayableDevice) {
        AssertIsOnMainThread()

        let titleFormat = OWSLocalizedString(
            "UNLINK_CONFIRMATION_ALERT_TITLE",
            comment: "Alert title for confirming device deletion",
        )
        let title = String.nonPluralLocalizedStringWithFormat(titleFormat, displayableDevice.displayName)
        let message = OWSLocalizedString(
            "UNLINK_CONFIRMATION_ALERT_BODY",
            comment: "Alert message to confirm unlinking a device",
        )
        let alert = ActionSheetController(title: title, message: message)
        alert.addAction(
            ActionSheetAction(
                title: OWSLocalizedString(
                    "UNLINK_ACTION",
                    comment: "button title for unlinking a device",
                ),
                style: .destructive,
                handler: { [weak viewModel] _ in
                    viewModel?.unlinkDevice(displayableDevice.device)
                },
            ),
        )
        alert.addAction(OWSActionSheets.cancelAction)
        presentActionSheet(alert)
    }

    private func showUnlinkFailedAlert(device: OWSDevice, error: Error) {
        AssertIsOnMainThread()

        let title = OWSLocalizedString(
            "UNLINKING_FAILED_ALERT_TITLE",
            comment: "Alert title when unlinking device fails",
        )
        let alert = ActionSheetController(title: title, message: error.userErrorDescription)
        alert.addAction(
            ActionSheetAction(
                title: CommonStrings.retryButton,
                style: .default,
            ) { [weak self] _ in
                self?.viewModel.unlinkDevice(device)
            },
        )
        alert.addAction(OWSActionSheets.cancelAction)
        presentActionSheet(alert)
    }
}

// MARK: - LinkedDevicesView

struct LinkedDevicesView: View {
    @ObservedObject var viewModel: LinkedDevicesViewModel

    private var isEditing: Bool {
        viewModel.editMode.isEditing
    }

    var body: some View {
        SignalList {
            SignalSection {
                VStack(spacing: 20) {
                    Image("linked-device-intro-dark")

                    Text(OWSLocalizedString(
                        "LINKED_DEVICES_HEADER_DESCRIPTION",
                        comment: "Description for header of the linked devices list",
                    ))
                    .appendLink(CommonStrings.learnMore) {
                        viewModel.present.send(.linkedDeviceEducation)
                    }
                    .foregroundStyle(Color.Signal.secondaryLabel)
                    .font(.subheadline)
                    .multilineTextAlignment(.center)

                    Button {
                        viewModel.present.send(.linkDeviceAuthentication)
                    } label: {
                        Text(OWSLocalizedString(
                            "LINK_NEW_DEVICE_TITLE",
                            comment: "Navigation title when scanning QR code to add new device.",
                        ))
                    }
                    .buttonStyle(Registration.UI.LargePrimaryButtonStyle())
                    .disabled(isEditing)
                }
                .padding(.horizontal, 8)
                .padding(.vertical, 12)
            }

            SignalSection {
                if viewModel.displayableDevices.isEmpty {
                    ZStack(alignment: .center) {
                        // For height calculation
                        DeviceView(device: nil)
                            .opacity(0)

                        if viewModel.isLoading {
                            // [Device Linking] TODO: Signal spinner
                            ProgressView()
                        } else {
                            Text(OWSLocalizedString(
                                "LINKED_DEVICES_EMPTY_STATE",
                                comment: "Text that appears where the linked device list would be indicating that there are no linked devices.",
                            ))
                            .foregroundStyle(.secondary)
                            .font(.subheadline)
                        }
                    }
                    .frame(maxWidth: .infinity)
                }

                ForEach(viewModel.displayableDevices, id: \.self) { device in
                    DeviceView(device: device)
                        .swipeActions {
                            Button(Self.unlinkString) {
                                viewModel.present.send(.unlinkDeviceConfirmation(displayableDevice: device))
                            }
                            .tint(.red)
                        }
                }
                .onDelete { _ in
                    // This exists for adding the (-) buttons in edit mode,
                    // but the actual swipe action is defined above.
                }
            } header: {
                Text(OWSLocalizedString(
                    "LINKED_DEVICES_LIST_TITLE",
                    comment: "Title above the list of currently-linked devices",
                ))
            } footer: {
                (
                    SignalSymbol.lock.text(dynamicTypeBaseSize: 16).baselineOffset(-1) +
                        Text(" ") +
                        Text(OWSLocalizedString(
                            "LINKED_DEVICES_LIST_FOOTER",
                            comment: "Footer text below the list of currently-linked devices",
                        )),
                )
                .multilineTextAlignment(.center)
                .foregroundStyle(Color.Signal.secondaryLabel)
                .font(.caption)
                .padding(.top)
            }

            if shouldShowDeviceIdsBecauseUserIsInternal {
                SignalSection { } footer: {
                    Text(LocalizationNotNeeded(
                        "Device IDs (and this message) are only shown to internal users.",
                    ))
                    .foregroundStyle(Color.Signal.tertiaryLabel)
                }
            }
        }
        .task {
            await viewModel.refreshDevices()
        }
        .refreshable {
            await viewModel.refreshDevices()
        }
        .environment(\.editMode, self.$viewModel.editMode)
        .environmentObject(viewModel)
    }

    // MARK: DeviceView

    private struct DeviceView: View {
        @EnvironmentObject private var viewModel: LinkedDevicesViewModel

        var device: LinkedDevicesViewModel.DisplayableDevice?

        private var deviceName: String {
            if let device {
                if shouldShowDeviceIdsBecauseUserIsInternal {
                    LocalizationNotNeeded(
                        "#\(device.device.deviceId): \(device.displayName)",
                    )
                } else {
                    device.displayName
                }
            } else {
                " "
            }
        }

        private func dateFormattedString(format: String, date: Date) -> String {
            String.nonPluralLocalizedStringWithFormat(
                format,
                DateUtil.dateFormatter.string(from: date),
            )
        }

        private var linkedDateString: String {
            guard let device else { return " " }
            return dateFormattedString(
                format: OWSLocalizedString(
                    "DEVICE_LINKED_AT_LABEL",
                    comment: "{{Short Date}} when device was linked.",
                ),
                date: device.device.createdAt,
            )
        }

        private var lastSeenDateString: String {
            guard let device else { return " " }
            return dateFormattedString(
                format: OWSLocalizedString(
                    "DEVICE_LAST_ACTIVE_AT_LABEL",
                    comment: "{{Short Date}} when device last communicated with Signal Server.",
                ),
                // lastSeenAt is stored at day granularity. At midnight UTC.
                // Making it likely that when you first link a device it will
                // be "last seen" the day before it was created, which looks broken.
                date: max(
                    device.device.createdAt,
                    device.device.lastSeenAt,
                ),
            )
        }

        var body: some View {
            HStack(spacing: 12) {
                Image("devices")
                    .padding(6)
                    .background(Color(.systemFill), in: .circle)

                VStack(alignment: .leading) {
                    Text(self.deviceName)
                    Group {
                        Text(linkedDateString)
                        Text(lastSeenDateString)
                    }
                    .foregroundStyle(.secondary)
                    .font(.subheadline)
                }

                Spacer(minLength: 0)

                Menu {
                    Button {
                        guard let device else { return }
                        viewModel.present.send(
                            .renameDevice(displayableDevice: device),
                        )
                    } label: {
                        Label(
                            OWSLocalizedString(
                                "LINKED_DEVICES_RENAME_BUTTON",
                                comment: "Button title for renaming a linked device",
                            ),
                            image: "edit",
                        )
                    }

                    Button(role: .destructive) {
                        guard let device else { return }
                        viewModel.present.send(
                            .unlinkDeviceConfirmation(displayableDevice: device),
                        )
                    } label: {
                        Label(LinkedDevicesView.unlinkString, image: "link-slash")
                    }
                } label: {
                    VStack(spacing: 0) {
                        Label(CommonStrings.editButton, image: "more-vertical")
                            .labelStyle(.iconOnly)
                        Spacer(minLength: 0)
                    }
                }
                .padding(.top, 9)
                .foregroundStyle(.primary)
            }
        }
    }

    private static let unlinkString = OWSLocalizedString(
        "UNLINK_ACTION",
        comment: "button title for unlinking a device",
    )
}

// MARK: - EditDeviceNameViewController

class EditDeviceNameViewController: NameEditorViewController {
    override class var nameByteLimit: Int { 225 }
    override class var nameGlyphLimit: Int { 50 }

    override var allowEmptyName: Bool { false }
    override var placeholderText: String? {
        OWSLocalizedString(
            "SECONDARY_ONBOARDING_CHOOSE_DEVICE_NAME_PLACEHOLDER",
            comment: "text field placeholder",
        )
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        self.title = OWSLocalizedString(
            "LINKED_DEVICES_RENAME_TITLE",
            comment: "Title for the screen for renaming a linked device",
        )
    }

    override func handleError(_ error: any Error) {
        OWSActionSheets.showErrorAlert(
            message: OWSLocalizedString(
                "LINKED_DEVICES_RENAME_FAILURE_MESSAGE",
                comment: "Message on a sheet indicating the device rename attempt received an error.",
            ),
        )
    }
}

// MARK: - Previews

#if DEBUG
@available(iOS 17, *)
#Preview {
    let semaphore = DispatchSemaphore(value: 0)
    Task.detached {
        await MockSSKEnvironment.activate()
        semaphore.signal()
    }
    semaphore.wait()
    let viewController = LinkedDevicesHostingController(isPreview: true)
    return OWSNavigationController(rootViewController: viewController)
}
#endif