// // 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() // 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() 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) } }