// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import Reachability import NVActivityIndicatorView import SessionMessagingKit import SessionUIKit import SessionSnodeKit final class PathVC: BaseVC { public static let dotSize: CGFloat = 8 public static let expandedDotSize: CGFloat = 16 private static let rowHeight: CGFloat = (isIPhone5OrSmaller ? 52 : 75) // MARK: - Components private lazy var pathStackView: UIStackView = { let result = UIStackView() result.axis = .vertical return result }() private let spinner: NVActivityIndicatorView = { let result: NVActivityIndicatorView = NVActivityIndicatorView( frame: CGRect.zero, type: .circleStrokeSpin, color: .black, padding: nil ) result.set(.width, to: 64) result.set(.height, to: 64) ThemeManager.onThemeChange(observer: result) { [weak result] theme, _ in guard let textPrimary: UIColor = theme.color(for: .textPrimary) else { return } result?.color = textPrimary } return result }() private lazy var learnMoreButton: SessionButton = { let result = SessionButton(style: .bordered, size: .large) result.setTitle("vc_path_learn_more_button_title".localized(), for: UIControl.State.normal) result.addTarget(self, action: #selector(learnMore), for: UIControl.Event.touchUpInside) return result }() // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() setUpNavBar() setUpViewHierarchy() registerObservers() } private func setUpNavBar() { setNavBarTitle("vc_path_title".localized()) } private func setUpViewHierarchy() { // Set up explanation label let explanationLabel = UILabel() explanationLabel.font = .systemFont(ofSize: Values.smallFontSize) explanationLabel.text = "vc_path_explanation".localized() explanationLabel.themeTextColor = .textSecondary explanationLabel.textAlignment = .center explanationLabel.lineBreakMode = .byWordWrapping explanationLabel.numberOfLines = 0 // Set up path stack view let pathStackViewContainer = UIView() pathStackViewContainer.addSubview(pathStackView) pathStackView.pin([ UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: pathStackViewContainer) pathStackView.center(in: pathStackViewContainer) pathStackView.leadingAnchor.constraint(greaterThanOrEqualTo: pathStackViewContainer.leadingAnchor).isActive = true pathStackViewContainer.trailingAnchor.constraint(greaterThanOrEqualTo: pathStackView.trailingAnchor).isActive = true pathStackViewContainer.addSubview(spinner) spinner.leadingAnchor.constraint(greaterThanOrEqualTo: pathStackViewContainer.leadingAnchor).isActive = true spinner.topAnchor.constraint(greaterThanOrEqualTo: pathStackViewContainer.topAnchor).isActive = true pathStackViewContainer.trailingAnchor.constraint(greaterThanOrEqualTo: spinner.trailingAnchor).isActive = true pathStackViewContainer.bottomAnchor.constraint(greaterThanOrEqualTo: spinner.bottomAnchor).isActive = true spinner.center(in: pathStackViewContainer) // Set up rebuild path button let inset: CGFloat = isIPhone5OrSmaller ? 64 : 80 let learnMoreButtonContainer = UIView(wrapping: learnMoreButton, withInsets: UIEdgeInsets(top: 0, leading: inset, bottom: 0, trailing: inset), shouldAdaptForIPadWithWidth: Values.iPadButtonWidth) // Set up spacers let topSpacer = UIView.vStretchingSpacer() let bottomSpacer = UIView.vStretchingSpacer() // Set up main stack view let mainStackView = UIStackView(arrangedSubviews: [ explanationLabel, topSpacer, pathStackViewContainer, bottomSpacer, learnMoreButtonContainer ]) mainStackView.axis = .vertical mainStackView.alignment = .fill mainStackView.layoutMargins = UIEdgeInsets( top: Values.largeSpacing, left: Values.largeSpacing, bottom: Values.smallSpacing, right: Values.largeSpacing ) mainStackView.isLayoutMarginsRelativeArrangement = true view.addSubview(mainStackView) mainStackView.pin(to: view) // Set up spacer constraints topSpacer.heightAnchor.constraint(equalTo: bottomSpacer.heightAnchor).isActive = true // Perform initial update update() } private func registerObservers() { let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(handleBuildingPathsNotification), name: .buildingPaths, object: nil) notificationCenter.addObserver(self, selector: #selector(handlePathsBuiltNotification), name: .pathsBuilt, object: nil) notificationCenter.addObserver(self, selector: #selector(handleOnionRequestPathCountriesLoadedNotification), name: .onionRequestPathCountriesLoaded, object: nil) } deinit { NotificationCenter.default.removeObserver(self) } // MARK: - Updating @objc private func handleBuildingPathsNotification() { update() } @objc private func handlePathsBuiltNotification() { update() } @objc private func handleOnionRequestPathCountriesLoadedNotification() { update() } private func update() { pathStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } guard let pathToDisplay: [Snode] = OnionRequestAPI.paths.first else { spinner.startAnimating() UIView.animate(withDuration: 0.25) { self.spinner.alpha = 1 } return } let dotAnimationRepeatInterval = Double(pathToDisplay.count) + 2 let snodeRows: [UIStackView] = pathToDisplay.enumerated().map { index, snode in let isGuardSnode = (snode == pathToDisplay.first) return getPathRow( snode: snode, location: .middle, dotAnimationStartDelay: Double(index) + 2, dotAnimationRepeatInterval: dotAnimationRepeatInterval, isGuardSnode: isGuardSnode ) } let youRow = getPathRow( title: "vc_path_device_row_title".localized(), subtitle: nil, location: .top, dotAnimationStartDelay: 1, dotAnimationRepeatInterval: dotAnimationRepeatInterval ) let destinationRow = getPathRow( title: "vc_path_destination_row_title".localized(), subtitle: nil, location: .bottom, dotAnimationStartDelay: Double(pathToDisplay.count) + 2, dotAnimationRepeatInterval: dotAnimationRepeatInterval ) let rows = [ youRow ] + snodeRows + [ destinationRow ] rows.forEach { pathStackView.addArrangedSubview($0) } spinner.stopAnimating() UIView.animate(withDuration: 0.25) { self.spinner.alpha = 0 } } // MARK: - General private func getPathRow(title: String, subtitle: String?, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double) -> UIStackView { let lineView = LineView( location: location, dotAnimationStartDelay: dotAnimationStartDelay, dotAnimationRepeatInterval: dotAnimationRepeatInterval ) lineView.set(.width, to: PathVC.expandedDotSize) lineView.set(.height, to: PathVC.rowHeight) let titleLabel: UILabel = UILabel() titleLabel.font = .systemFont(ofSize: Values.mediumFontSize) titleLabel.text = title titleLabel.themeTextColor = .textPrimary titleLabel.lineBreakMode = .byTruncatingTail let titleStackView = UIStackView(arrangedSubviews: [ titleLabel ]) titleStackView.axis = .vertical if let subtitle = subtitle { let subtitleLabel = UILabel() subtitleLabel.font = .systemFont(ofSize: Values.verySmallFontSize) subtitleLabel.text = subtitle subtitleLabel.themeTextColor = .textPrimary subtitleLabel.lineBreakMode = .byTruncatingTail titleStackView.addArrangedSubview(subtitleLabel) } let stackView = UIStackView(arrangedSubviews: [ lineView, titleStackView ]) stackView.axis = .horizontal stackView.spacing = Values.largeSpacing stackView.alignment = .center return stackView } private func getPathRow(snode: Snode, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double, isGuardSnode: Bool) -> UIStackView { let country: String = (IP2Country.isInitialized ? IP2Country.shared.countryNamesCache.wrappedValue[snode.ip].defaulting(to: "Resolving...") : "Resolving..." ) return getPathRow( title: (isGuardSnode ? "vc_path_guard_node_row_title".localized() : "vc_path_service_node_row_title".localized() ), subtitle: country, location: location, dotAnimationStartDelay: dotAnimationStartDelay, dotAnimationRepeatInterval: dotAnimationRepeatInterval ) } // MARK: - Interaction @objc private func learnMore() { let urlAsString = "https://getsession.org/faq/#onion-routing" let url = URL(string: urlAsString)! UIApplication.shared.open(url) } } // MARK: - Line View private final class LineView: UIView { private let location: Location private let dotAnimationStartDelay: Double private let dotAnimationRepeatInterval: Double private var dotViewWidthConstraint: NSLayoutConstraint! private var dotViewHeightConstraint: NSLayoutConstraint! private var dotViewAnimationTimer: Timer! private let reachability: Reachability? = Environment.shared?.reachabilityManager.reachability enum Location { case top, middle, bottom } private lazy var dotView: UIView = { let result = UIView() result.themeBackgroundColor = .path_connected result.layer.themeShadowColor = .path_connected result.layer.shadowOffset = .zero result.layer.shadowPath = UIBezierPath( ovalIn: CGRect( origin: CGPoint.zero, size: CGSize(width: PathVC.dotSize, height: PathVC.dotSize) ) ).cgPath result.layer.cornerRadius = (PathVC.dotSize / 2) ThemeManager.onThemeChange(observer: result) { [weak result] theme, _ in result?.layer.shadowOpacity = (theme.interfaceStyle == .light ? 0.4 : 1) result?.layer.shadowRadius = (theme.interfaceStyle == .light ? 1 : 2) } return result }() init(location: Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double) { self.location = location self.dotAnimationStartDelay = dotAnimationStartDelay self.dotAnimationRepeatInterval = dotAnimationRepeatInterval super.init(frame: CGRect.zero) setUpViewHierarchy() registerObservers() } override init(frame: CGRect) { preconditionFailure("Use init(location:dotAnimationStartDelay:dotAnimationRepeatInterval:) instead.") } required init?(coder: NSCoder) { preconditionFailure("Use init(location:dotAnimationStartDelay:dotAnimationRepeatInterval:) instead.") } deinit { NotificationCenter.default.removeObserver(self) dotViewAnimationTimer?.invalidate() } private func setUpViewHierarchy() { let lineView = UIView() lineView.set(.width, to: Values.separatorThickness) lineView.themeBackgroundColor = .textPrimary addSubview(lineView) lineView.center(.horizontal, in: self) switch location { case .top: lineView.topAnchor.constraint(equalTo: centerYAnchor).isActive = true case .middle, .bottom: lineView.pin(.top, to: .top, of: self) } switch location { case .top, .middle: lineView.pin(.bottom, to: .bottom, of: self) case .bottom: lineView.bottomAnchor.constraint(equalTo: centerYAnchor).isActive = true } let dotSize = PathVC.dotSize dotViewWidthConstraint = dotView.set(.width, to: dotSize) dotViewHeightConstraint = dotView.set(.height, to: dotSize) addSubview(dotView) dotView.center(in: self) let repeatInterval: TimeInterval = self.dotAnimationRepeatInterval Timer.scheduledTimer(withTimeInterval: dotAnimationStartDelay, repeats: false) { [weak self] _ in self?.animate() self?.dotViewAnimationTimer = Timer.scheduledTimer(withTimeInterval: repeatInterval, repeats: true) { _ in self?.animate() } } switch (reachability?.isReachable(), OnionRequestAPI.paths.isEmpty) { case (.some(false), _), (nil, _): setStatus(to: .error) case (.some(true), true): setStatus(to: .connecting) case (.some(true), false): setStatus(to: .connected) } } private func registerObservers() { NotificationCenter.default.addObserver( self, selector: #selector(handleBuildingPathsNotification), name: .buildingPaths, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(handlePathsBuiltNotification), name: .pathsBuilt, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(reachabilityChanged), name: .reachabilityChanged, object: nil ) } private func animate() { expandDot() Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in self?.collapseDot() } } private func expandDot() { UIView.animate(withDuration: 0.5) { [weak self] in self?.dotView.transform = CGAffineTransform.scale(PathVC.expandedDotSize / PathVC.dotSize) } } private func collapseDot() { UIView.animate(withDuration: 0.5) { [weak self] in self?.dotView.transform = CGAffineTransform.scale(1) } } private func setStatus(to status: PathStatusView.Status) { dotView.themeBackgroundColor = status.themeColor dotView.layer.themeShadowColor = status.themeColor } @objc private func handleBuildingPathsNotification() { guard reachability?.isReachable() == true else { setStatus(to: .error) return } setStatus(to: .connecting) } @objc private func handlePathsBuiltNotification() { guard reachability?.isReachable() == true else { setStatus(to: .error) return } setStatus(to: .connected) } @objc private func reachabilityChanged() { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.reachabilityChanged() } return } guard reachability?.isReachable() == true else { setStatus(to: .error) return } setStatus(to: (!OnionRequestAPI.paths.isEmpty ? .connected : .connecting)) } }