Eine aufbereitete Darstellung der Quelle

 
     
 
 
Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 

Benutzer

Quelle  ProfileCoordinator.swift   Sprache: JAVA

 

import Combine
import Coordinator
import Foundation
import SwiftUI
import ThreemaFramework
import ThreemaMacros

final class ProfileCoordinator: NSObject, Coordinator {

    // MARK: - Internal destination

    enum InternalDestination: Hashable {
        // Actions and modals (never part of the navigation path)
        case editProfile
        case scanQRCode
        case qrCode
        case shareID
        case workAvailabilityStatus
        case revocationPassword
        case publicKey
        case revokeDelete

        // Path destinations
        case backups
        /// Threema Safe dashboard, always shown on top of `.backups`
        case threemaSafe
        case linkPhone
        case linkMail
    }

    // MARK: - Coordinator

    var childCoordinators: [any Coordinator] = []
    var rootViewController: UIViewController {
        rootNavigationController
    }

    // MARK: - Routers

    private weak var presentingViewController: ThreemaSplitViewController?
    private let businessInjector: any BusinessInjectorProtocol
    private let shareActivityRouter: any ShareActivityRouterProtocol
    private let modalRouter: any ModalRouterProtocol
    private let passcodeRouter: any PasscodeRouterProtocol
    private let router = NavigationRouter()

    private lazy var mdmSetup = MDMSetup()
    private lazy var appFlavor = AppFlavorService()
    private lazy var safeConfigManager = SafeConfigManager()
    private lazy var serverApiConnector = ServerAPIConnector()
    private lazy var safeApiService = SafeApiService()
    private lazy var deviceCapabilitiesManager = DeviceCapabilitiesManager()
    private lazy var appEnvironment = AppEnvironment(businessInjector: businessInjector)

    private lazy var collectionView = ProfileCollectionView { [weak self] in
        self?.currentDestination
    } shouldAllowAutoDeselection: { [weak self] in
        let traitCollection = self?.presentingViewController?.traitCollection
        return traitCollection?.horizontalSizeClass == .compact
    }

    private var canAddContact: Bool {
        if TargetManager.isBusinessApp {
            mdmSetup?.disableAddContact() == false
        }
        else {
            true
        }
    }

    private lazy var dataSource: ProfileCollectionViewDataSource = {

        let dataSource = ProfileCollectionViewDataSource(
            collectionView: collectionView,
            cellProvider: cellProvider
        ) { [weak self] in
            guard let destination = $0.toProfileDestination() else {
                return
            }

            self?.show(destination)
        }

        return dataSource
    }()

    private lazy var cellProvider: ProfileCollectionViewDataSource.CellProvider = {
        [weak self] collectionView, indexPath, item in

        guard let self else {
            return nil
        }

        if item == .header {
            let cell: ProfileCollectionViewHeaderCell = collectionView.dequeueCell(for: indexPath)
            cell.backgroundConfiguration = .clear()
            cell.coordinator = self
            cell.updateContent()
            return cell
        }
        else {
            guard let cell: UICollectionViewListCell = collectionView.dequeueReusableCell(
                withReuseIdentifier: "Default",
                for: indexPath
            ) as? UICollectionViewListCell else {
                return nil
            }

            var content = UIListContentConfiguration.valueCell()
            content.text = item.title

            if item.isDestructive {
                content.textProperties.color = .systemRed
            }

            if item.isInteractionDisabled {
                content.textProperties.color = .secondaryLabel
            }
            let indicator = UICellAccessory.disclosureIndicator()
            cell.accessories = [indicator]

            if let text = item.accessoryText {
                content.secondaryText = text
            }

            if let accessibilityIdentifier = item.accessibilityIdentifier {
                cell.accessibilityIdentifier = accessibilityIdentifier
            }

            if item == .workAvailabilityStatus {
                let manager = businessInjector.workAvailabilityStatusManager
                let category = manager.ownStatus().category

                // Only show label for status other than `.none`
                if category != .none {
                    let attachment = NSTextAttachment()
                    let font = UIFont.preferredFont(forTextStyle: .body)

                    let size = CGSize(width: font.capHeight, height: font.capHeight)
                    attachment.image = UIGraphicsImageRenderer(size: size).image { _ in
                        category.color.setFill()
                        UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill()
                    }
                    attachment.bounds = CGRect(origin: .zero, size: size)

                    let attributedText = NSMutableAttributedString(attachment: attachment)
                    attributedText.append(NSAttributedString(string: " \(category.localizedDescription)"))

                    content.secondaryAttributedText = attributedText
                }
            }
            cell.contentConfiguration = content

            cell.isUserInteractionEnabled = !item.isInteractionDisabled
            return cell
        }
    }

