324 lines
16 KiB
Swift
324 lines
16 KiB
Swift
import NVActivityIndicatorView
|
||
import SwiftCSV
|
||
|
||
final class PathVC : BaseVC {
|
||
|
||
static let ipv4Table = try! CSV(name: "GeoLite2-Country-Blocks-IPv4", extension: "csv", bundle: .main, delimiter: ",", encoding: .utf8, loadColumns: true)!
|
||
static let countryNamesTable = try! CSV(name: "GeoLite2-Country-Locations-English", extension: "csv", bundle: .main, delimiter: ",", encoding: .utf8, loadColumns: true)!
|
||
static var countryNamesCache: [String:String] = [:]
|
||
|
||
// MARK: Components
|
||
private lazy var pathStackView: UIStackView = {
|
||
let result = UIStackView()
|
||
result.axis = .vertical
|
||
return result
|
||
}()
|
||
|
||
private lazy var spinner: NVActivityIndicatorView = {
|
||
let result = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil)
|
||
result.set(.width, to: 64)
|
||
result.set(.height, to: 64)
|
||
return result
|
||
}()
|
||
|
||
private lazy var rebuildPathButton: Button = {
|
||
let result = Button(style: .prominentOutline, size: .large)
|
||
result.setTitle(NSLocalizedString("Rebuild Path", comment: ""), for: UIControl.State.normal)
|
||
result.addTarget(self, action: #selector(rebuildPath), for: UIControl.Event.touchUpInside)
|
||
return result
|
||
}()
|
||
|
||
// MARK: Lifecycle
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
setUpGradientBackground()
|
||
setUpNavBar()
|
||
setUpViewHierarchy()
|
||
registerObservers()
|
||
}
|
||
|
||
private func setUpNavBar() {
|
||
setUpNavBarStyle()
|
||
setNavBarTitle(NSLocalizedString("Path", comment: ""))
|
||
// Set up close button
|
||
let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close))
|
||
closeButton.tintColor = Colors.text
|
||
navigationItem.leftBarButtonItem = closeButton
|
||
let learnMoreButton = UIBarButtonItem(image: #imageLiteral(resourceName: "QuestionMark").scaled(to: CGSize(width: 24, height: 24)), style: .plain, target: self, action: #selector(learnMore))
|
||
learnMoreButton.tintColor = Colors.text
|
||
navigationItem.rightBarButtonItem = learnMoreButton
|
||
}
|
||
|
||
private func setUpViewHierarchy() {
|
||
// Set up explanation label
|
||
let explanationLabel = UILabel()
|
||
explanationLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity)
|
||
explanationLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||
explanationLabel.text = NSLocalizedString("Session hides your IP by bouncing your messages through several Service Nodes in Session’s decentralized network. These are the Service Nodes currently being used by your device:", comment: "")
|
||
explanationLabel.numberOfLines = 0
|
||
explanationLabel.textAlignment = .center
|
||
explanationLabel.lineBreakMode = .byWordWrapping
|
||
// 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 rebuildPathButtonContainer = UIView()
|
||
rebuildPathButtonContainer.addSubview(rebuildPathButton)
|
||
rebuildPathButton.pin(.leading, to: .leading, of: rebuildPathButtonContainer, withInset: 80)
|
||
rebuildPathButton.pin(.top, to: .top, of: rebuildPathButtonContainer)
|
||
rebuildPathButtonContainer.pin(.trailing, to: .trailing, of: rebuildPathButton, withInset: 80)
|
||
rebuildPathButtonContainer.pin(.bottom, to: .bottom, of: rebuildPathButton)
|
||
// Set up spacers
|
||
let topSpacer = UIView.vStretchingSpacer()
|
||
let bottomSpacer = UIView.vStretchingSpacer()
|
||
// Set up main stack view
|
||
let mainStackView = UIStackView(arrangedSubviews: [ explanationLabel, topSpacer, pathStackViewContainer, bottomSpacer, rebuildPathButtonContainer ])
|
||
mainStackView.axis = .vertical
|
||
mainStackView.alignment = .fill
|
||
mainStackView.layoutMargins = UIEdgeInsets(top: Values.largeSpacing, left: Values.largeSpacing, bottom: Values.largeSpacing, 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)
|
||
}
|
||
|
||
deinit {
|
||
NotificationCenter.default.removeObserver(self)
|
||
}
|
||
|
||
// MARK: Updating
|
||
@objc private func handleBuildingPathsNotification() { update() }
|
||
@objc private func handlePathsBuiltNotification() { update() }
|
||
|
||
private func update() {
|
||
pathStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||
if OnionRequestAPI.paths.count >= OnionRequestAPI.pathCount {
|
||
let pathToDisplay = OnionRequestAPI.paths.first!
|
||
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: NSLocalizedString("You", comment: ""), subtitle: nil, location: .top, dotAnimationStartDelay: 1, dotAnimationRepeatInterval: dotAnimationRepeatInterval)
|
||
let destinationRow = getPathRow(title: NSLocalizedString("Destination", comment: ""), 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
|
||
self.rebuildPathButton.layer.borderColor = Colors.accent.cgColor
|
||
self.rebuildPathButton.setTitleColor(Colors.accent, for: UIControl.State.normal)
|
||
}
|
||
rebuildPathButton.isEnabled = true
|
||
} else {
|
||
spinner.startAnimating()
|
||
UIView.animate(withDuration: 0.25) {
|
||
self.spinner.alpha = 1
|
||
self.rebuildPathButton.layer.borderColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity).cgColor
|
||
self.rebuildPathButton.setTitleColor(Colors.text.withAlphaComponent(Values.unimportantElementOpacity), for: UIControl.State.normal)
|
||
}
|
||
rebuildPathButton.isEnabled = false
|
||
}
|
||
}
|
||
|
||
// 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: Values.pathRowDotSize)
|
||
lineView.set(.height, to: Values.pathRowHeight)
|
||
let titleLabel = UILabel()
|
||
titleLabel.textColor = Colors.text
|
||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||
titleLabel.text = title
|
||
titleLabel.lineBreakMode = .byTruncatingTail
|
||
let titleStackView = UIStackView(arrangedSubviews: [ titleLabel ])
|
||
titleStackView.axis = .vertical
|
||
if let subtitle = subtitle {
|
||
let subtitleLabel = UILabel()
|
||
subtitleLabel.textColor = Colors.text
|
||
subtitleLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||
subtitleLabel.text = subtitle
|
||
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: LokiAPITarget, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double, isGuardSnode: Bool) -> UIStackView {
|
||
var snodeIP = snode.ip
|
||
func getCountry() -> String {
|
||
if let country = PathVC.countryNamesCache[snode.address] { return country }
|
||
if let ipv4TableIndex = PathVC.ipv4Table.namedColumns["network"]!.firstIndex(where: { $0.starts(with: snodeIP) }) {
|
||
let countryID = PathVC.ipv4Table.namedColumns["registered_country_geoname_id"]![ipv4TableIndex]
|
||
if let countryNamesTableIndex = PathVC.countryNamesTable.namedColumns["geoname_id"]!.firstIndex(of: countryID) {
|
||
let country = PathVC.countryNamesTable.namedColumns["country_name"]![countryNamesTableIndex]
|
||
PathVC.countryNamesCache[snode.address] = country
|
||
return country
|
||
}
|
||
}
|
||
if snodeIP.contains(".") && !snodeIP.hasSuffix(".") { // The fuzziest we want to go is xxx.x
|
||
snodeIP.removeLast()
|
||
if snodeIP.hasSuffix(".") { snodeIP.removeLast() }
|
||
return getCountry()
|
||
} else {
|
||
return "Unknown Country"
|
||
}
|
||
}
|
||
let title = isGuardSnode ? NSLocalizedString("Guard Node", comment: "") : NSLocalizedString("Service Node", comment: "")
|
||
return getPathRow(title: title, subtitle: getCountry(), location: location, dotAnimationStartDelay: dotAnimationStartDelay, dotAnimationRepeatInterval: dotAnimationRepeatInterval)
|
||
}
|
||
|
||
// MARK: Interaction
|
||
@objc private func close() {
|
||
dismiss(animated: true, completion: nil)
|
||
}
|
||
|
||
@objc private func learnMore() {
|
||
let urlAsString = "https://getsession.org/faq/#onion-routing"
|
||
let url = URL(string: urlAsString)!
|
||
UIApplication.shared.open(url)
|
||
}
|
||
|
||
@objc private func rebuildPath() {
|
||
// Dispatch async on the main queue to avoid nested write transactions
|
||
DispatchQueue.main.async {
|
||
let storage = OWSPrimaryStorage.shared()
|
||
storage.dbReadWriteConnection.readWrite { transaction in
|
||
storage.clearOnionRequestPaths(in: transaction)
|
||
}
|
||
}
|
||
OnionRequestAPI.guardSnodes = []
|
||
OnionRequestAPI.paths = []
|
||
let _ = OnionRequestAPI.buildPaths()
|
||
}
|
||
}
|
||
|
||
// 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!
|
||
|
||
enum Location {
|
||
case top, middle, bottom
|
||
}
|
||
|
||
private lazy var dotView: UIView = {
|
||
let result = UIView()
|
||
result.layer.cornerRadius = Values.pathRowDotSize / 2
|
||
let glowConfiguration = UIView.CircularGlowConfiguration(size: Values.pathRowDotSize, color: Colors.accent, isAnimated: true, radius: isLightMode ? 2 : 4)
|
||
result.setCircularGlow(with: glowConfiguration)
|
||
result.backgroundColor = Colors.accent
|
||
return result
|
||
}()
|
||
|
||
init(location: Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double) {
|
||
self.location = location
|
||
self.dotAnimationStartDelay = dotAnimationStartDelay
|
||
self.dotAnimationRepeatInterval = dotAnimationRepeatInterval
|
||
super.init(frame: CGRect.zero)
|
||
setUpViewHierarchy()
|
||
}
|
||
|
||
override init(frame: CGRect) {
|
||
preconditionFailure("Use init(location:dotAnimationStartDelay:dotAnimationRepeatInterval:) instead.")
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
preconditionFailure("Use init(location:dotAnimationStartDelay:dotAnimationRepeatInterval:) instead.")
|
||
}
|
||
|
||
private func setUpViewHierarchy() {
|
||
let lineView = UIView()
|
||
lineView.set(.width, to: Values.pathRowLineThickness)
|
||
lineView.backgroundColor = Colors.text
|
||
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 = Values.pathRowDotSize
|
||
dotViewWidthConstraint = dotView.set(.width, to: dotSize)
|
||
dotViewHeightConstraint = dotView.set(.height, to: dotSize)
|
||
addSubview(dotView)
|
||
dotView.center(in: self)
|
||
Timer.scheduledTimer(withTimeInterval: dotAnimationStartDelay, repeats: false) { [weak self] _ in
|
||
guard let strongSelf = self else { return }
|
||
strongSelf.animate()
|
||
strongSelf.dotViewAnimationTimer = Timer.scheduledTimer(withTimeInterval: strongSelf.dotAnimationRepeatInterval, repeats: true) { _ in
|
||
guard let strongSelf = self else { return }
|
||
strongSelf.animate()
|
||
}
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
dotViewAnimationTimer?.invalidate()
|
||
}
|
||
|
||
private func animate() {
|
||
expandDot()
|
||
Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in
|
||
self?.collapseDot()
|
||
}
|
||
}
|
||
|
||
private func expandDot() {
|
||
let newSize = Values.pathRowExpandedDotSize
|
||
let newGlowRadius: CGFloat = isLightMode ? 6 : 8
|
||
updateDotView(size: newSize, glowRadius: newGlowRadius)
|
||
}
|
||
|
||
private func collapseDot() {
|
||
let newSize = Values.pathRowDotSize
|
||
let newGlowRadius: CGFloat = isLightMode ? 2 : 4
|
||
updateDotView(size: newSize, glowRadius: newGlowRadius)
|
||
}
|
||
|
||
private func updateDotView(size: CGFloat, glowRadius: CGFloat) {
|
||
let frame = CGRect(center: dotView.center, size: CGSize(width: size, height: size))
|
||
dotViewWidthConstraint.constant = size
|
||
dotViewHeightConstraint.constant = size
|
||
UIView.animate(withDuration: 0.5) {
|
||
self.layoutIfNeeded()
|
||
self.dotView.frame = frame
|
||
self.dotView.layer.cornerRadius = size / 2
|
||
let glowColor = Colors.accent
|
||
let glowConfiguration = UIView.CircularGlowConfiguration(size: size, color: glowColor, isAnimated: true, radius: glowRadius)
|
||
self.dotView.setCircularGlow(with: glowConfiguration)
|
||
self.dotView.backgroundColor = Colors.accent
|
||
}
|
||
}
|
||
}
|