mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			426 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			426 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
| // 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))
 | |
|     }
 | |
| }
 |