    private lazy var profileViewController: ProfileViewController = {
        let viewController = ProfileViewController(
            collectionView: collectionView,
            dataSource: dataSource
        )

        let tab = ThreemaTab(.profile)
        viewController.tabBarItem = tab.tabBarItem
        viewController.title = tab.title

        return viewController
    }()

    private(set) lazy var rootNavigationController = StatusNavigationController()

    private lazy var safeStore = SafeStore(
        safeConfigManager: safeConfigManager,
        serverApiConnector: serverApiConnector,
        groupManager: businessInjector.groupManager,
        myIdentityStore: businessInjector.myIdentityStore,
        phoneNumberNormalizer: PhoneNumberNormalizer()
    )

    private lazy var safeManager = SafeManager(
        safeConfigManager: safeConfigManager,
        safeStore: safeStore,
        safeAPIService: safeApiService
    )

    // MARK: - Lifecycle

    init(
        presentingViewController: ThreemaSplitViewController,
        businessInjector: any BusinessInjectorProtocol,
        shareActivityRouter: any ShareActivityRouterProtocol,
        modalRouter: any ModalRouterProtocol,
        passcodeRouter: any PasscodeRouterProtocol
    ) {
        self.presentingViewController = presentingViewController
        self.businessInjector = businessInjector
        self.shareActivityRouter = shareActivityRouter
        self.modalRouter = modalRouter
        self.passcodeRouter = passcodeRouter
        super.init()

        router.delegate = self
        router.forwardDelegate = self
    }

    // MARK: - Presentation

    func start() {
        /// Due to this coordinator's rootViewController being part of a
        /// `UITabViewController`, it's not needed to present anything here.
        /// The rootViewController is added by to the `UITabViewController`'s
        /// viewControllers in ``AppCoordinator``'s `configureSplitViewController` method.
        rootNavigationController.setViewControllers(
            [profileViewController],
            animated: false
        )

        setupNavigationBarItems()
        observeIncomingSync()
    }

    func finish() {
        router.unbind()
        cancellables.removeAll()
        for child in childCoordinators {
            child.finish()
        }
        childCoordinators.removeAll()
    }

    func show(_ destination: InternalDestination) {
        defer {
            switch destination {
            case .editProfile, .scanQRCode, .qrCode, .shareID, .workAvailabilityStatus,
                 .revocationPassword, .publicKey, .revokeDelete:
                // Actions and modals don't change the path and dismissing a sheet doesn't trigger
                // `viewIsAppearing`, so the tapped row would stay highlighted. Re-derive it from the path.
                profileViewController.updateSelection()

            case .backups, .threemaSafe, .linkPhone, .linkMail:
                break
            }
        }

        switch destination {
        case .editProfile:
            showEditProfile()

        case .scanQRCode:
            showScanner()

        case .qrCode:
            showQRCode()

        case .shareID:
            shareID(from: nil)

        case .workAvailabilityStatus:
            showWorkAvailabilityStatus()

        case .revocationPassword:
            showPasswordForRevocation()

        case .publicKey:
            showPublicKey()

        case .revokeDelete:
            showRevokeDelete()

        case .backups:
            // Already inside the authenticated backups area (backups itself or a
            // screen on top of it): navigate without asking for the passcode again.
            if path.first == .backups {
                setPath([.backups], animated: true)
                return
            }

            // Guard-then-mutate: the path only changes after authentication
            // succeeded, so cancelling requires no rollback.
            passcodeRouter.requireAuthenticationIfNeeded(onSuccess: { [weak self] in
                self?.setPath([.backups], animated: true)
            })

        case .threemaSafe:
            // Already shown: don't ask for the passcode again.
            guard path != [.backups, .threemaSafe] else {
                return
            }

            passcodeRouter.requireAuthenticationIfNeeded(onSuccess: { [weak self] in
                self?.setPath([.backups, .threemaSafe], animated: true)
            })

        case .linkPhone, .linkMail:
            setPath([destination], animated: true)
        }
    }

