Files

702 lines
26 KiB
Swift

//
// MainPresenter.swift
// PrivadoVPN
//
// Created by Juraldinio on 1/27/21.
// Copyright © 2021 Privado LLC. All rights reserved.
//
import Foundation
protocol MainModuleOutput: HeaderControllerModuleOutput {
var session: Session? { get }
var keychain: KeychainSwift { get }
var scheduler: Emitter<[NotificationLocal]> { get }
func navigate(to route: Route)
}
protocol MainPresenterInput {
var availableServerRecords: [ServerRecord] { get }
var bestRecord: ServerRecord? { get }
func refreshCustomer()
func switchServerRecord(current record: ServerRecord)
func connectServer()
func disconnectServer()
}
final class MainPresenter: ServerPanelOutput, KilSwitchPanelOutput, TrafficPanelOutput {
typealias FetchServersCompletion = () -> Void
private enum Constants {
enum Localized {
static let loadingInfo = "main.flow.loading.info"
static let pingServers = "main.flow.loading.ping"
//
static let modalTrafficTitle = "modal.traffic.title"
static let modalTrafficDescription = "modal.traffic.description"
static let modalTrafficButton = "modal.traffic.button"
//
static let modalCustomerTitle = "modal.customer.title"
static let modalCustomerDescription = "modal.customer.description"
static let modalCustomerButton = "modal.customer.button"
//
static let modalServerOverqutaTitle = "modal.overquota.server.title"
static let modalServerOverqutaButton = "modal.overquota.server.button"
//
static let serverUnavailable = "main.flow.serverUnavailable"
//
static let accountSuspendedTitle = "modal.suspended.title"
static let accountSuspendedDescrition = "modal.suspended.description"
static let accountSuspendedButton = "modal.suspended.button"
}
enum Actions {
static let customerRefresh = "privadovpn://customer"
}
enum Image {
static let overquta = "connection.lock"
}
enum Time {
#if PRODUCTION
static let serverListUpdateInterval: Int = 15
#else
static let serverListUpdateInterval: Int = 3
#endif
}
}
private let interactor: MainInteractorInput
private weak var output: MainModuleOutput?
weak var viewInput: MainControllerInput?
private var context: MainRecordsContext
private let type: Route.Main
private var serverRecords = [ServerRecord]()
private(set) var availableServerRecords = [ServerRecord]() // MainPresenterInput
private(set) var bestRecord: ServerRecord?
let updateEmitter = Emitter<Void>() // ConnectionPanelOutput, ServerPanelOutput
private var lastServerListUpdate = Date()
private var serverFetcherConveyor: ServerFetcherConveyor?
private let geoAssistant: GeoLocationAssistant
private let vpnAssistant: VPNAssistant
private var customerModule = CustomerModuleBuilder.shared()
private var notificationPanel: NotificationPanelViewInput?
// MARK: - Init
init(interactor: MainInteractorInput, output: MainModuleOutput, type: Route.Main) {
self.interactor = interactor
self.output = output
self.context = MainRecordsContext()
self.type = type
self.vpnAssistant = VPNAssistant(session: output.session, keychain: output.keychain)
self.geoAssistant = GeoLocationAssistant(interactor: interactor, vpnAssistant: self.vpnAssistant)
self.bind(using: output)
}
// MARK: - Private
private func bind(using output: MainModuleOutput) {
self.vpnAssistant.connectionStateEmitter.addReaction { [weak self] _, _ -> ShouldContinueReceiveNotifications in
self?.updateEmitter.invoke(())
return self.isExist
}
self.geoAssistant.geoLocationEmitter.addReaction { [weak self] _, result -> ShouldContinueReceiveNotifications in
guard let self = self else { return false }
guard let location = result.optional else { return true }
let oldLocation = self.context.geoLocation
self.context.geoLocation = location
if oldLocation != self.context.geoLocation {
self.updateEmitter.invoke(())
}
if self.vpnAssistant.connectionState == .disconnected && self.needToReloadServerlist() {
self.fetchServersInfo()
}
return true
}
output.scheduler.addReaction { [weak self] notifications -> ShouldContinueReceiveNotifications in
guard let self = self else { return false }
guard let record = notifications.sorted(by: { $0.date > $1.date }).first else { return true }
guard record.isVPNActive else {
self.showModal(with: record.title,
description: record.body,
button: NSLocalizedString(Constants.Localized.modalTrafficButton, comment: ""),
url: URL(string: record.loginUrlPath ?? ""))
self.disconnectServer()
return true
}
guard record.actions.isEmpty else {
let action = record.actions.compactMap { NotificationLocal.Action.init(rawValue: $0) }.first
switch action {
case .passReset:
if !record.subject.isEmpty && !record.body.isEmpty {
self.showModalNotification(with: record.body, closable: false)
}
self.disconnectServer()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.navigate(to: .login(type: .signoutPrefill))
}
case .conlimit:
guard let ip = record.localIp else { return true }
if !record.subject.isEmpty && !record.body.isEmpty {
self.showModalNotification(with: record.body, closable: false)
}
if !InterfaceAddress.instance.getAllAddresses().contains(ip) {
self.disconnectServer()
}
default:
break
}
return true
}
let traffic = TrafficPanelData(total: record.trafficTotalMb, left: record.trafficLeftMb)
self.trafficEmitter.invoke(traffic)
if let notificationPanel = self.notificationPanel {
self.update(notificationPanel: notificationPanel, with: record)
}
return true
}
self.customerModule.customerEmitter.addReaction { [weak self] customer -> ShouldContinueReceiveNotifications in
guard let self = self else { return false }
guard let customer = customer else {
self.showModal(with: NSLocalizedString(Constants.Localized.modalCustomerTitle, comment: ""),
description: NSLocalizedString(Constants.Localized.modalCustomerDescription, comment: ""),
button: NSLocalizedString(Constants.Localized.modalCustomerButton, comment: ""),
url: URL(string: Constants.Actions.customerRefresh))
return true
}
self.update(customer: customer)
switch self.type {
case .verifyAutologin:
openApplicationRoute(.tutorial)
default:
break
}
return true
}
}
// swiftlint:disable function_body_length
private func fetchServersInfo(_ completion: FetchServersCompletion? = nil) {
guard !self.serverFetcherConveyor.isExist
, let customer = self.customerModule.customer else { return }
let lastServer = self.context.selectedRecord
self.serverRecords = []
self.context = MainRecordsContext.empty()
let conveyor = ServerFetcherConveyor(interactor: self.interactor)
let viewInput = self.viewInput
// Progress
conveyor.pingProgress = { progress in
DispatchQueue.main.async {
// let title = try? NSLocalizedString(Constants.LocalizedString.pingServers, comment: "")
// .replace("#PERCENTS#", replacement: "\(Int((progress * 100).rounded(.down)))")
viewInput?.loading(progress: true)
}
}
// State change
conveyor.stateChangeClosure = { state, success in
switch state {
case .fetch where success == false:
DispatchQueue.main.async {
viewInput?.loading(progress: false)
viewInput?.activate(components: [.notification])
}
case .convert where success == true:
DispatchQueue.main.async {
// let title = try? NSLocalizedString(Constants.LocalizedString.pingServers, comment: "").replace("#PERCENTS#", replacement: "0")
viewInput?.loading(progress: true)
}
default: break
}
}
self.serverFetcherConveyor = conveyor
// Completion
conveyor
.run()
.onResult { [weak self] result in
guard let self = self else { return }
guard let operationsResult = result.optional else {
DispatchQueue.main.async {
self.viewInput?.loading(progress: false)
self.viewInput?.activate(components: [.notification])
}
return
}
self.serverRecords = operationsResult.serverRecords ?? []
self.context = operationsResult.context
self.serverFetcherConveyor = nil
// For Overquota plan we must change all behaviour.
let plan = customer.plan ?? .unrecognized(value: "")
if self.updateServerList(using: plan, preferred: self.interactor.preferredServer)
, let record = self.context.selectedRecord {
if let server = lastServer {
if self.availableServerRecords.contains(where: { $0.city == server.city }) {
self.context.selectedRecord = server
self.vpnAssistant.selected(server: server.server)
if let completion = completion {
completion()
}
} else {
self.notificationPanel?.notificationMessage(.error(message: NSLocalizedString(Constants.Localized.serverUnavailable, comment: ""), icon: nil))
self.showModalNotification(with: NSLocalizedString(Constants.Localized.serverUnavailable, comment: ""), closable: true)
self.vpnAssistant.selected(server: record.server)
}
self.lastServerListUpdate = Date()
return
} else {
self.vpnAssistant.selected(server: record.server)
if self.interactor.preferredServer.isExist {
self.reconnect(to: record.server)
}
}
}
self.lastServerListUpdate = Date()
let components = self.activationComponents(for: plan)
DispatchQueue.main.async {
self.viewInput?.activate(components: components)
}
openApplicationRoute(.killSwitch(type: .refresh))
}
/*DispatchQueue.main.async {
let route = Route.modal(settings: .closable(title: "Oh no! You ran out of data", description: "You can gat unlimited", buttonTitle: "Get more data", action: nil))
self.output?.navigate(to: route)
}*/
}
// swiftlint:enable function_body_length
private func update(customer: Customer) {
guard let isVPNActive = customer.isVPNActive else {
// We are premium?!
self.viewInput?.loading(progress: true)
self.fetchServersInfo()
return
}
guard customer.plan == .overquota || customer.isPremium || isVPNActive else {
self.showModal(with: NSLocalizedString(Constants.Localized.modalTrafficTitle, comment: ""),
description: NSLocalizedString(Constants.Localized.modalTrafficDescription, comment: ""),
button: NSLocalizedString(Constants.Localized.modalTrafficButton, comment: ""),
url: URL(string: customer.loginUrl ?? ""))
self.disconnectServer()
return
}
self.viewInput?.loading(progress: true)
self.fetchServersInfo()
}
private func update(notificationPanel: NotificationPanelViewInput, with notification: NotificationLocal) {
let message: NotificationPanelMessage
if let linkPath = notification.loginUrlPath
, let url = URL(string: linkPath) {
message = .link(message: notification.title, link: "", url: url)
} else {
message = .info(message: notification.title)
}
notificationPanel.notificationMessage(message)
}
private func showModal(with title: String, description: String, button: String, url: URL?) {
let settings: Route.ModalSettings = .modal(title: title,
description: description,
buttonTitle: button,
action: url)
let modal = Route.modal(settings: settings)
self.output?.navigate(to: modal)
}
private func showModalNotification(with title: String, closable: Bool) {
let settings: Route.ModalSettings = .modalNotification(closable: closable, title: title)
let modal = Route.modal(settings: settings)
self.output?.navigate(to: modal)
}
private func updateServerList(using plan: Customer.Plan, preferred: PreferredServerType?) -> Bool {
switch plan {
case .unrecognized:
self.availableServerRecords = []
self.context.bestRecord = nil
self.bestRecord = nil
return false
default:
self.availableServerRecords = self.serverRecords
.sorted(by: { $0.group > $1.group })
.reduce(into: []) { acc, record in
if acc.first(where: { $0.group == record.group && $0.countryCode == record.countryCode && $0.city == record.city }).isExist { return }
let records = acc.filter({ $0.countryCode == record.countryCode && $0.city == record.city })
if records.isEmpty {
switch plan {
case .premium where record.group == .premium:
acc.append(record)
return
case .freemium:
acc.append(record)
return
case .overquota where record.group == .overquota:
acc.append(record)
return
default: break
}
}
if case .freemium = plan, (!records.contains(where: { $0.isFreemium }) || record.isPremium) {
acc.append(record)
} else if case .overquota = plan, !records.contains(where: { $0.isOverquota }) {
acc.append(record)
} else {
return
}
}
}
let searchServers: [ServerRecord]
switch plan {
case .freemium: searchServers = self.availableServerRecords.filter { $0.isFreemium }
case .overquota: searchServers = self.availableServerRecords.filter { $0.isOverquota }
default: searchServers = self.availableServerRecords
}
let bestRecord: ServerRecord?
if let location = self.context.geoLocation
, let latitude = Double(location.lat)
, let longitude = Double(location.long) {
bestRecord = BestRecord.calculate(for: searchServers, latitude: latitude, longitude: longitude)?.record
} else {
bestRecord = BestRecord.calculate(for: searchServers, latitude: 0.0, longitude: 0.0)?.record
}
let record: ServerRecord?
if preferred == .last
, let server = interactor.restoreServer()
, let found = searchServers.first(where: { $0.server.name == server.name}) {
record = found
} else if preferred == .random {
let randomIndex = Int.random(in: 0..<(searchServers.count))
if let searched = searchServers[safe: randomIndex] {
record = searched
} else {
record = bestRecord
}
} else {
record = bestRecord
}
self.context.bestRecord = bestRecord
self.bestRecord = bestRecord
self.context.selectedRecord = record ?? bestRecord
return true
}
private func activationComponents(for plan: Customer.Plan) -> [MainControllerComponent] {
switch plan {
case .unrecognized: return []
case .freemium: return [.notification, .connection, .servers, .killswitch, .remaining]
case .overquota: return [.notification, .connection, .servers, .killswitch, .remaining]
case .premium: return [.notification, .connection, .servers, .killswitch]
}
}
private func needToReloadServerlist() -> Bool {
guard let minutes = Calendar.current.dateComponents([.minute], from: self.lastServerListUpdate, to: Date()).minute else { return false }
return minutes >= Constants.Time.serverListUpdateInterval
}
func isCustomerSuspended() -> Bool {
if let customer = self.customerModule.customer {
switch customer.plan {
case .premium:
if let isActive = customer.isVPNActive, !isActive {
let url = customer.loginUrl.flatMap { URL(string: $0) }
self.showSuspendedModal(with: url)
return true
}
default:
break
}
} else {
if let record = self.session?.currentRecord {
// 2 means it's usenet suspended
if record.accountType == 2 {
self.showSuspendedModal(with: nil)
return true
}
}
}
return false
}
private func showSuspendedModal(with url: URL?) {
let settings = Route.ModalSettings.closable(title: NSLocalizedString(Constants.Localized.accountSuspendedTitle, comment: ""),
description: NSLocalizedString(Constants.Localized.accountSuspendedDescrition, comment: ""),
buttonTitle: NSLocalizedString(Constants.Localized.accountSuspendedButton, comment: ""),
action: url)
self.output?.navigate(to: .modal(settings: settings))
}
private func reconnect(to server: Server) {
guard !self.isCustomerSuspended() else { return }
self.vpnAssistant.reconnect(to: server)
}
// MARK: - ServerPanelOutput
func update(for panel: ServerPanelInput) {
guard let record = self.context.selectedRecord else {
panel.update(state: .undefined)
return
}
let plan = self.customerModule.customer?.plan ?? .freemium
let state: ServerPanelState = plan == .overquota
? .overqoute(country: record.countryCode, city: record.city, flag: record.countryCode)
: .selected(country: record.countryCode, city: record.city, flag: record.countryCode)
panel.update(state: state)
}
func selectRequired(from panel: ServerPanelInput) {
guard let customer = self.customerModule.customer else {
self.output?.navigate(to: .servers)
return
}
let route: Route
if customer.plan == .overquota {
let settings = Route.ModalSettings.image(named: Constants.Image.overquta,
title: NSLocalizedString(Constants.Localized.modalServerOverqutaTitle, comment: ""),
buttonTitle: NSLocalizedString(Constants.Localized.modalServerOverqutaButton, comment: ""),
action: URL(string: customer.loginUrl ?? ""))
route = .modal(settings: settings)
} else {
route = .servers
}
self.output?.navigate(to: route)
}
// MARK: - KilSwitchPanelOutput
// MARK: - TrafficPanelOutput
let trafficEmitter = Emitter<TrafficPanelData>()
var session: Session? { self.output?.session }
var customerInput: CustomerModuleInput { self.customerModule }
func navigate(to url: URL) { self.output?.navigate(to: .navigate(url: url)) }
}
// MARK: - MainPresenterInput
extension MainPresenter: MainPresenterInput {
func refreshCustomer() {
guard let customer = self.customerModule.customer else {
self.customerModule.requireCustomerInfo()
self.viewInput?.loading(progress: false)
return
}
self.update(customer: customer)
}
func switchServerRecord(current record: ServerRecord) {
guard self.vpnAssistant.isDifferent(server: record.server) else { return }
self.context.selectedRecord = record
self.interactor.storeServer(record.server)
if self.vpnAssistant.connectionState != .disconnected {
self.reconnect(to: record.server)
} else {
self.vpnAssistant.selected(server: record.server)
self.vpnAssistant.connect()
}
self.updateEmitter.invoke(())
}
}
// MARK: - MainControllerOutput
extension MainPresenter: MainControllerOutput {
func viewIsReady() {
self.refreshCustomer()
self.viewInput?.loading(progress: true)
}
}
// MARK: - NotificationPanelOutput
extension MainPresenter: NotificationPanelOutput {
func ready(panel: NotificationPanelViewInput) {
self.notificationPanel = panel
panel.notificationHide()
guard let record = self.output?.scheduler.value?.last , !record.subject.isEmpty else { return }
self.update(notificationPanel: panel, with: record)
}
}
// MARK: - ConnectionPanelOutput
extension MainPresenter: ConnectionPanelOutput {
func update(for panel: ConnectionPanelInput) {
let connectionState = self.vpnAssistant.connectionState
guard let location = self.context.geoLocation else {
switch connectionState {
case .connecting:
panel.update(state: .connecting)
case .disconnecting(true),
.disconnecting(false):
panel.update(state: .disconnecting)
case .reconnect:
panel.update(state: .reconnecting)
default:
let state: ConnectionPanelState = [.prepare, .connecting, .connected].contains(where: { $0 == connectionState })
? .connecting
: .disconnecting
panel.update(state: state)
}
return
}
switch connectionState {
case .connected:
panel.update(state: .connected(ip: location.ip, secured: true))
case .connecting:
panel.update(state: .connecting)
case .disconnecting:
panel.update(state: .disconnecting)
case .disconnected:
panel.update(state: .disconnected(ip: location.ip, city: location.city ?? "", country: location.country ?? ""))
case .reconnect:
panel.update(state: .reconnecting)
default: break
}
}
func connectServer() {
guard !self.isCustomerSuspended() else { return }
switch self.vpnAssistant.connectionState {
case .disconnected:
CometLogger.shared.event(name: PrivadoConstants.Event.user,
attributes: [PrivadoConstants.Event.Attributes.connect: true],
secured: nil)
self.context.clearGeo()
if self.needToReloadServerlist() {
self.fetchServersInfo {
self.vpnAssistant.connect()
}
return
}
self.vpnAssistant.connect()
default:
break
}
}
func disconnectServer() {
switch self.vpnAssistant.connectionState {
case .connected:
CometLogger.shared.event(name: PrivadoConstants.Event.user,
attributes: [PrivadoConstants.Event.Attributes.connect: false],
secured: nil)
self.context.clearGeo()
self.vpnAssistant.disconnect()
default:
break
}
}
func navigate(to route: Route) {
self.output?.navigate(to: route)
}
}