    func navigateToThreemaSafe() {
        show(.threemaSafe)
    }

    func dismiss() {
        presentingViewController?.presentedViewController?.dismiss(animated: true)
    }

    // MARK: - Router binding

    /// Hooks for `ThreemaSplitViewNavigationManager` to activate/deactivate this
    /// tab's router when tab selection or the size class changes.
    var routerHandle: RouterManagedTabHandle {
        RouterManagedTabHandle(
            deactivate: { [weak self] in
                self?.router.unbind()
            },
            activateCompact: { [weak self] in
                self?.bindCompact()
            },
            activateRegular: { [weak self] navigationController in
                self?.bindRegular(into: navigationController)
            },
            reauthenticateProtectedDestinations: { [weak self] in
                guard
                    let self,
                    path.first == .backups
                else {
                    return
                }
                // The backups area stays in place behind the prompt; cancelling
                // clears it. (With no passcode set this succeeds immediately.)
                passcodeRouter.requireAuthenticationIfNeeded(
                    onCancel: { [weak self] in
                        self?.setPath([], animated: false)
                    },
                    onSuccess: { }
                )
            },
            releaseMaterializedScreens: { [weak self] in
                guard
                    let self,
                    !router.isBound
                else {
                    return
                }
                router.releaseMaterializedScreens()
                // The tab's own navigation controller retains the compact stack
                // even while inactive — reset it to the root; the next
                // activation re-materializes the stack from the path.
                rootNavigationController.setViewControllers(
                    [profileViewController],
                    animated: false
                )
            }
        )
    }

    /// Collapsed width: the whole stack lives in the tab's own navigation
    /// controller, rooted in the profile screen.
    private func bindCompact() {
        router.bind(
            to: rootNavigationController,
            rootViewController: profileViewController
        ) { [weak self] destination in
            self?.makeScreen(destination)
        }

        scheduleSelectionSync()
    }

    /// Regular width: the stack renders in the secondary column, rooted in a
    /// placeholder (the profile screen stays in the primary column). An empty path
    /// shows just the placeholder.
    private func bindRegular(into navigationController: UINavigationController) {
        router.bind(
            to: navigationController,
            rootViewController: ThreemaLogoViewController(rootView: ThreemaLogoView()),
            rootMode: .placeholder
        ) { [weak self] destination in
            self?.makeScreen(destination)
        }

        scheduleSelectionSync()
    }

    /// While the collapse/expand transition that triggered a (re)bind is running,
    /// the size class still reports the old value and the appearance callbacks
    /// fired during it clear the list selection. Re-derive the selection once the
    /// transition has settled.
    private func scheduleSelectionSync() {
        DispatchQueue.main.async { [weak self] in
            self?.profileViewController.updateSelection()
        }
    }

    // MARK: - Screen construction

    private func makeScreen(_ destination: AnyHashable) -> UIViewController? {
        guard let destination = destination as? InternalDestination else {
            return nil
        }

        switch destination {
        case .backups:
            return makeBackupsScreen()

        case .threemaSafe:
            return makeThreemaSafeScreen()

        case .linkPhone:
            return UIHostingController(rootView: LinkPhoneNumberDeciderView())

        case .linkMail:
            return UIHostingController(rootView: LinkEmailDeciderView())

        case .editProfile, .scanQRCode, .qrCode, .shareID, .workAvailabilityStatus,
             .revocationPassword, .publicKey, .revokeDelete:
            // Actions and modals never materialize as path screens.
            return nil
        }
    }

    private func makeBackupsScreen() -> UIViewController? {
        guard let mdmSetup = MDMSetup() else {
            return nil
        }

        let model = BackupsViewModel(
            appFlavor: AppFlavorService(),
            mdmSetup: mdmSetup,
            safeManager: safeManager
        )

        let cancellable = model.routePublisher.sink { [weak self] route in
            switch route {
            case .threemaSafe:
                // Same guarded flow (skip when already shown, authenticate
                // otherwise) as entering Threema Safe from anywhere else.
                self?.show(.threemaSafe)
            case .idExport:
                self?.showPasswordForIDExport()
            }
        }
        cancellables.insert(cancellable)

        return UIHostingController(rootView: BackupsView(model: model))
    }

    private func makeThreemaSafeScreen() -> UIViewController? {
        guard let mdmSetup = MDMSetup() else {
            return nil
        }

        let model = ThreemaSafeDashboardViewModel(
            appFlavor: appFlavor,
            myIdentityStore: BusinessInjector.ui.myIdentityStore,
            mdmSetup: mdmSetup,
            notificationCenter: NotificationCenter.default,
            safeConfigManager: safeConfigManager,
            safeManager: safeManager,
            safeStore: safeStore
        )

        return UIHostingController(rootView: ThreemaSafeDashboardView(model: model))
    }

    // MARK: - Private functions

    private var cancellables: Set<AnyCancellable> = []

    private func showEditProfile() {
        if businessInjector.userSettings.enableMultiDevice,
           businessInjector.serverConnector.connectionState != .loggedIn,
           let presentingViewController {
            UIAlertTemplate.showOKAlert(
                owner: presentingViewController,
                title: #localize("not_connected_for_edit_profile_title"),
                message: #localize("not_connected_for_edit_profile_message")
            )
        }
        else {
            let viewController = EditProfileViewController()
            modalRouter.present(viewController)
        }
    }

    private func showScanner() {
        MainActor.assumeIsolated { [weak self] in
            guard let self else {
                return
            }

            if canAddContact {
                let viewModel = QRCodeScannerViewModel(
                    mode: .identity,
                    audioSessionManager: BusinessInjector.ui.audioSessionManager,
                    systemFeedbackManager: SystemFeedbackManager(
                        deviceCapabilitiesManager: deviceCapabilitiesManager,
                        settingsStore: businessInjector.settingsStore
                    ),
                    systemPermissionsManager: SystemPermissionsManager()
                )
                let rootView = QRCodeScannerView(model: viewModel)
                let viewController = UIHostingController(rootView: rootView)
                viewModel.onCompletion = { [weak self] result in
                    self?.handleScannerResult(result)
                }
                viewModel.onCancel = { [weak self] in
                    self?.presentingViewController?.dismiss(animated: true)
                }
                modalRouter.present(viewController, animated: true)
            }
            else {
                guard let presentingViewController else {
                    return
                }

                UIAlertTemplate.showOKAlert(
                    owner: presentingViewController,
                    title: "",
                    message: #localize("disabled_by_device_policy")
                )
            }
        }
    }

    @MainActor
    private func handleScannerResult(_ result: QRCodeScannerViewModel.QRCodeResult) {
        switch result {
        case let .identityContact(identity: id, publicKey: key, expirationDate: date):
            let model = ContactIdentityProcessingViewModel(
                expectedIdentity: nil,
                scannedIdentity: id,
                scannedPublicKey: key,
                scannedExpirationDate: date,
                systemFeedbackManager: SystemFeedbackManager(
                    deviceCapabilitiesManager: DeviceCapabilitiesManager(),
                    settingsStore: businessInjector.settingsStore
                )
            )
            model.onCompletion = { [weak self] verifiedContact in
                self?.presentingViewController?.dismiss(animated: true) {
                    guard let verifiedContact else {
                        return
                    }

                    let name = Notification.Name(kNotificationShowContact)
                    let userInfo = [kKeyContact: verifiedContact]
                    NotificationCenter.default.post(
                        name: name,
                        object: nil,
                        userInfo: userInfo
                    )
                }
            }
            let rootView = ContactIdentityProcessingView(model: model)
            let viewController = UIHostingController(rootView: rootView)

            // Push onto the scanner's modal navigation controller
            if let modalNavigationController =
                presentingViewController?.presentedViewController as? UINavigationController {
                modalNavigationController.pushViewController(viewController, animated: true)
            }
            else {
                modalRouter.present(viewController)
            }

        case let .identityLink(url: url):
            presentingViewController?.dismiss(animated: true) {
                URLHandler().handle(url, hideAppChooser: true)
            }

        default:
            break
        }
    }

    private func showQRCode() {
        let vc = UIHostingController(rootView: QrCodeView(coordinator: self))
        modalRouter.present(vc)
    }

    /// Presents the share-ID sheet. This is an action, not a destination:
    /// it does not change the navigation path.
    /// - Parameter sourceView: Popover anchor for regular size classes.
    func shareID(from sourceView: UIView?) {
        let shareText = String.localizedStringWithFormat(
            #localize("profile_share_id_text"),
            TargetManager.localizedAppName
        )
        let combinedShareText =
            "\(shareText): \(THREEMA_ID_SHARE_LINK)\(businessInjector.myIdentityStore.identity ?? "")"
        shareActivityRouter.present(
            items: [combinedShareText],
            sourceView: sourceView
        )
    }

    private func showWorkAvailabilityStatus() {
        let model = WorkAvailabilityStatusViewModel(profileStore: businessInjector.profileStore) { [weak self] in
            self?.dismiss()
        }

        let rootView = WorkAvailabilityStatusView(model: model)
        let vc = UIHostingController(rootView: rootView)
        modalRouter.present(vc)
    }

    private func showPasswordForIDExport() {
        passcodeRouter.requireAuthenticationIfNeeded(onSuccess: { [weak self] in
            let vc = UIHostingController(rootView: PasswordCreationView(
                coordinator: self,
                title: #localize("profile_id_export"),
                footer: #localize("password_description_backup"),
                passwordCreateButton: #localize("profile_id_export_button")
            ) { password in
                self?.showExportedID(password: password)
            })

            self?.modalRouter.present(vc)
        })
    }

    private func showExportedID(password: String) {
        let vc = UIHostingController(rootView: IDExportView(coordinator: self, password: password))
        modalRouter.present(vc)
    }

    private func showPasswordForRevocation() {
        passcodeRouter.requireAuthenticationIfNeeded(onSuccess: { [weak self] in
            let vc = UIHostingController(rootView: PasswordCreationView(
                coordinator: self,
                title: #localize("revocation_password"),
                footer: String.localizedStringWithFormat(
                    #localize("revocation_password_description"),
                    TargetManager.localizedAppName
                ),
                passwordCreateButton: #localize("revocation_password_button")
            ) { password in
                RevocationKeyManager.shared.setPassword(password)
            })

            self?.modalRouter.present(vc)
        })
    }

    private func showPublicKey() {
        let identityStore = businessInjector.myIdentityStore
        let view = PublicKeyView(identity: identityStore.identity, publicKey: identityStore.publicKey) { [weak self] in
            self?.dismiss()
        }
        let vc = UIViewController()
        vc.view = view
        modalRouter.present(
            vc,
            style: .overFullScreen,
            transition: .crossDissolve
        )
    }

    private func showRevokeDelete() {
        guard !passcodeRouter.isPasscodeRequired() else {
            if let presentingViewController {
                UIAlertTemplate.showOKAlert(
                    owner: presentingViewController,
                    title: #localize("delete_identity_passcode_title"),
                    message: #localize("delete_identity_passcode_message")
                )
            }
            return
        }

        let deleteRevokeView = DeleteRevokeView { [weak self] in
            self?.dismiss()
        }

        /// Due to ``DeleteRevokeView`` having an inner flow,
        /// we don't want to add this to a ``UINavigationController``,
        /// but present the ``UIHostingController`` directly.
        let vc = DarkModeUIHostingController(rootView: deleteRevokeView)
        vc.modalPresentationStyle = .overFullScreen
        modalRouter.rootViewController.present(vc, animated: true)
    }

    private func setupNavigationBarItems() {
        let leftBarButtonItem = UIBarButtonItem.editButton(target: self, selector: #selector(editProfile))
        profileViewController.navigationItem.leftBarButtonItem = leftBarButtonItem
        
        guard deviceCapabilitiesManager.hasVideoCaptureDevice else {
            return
        }

        let rightBarButtonItem = UIBarButtonItem(
            image: UIImage(systemName: "qrcode.viewfinder"),
            style: .plain,
            target: self,
            action: #selector(scanQRCode)
        )
        profileViewController.navigationItem.rightBarButtonItem = rightBarButtonItem
    }

    private func observeIncomingSync() {
        appEnvironment.profileSyncPublisher
            .sink { [weak self] _ in
                self?.incomingSync()
            }
            .store(in: &cancellables)
    }

    @objc private func editProfile() {
        show(.editProfile)
    }

    @objc private func scanQRCode() {
        show(.scanQRCode)
    }

    private func incomingSync() {
        collectionView.reloadData()

        NotificationPresenterWrapper.shared.present(
            type: NotificationPresenterType(
                notificationText: #localize("incoming_profile_sync_title"),
                notificationStyle: .none
            ),
            subtitle: #localize("incoming_profile_sync_message")
        )
    }
}

// MARK: - PathCoordinator

extension ProfileCoordinator: PathCoordinator {

    /// Derived from the router — there is no separate state to keep in sync.
    var path: [InternalDestination] {
        router.path.compactMap { $0 as? InternalDestination }
    }

    func setPath(_ path: [InternalDestination], animated: Bool) {
        router.setPath(path.map { AnyHashable($0) }, animated: animated)
        profileViewController.updateSelection()
    }
}

// MARK: - NavigationRouterDelegate

extension ProfileCoordinator: NavigationRouterDelegate {

    /// The user popped (back button, swipe, tab re-tap pop-to-root): the path is
    /// already trimmed, only the list highlight needs a refresh.
    func router(_ router: NavigationRouter, didPopTo path: [AnyHashable]) {
        profileViewController.updateSelection()
    }
}

// MARK: - UINavigationControllerDelegate

extension ProfileCoordinator: UINavigationControllerDelegate {

    /// Forwarded by the router (which owns the delegate slot while bound) to keep
    /// the status navigation bar content up to date.
    func navigationController(
        _ navigationController: UINavigationController,
        didShow viewController: UIViewController,
        animated: Bool
    ) {
        (navigationController as? StatusNavigationController)?.updateNavigationBarContent()
    }
}

// MARK: - ProfileCollectionViewDataSource.Row

extension ProfileCollectionViewDataSource.Row {
    fileprivate func toProfileDestination() -> ProfileCoordinator.InternalDestination? {
        switch self {
        case .header:
            assertionFailure("Should not be possible to select.")
            return nil
        case .workAvailabilityStatus:
            return .workAvailabilityStatus
        case .backups:
            return .backups
        case .revocationPassword:
            return .revocationPassword
        case .phone:
            return .linkPhone
        case .mail:
            return .linkMail
        case .publicKey:
            return .publicKey
        case .revokeDelete:
            return .revokeDelete
        }
    }
}

Messung
CHZG
869410093

¤ Dauer der Verarbeitung: 0.11 Sekunden  ¤

*© Formatika GbR, Deutschland






Wurzel

Suchen

PVS Prover

Isabelle Prover


Cephes Mathematical Library

Vienna Development Method

Haftungshinweis

Die Informationen auf dieser Webseite wurden nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit, noch Qualität der bereit gestellten Informationen zugesichert.

Bemerkung:

Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.






                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

Open Source Software

     Quellcodebibliothek
     Eigene Quellcodes
     Fremde Quellcodes
     Suchen

Jenseits des Üblichen ....
    

Besucherstatistik

Besucherstatistik

Statistik
#Sources=1104845
#Domains=499168