import UIKit import Lottie import KeychainSwift import SafariServices import Reachability class HomeViewController: UIViewController, HomeDelegate { private var observer: NSObjectProtocol? private let reauthenticationNeededKey = "ReauthenticationNeededKey" @IBOutlet weak var bluetoothStatusOffView: UIView! @IBOutlet weak var bluetoothPermissionOffView: UIView! @IBOutlet weak var locationPermissionsView: UIView! @IBOutlet weak var inactiveSettingsContent: UIView! @IBOutlet weak var inactiveTokenExpiredView: UIView! @IBOutlet weak var shareView: UIView! @IBOutlet weak var inactiveAppSectionView: UIView! @IBOutlet weak var activeAppSectionView: UIView! @IBOutlet weak var covidInactiveLabel: UILabel! @IBOutlet weak var covidActiveLabel: UILabel! @IBOutlet weak var animatedBluetoothHeader: UIView! @IBOutlet weak var versionNumberLabel: UILabel! @IBOutlet weak var versionView: UIView! @IBOutlet weak var uploadView: UIView! @IBOutlet weak var uploadDateView: UIView! @IBOutlet weak var uploadDateLabel: UILabel! @IBOutlet weak var pairingRequestsLabel: UILabel! @IBOutlet weak var appActiveSubtitleLabel: UILabel! @IBOutlet weak var uploadDataContentLabel: UILabel! @IBOutlet weak var uploadDataTitleLabel: UILabel! @IBOutlet weak var covidStatisticsSection: UIView! @IBOutlet weak var covidStatisticsContainer: UIView! @IBOutlet weak var contentStackView: UIStackView! @IBOutlet weak var statisticsSectionHeight: NSLayoutConstraint! @IBOutlet weak var scrollView: UIScrollView! var appActiveSubtitleLabelInitialColor: UIColor? var lottieBluetoothView: AnimationView! let covidStatisticsViewController: CovidStatisticsViewController = CovidStatisticsViewController(nibName: "CovidStatisticsView", bundle: nil) var allPermissionOn = true var registrationNeeded: Bool { return UserDefaults.standard.bool(forKey: reauthenticationNeededKey) } var bluetoothStatusOn = true var bluetoothPermissionOn = true var pushNotificationOn = true var locationPermissionOn = true var shouldShowUpdateApp = false var didUploadData: Bool { let uploadTimestamp = UserDefaults.standard.double(forKey: "uploadDataDate") let lastUpload = Date(timeIntervalSince1970: uploadTimestamp) return Date().timeIntervalSince(lastUpload) < 86400 * 14 } var dataUploadedAttributedString: NSAttributedString? { let uploadTimestamp = UserDefaults.standard.double(forKey: "uploadDataDate") if(uploadTimestamp > 0){ let lastUpload = Date(timeIntervalSince1970: uploadTimestamp) let dateFormatterPrint = DateFormatter() dateFormatterPrint.dateFormat = "dd MMM yyyy" let formattedDate = dateFormatterPrint.string(from: lastUpload) let newAttributedString = NSMutableAttributedString( string: String.localizedStringWithFormat( "InformationUploaded".localizedString(comment: "Information uploaded template"), formattedDate) ) guard let dateRange = newAttributedString.string.range(of: formattedDate) else { return nil } let nsRange = NSRange(dateRange, in: newAttributedString.string) newAttributedString.addAttribute(.font, value: UIFont.preferredFont(for: .body, weight: .bold), range: nsRange) return newAttributedString } return nil } var shouldShowUploadDate: Bool { let uploadTimestamp = UserDefaults.standard.double(forKey: "uploadDataDate") if(uploadTimestamp > 0){ let lastUpload = Date(timeIntervalSince1970: uploadTimestamp) return Date().timeIntervalSince(lastUpload) <= 86400 * 14 } return false } var _preferredScreenEdgesDeferringSystemGestures: UIRectEdge = [] override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { return _preferredScreenEdgesDeferringSystemGestures } private let reachability = try! Reachability() override func viewDidLoad() { super.viewDidLoad() title = "home_bottom_nav".localizedString() tabBarItem.image = UIImage(named: "ausCheck") covidStatisticsViewController.homeDelegate = self scrollView.refreshControl = UIRefreshControl() scrollView.refreshControl?.addTarget(self, action: #selector(refreshControlEvent), for: .valueChanged) observer = NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [unowned self] notification in self.refreshView() } BluetraceManager.shared.sensorDidUpdateStateCallback = { state, sensorType in if let sensor = sensorType, sensor == .AWAKE { self.locationPermissionOn = state == .on self.toggleViews() } } if !shouldShowPolicyUpdateMessage() { startAllSensors() } updateAnimationViewWithAnimationName(name: "Spinner_home") NotificationCenter.default.addObserver(self, selector: #selector(enableDeferringSystemGestures(_:)), name: .enableDeferringSystemGestures, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(disableDeferringSystemGestures(_:)), name: .disableDeferringSystemGestures, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(disableUserInteraction(_:)), name: .disableUserInteraction, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(enableUserInteraction(_:)), name: .enableUserInteraction, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(appWillResignActive(_:)), name: UIApplication.willResignActiveNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) if let versionNumber = Bundle.main.versionShort, let buildNumber = Bundle.main.version { self.versionNumberLabel.text = String.localizedStringWithFormat( "home_version_number_ios".localizedString(comment: "Version number template"), versionNumber,buildNumber ) } else { toggleViewVisibility(view: versionView, isVisible: false) } // Some translators are adding the ** for this link, just cleaning that up. let pairingRequestString = NSLocalizedString("PairingRequestsInfo", comment: "Text explaining COVIDSafe does not send pairing requests").replacingOccurrences(of: "*", with: "") let pairingRequestText = NSMutableAttributedString(string: pairingRequestString, attributes: [.font: UIFont.preferredFont(forTextStyle: .body)]) let pairingRequestUnderlinedString = NSLocalizedString("PairingRequestsInfoUnderline", comment: "section of text that should be underlined from the PairingRequestsInfo text") if let requestsRange = pairingRequestText.string.range(of: pairingRequestUnderlinedString) { let nsRange = NSRange(requestsRange, in: pairingRequestText.string) pairingRequestText.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: nsRange) pairingRequestsLabel.attributedText = pairingRequestText } uploadDataTitleLabel.font = UIFont.preferredFont(for: .title3, weight: .bold) uploadDataContentLabel.font = UIFont.preferredFont(for: .callout, weight: .bold) covidInactiveLabel.font = UIFont.preferredFont(for: .title3, weight: .bold) appActiveSubtitleLabelInitialColor = appActiveSubtitleLabel.textColor setupStatisticsView() } deinit { if let observer = observer { NotificationCenter.default.removeObserver(observer) } reachability.stopNotifier() NotificationCenter.default.removeObserver(self, name: .reachabilityChanged, object: reachability) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) NotificationCenter.default.addObserver(self, selector: #selector(reachabilityChanged(note:)), name: .reachabilityChanged, object: reachability) do { try reachability.startNotifier() } catch { DLog("Could not start reachability notifier") } self.toggleViews() if !UserDefaults.standard.bool(forKey: "PerformHealthChecks") { DispatchQueue.global(qos: .background).async { self.getMessagesFromServer() } } performHealthCheck() covidStatisticsViewController.statisticsDelegate = self } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.lottieBluetoothView?.play() self.becomeFirstResponder() self.updateJWTKeychainAccess() if shouldShowPolicyUpdateMessage() { showPolicyUpdateMessage() } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.lottieBluetoothView?.stop() reachability.stopNotifier() NotificationCenter.default.removeObserver(self, name: .reachabilityChanged, object: reachability) } override var canBecomeFirstResponder: Bool { return true } override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { #if DEBUG guard let allowDebug = PlistHelper.getBoolFromInfoPlist(withKey: "Allow_Debug", plistName: "CovidSafe-config") else { return } if allowDebug == true && event?.subtype == .motionShake { guard let debugVC = UIStoryboard(name: "Debug", bundle: nil).instantiateInitialViewController() else { return } debugVC.modalTransitionStyle = .coverVertical debugVC.modalPresentationStyle = .fullScreen present(debugVC, animated: true, completion: nil) } #endif } func updateJWTKeychainAccess() { let hasUpdatedKeychainAccess = UserDefaults.standard.bool(forKey: "HasUpdatedKeychainAccess") let keychain = KeychainSwift() if (!hasUpdatedKeychainAccess) { if let jwt = keychain.get("JWT_TOKEN") { if (keychain.set(jwt, forKey: "JWT_TOKEN", withAccess: .accessibleAfterFirstUnlock)) { DLog("Updated access class on JWT") UserDefaults.standard.set(true, forKey: "HasUpdatedKeychainAccess") } } } } fileprivate func navigateToSettings() { tabBarController?.selectedIndex = 2 } fileprivate func refreshView() { toggleViews() performHealthCheck() covidStatisticsViewController.getStatistics() } fileprivate func setupStatisticsView() { addChild(covidStatisticsViewController) covidStatisticsViewController.view.translatesAutoresizingMaskIntoConstraints = false covidStatisticsContainer.addSubview(covidStatisticsViewController.view) NSLayoutConstraint.activate([ covidStatisticsViewController.view.leadingAnchor.constraint(equalTo: covidStatisticsContainer.leadingAnchor), covidStatisticsViewController.view.trailingAnchor.constraint(equalTo: covidStatisticsContainer.trailingAnchor), covidStatisticsViewController.view.topAnchor.constraint(equalTo: covidStatisticsContainer.topAnchor), covidStatisticsViewController.view.bottomAnchor.constraint(equalTo: covidStatisticsContainer.bottomAnchor) ]) covidStatisticsViewController.didMove(toParent: self) } fileprivate func toggleViews() { DispatchQueue.main.async { UNUserNotificationCenter.current().getNotificationSettings(completionHandler: { [weak self] settings in DispatchQueue.main.async { self?.readPermissions(notificationSettings: settings) self?.toggleBluetoothStatusView() self?.toggleBluetoothPermissionStatusView() self?.toggleLocationPermissionStatusView() self?.toggleHeaderView() self?.toggleUploadView() self?.toggleUploadDateView() self?.toggleShareView() self?.toggleStatisticsView() self?.toggleRegistrationNeededView() } }) } } fileprivate func performHealthCheck() { if UserDefaults.standard.bool(forKey: "PerformHealthChecks") { UserDefaults.standard.set(false, forKey: "PerformHealthChecks") guard allPermissionOn else { // if all permission not ON, stay in home screen return } getMessagesFromServer(force: true) { if (self.reachability.connection != .cellular && self.reachability.connection != .wifi) || self.shouldShowUpdateApp { DispatchQueue.main.async { self.navigateToSettings() } } else if self.allPermissionOn && self.isInternetReachable() && !self.shouldShowUpdateApp { DispatchQueue.main.async { self.covidActiveLabel.text = "home_header_active_title_thanks".localizedString() } } } } } func isInternetReachable() -> Bool { return reachability.connection == .cellular || reachability.connection == .wifi } fileprivate func toggleUploadDateView() { if shouldShowUploadDate, let lastUploadText = self.dataUploadedAttributedString { uploadDateLabel.attributedText = lastUploadText uploadDateView.isHidden = false } else { uploadDateView.isHidden = true } } fileprivate func readPermissions(notificationSettings: UNNotificationSettings) { self.bluetoothStatusOn = BluetraceManager.shared.isBluetoothOn() self.bluetoothPermissionOn = BluetraceManager.shared.isBluetoothAuthorized() self.locationPermissionOn = BluetraceManager.shared.isLocationOnAuthorized() self.pushNotificationOn = notificationSettings.authorizationStatus == .authorized let newAllPermissionsOn = self.bluetoothStatusOn && self.bluetoothPermissionOn && self.locationPermissionOn if newAllPermissionsOn != self.allPermissionOn { self.allPermissionOn = newAllPermissionsOn DispatchQueue.global(qos: .background).async { self.getMessagesFromServer(force: true) } } } fileprivate func toggleViewVisibility(view: UIView, isVisible: Bool) { view.isHidden = !isVisible } func updateAnimationViewWithAnimationName(name: String) { let bluetoothAnimation = AnimationView(name: name) bluetoothAnimation.loopMode = .loop bluetoothAnimation.frame = CGRect(origin: CGPoint(x: 0, y: 0), size: self.animatedBluetoothHeader.frame.size) if lottieBluetoothView != nil { lottieBluetoothView.stop() lottieBluetoothView.removeFromSuperview() } self.animatedBluetoothHeader.addSubview(bluetoothAnimation) lottieBluetoothView = bluetoothAnimation lottieBluetoothView.play() } fileprivate func toggleUploadView() { toggleViewVisibility(view: self.uploadView, isVisible: !self.didUploadData && !self.registrationNeeded) } fileprivate func toggleShareView() { toggleViewVisibility(view: shareView, isVisible: !registrationNeeded) } fileprivate func toggleStatisticsView() { toggleViewVisibility(view: covidStatisticsSection, isVisible: !registrationNeeded) } fileprivate func toggleHeaderView() { toggleViewVisibility(view: inactiveAppSectionView, isVisible: !self.allPermissionOn || registrationNeeded) toggleViewVisibility(view: inactiveSettingsContent, isVisible: !self.allPermissionOn && !registrationNeeded) toggleViewVisibility(view: activeAppSectionView, isVisible: self.allPermissionOn) } fileprivate func toggleBluetoothStatusView() { toggleViewVisibility(view: bluetoothStatusOffView, isVisible: self.bluetoothPermissionOn && !self.bluetoothStatusOn && !registrationNeeded) } fileprivate func toggleBluetoothPermissionStatusView() { toggleViewVisibility(view: bluetoothPermissionOffView, isVisible: !self.allPermissionOn && !self.bluetoothPermissionOn && !registrationNeeded) } fileprivate func toggleLocationPermissionStatusView() { toggleViewVisibility(view: locationPermissionsView, isVisible: !allPermissionOn && !locationPermissionOn && (bluetoothPermissionOn && bluetoothStatusOn) && !registrationNeeded) } fileprivate func toggleRegistrationNeededView() { toggleViewVisibility(view: inactiveTokenExpiredView, isVisible: registrationNeeded) } func attemptTurnOnBluetooth() { BluetraceManager.shared.toggleScanning(false) BluetraceManager.shared.turnOnBLE() } fileprivate func updateAppActiveSubtitle() { let haveInternet = self.reachability.connection == .cellular || self.reachability.connection == .wifi if shouldShowUpdateApp || !haveInternet { appActiveSubtitleLabel.font = UIFont.preferredFont(for: .callout, weight: .bold) appActiveSubtitleLabel.textColor = UIColor.covidSafeErrorColor appActiveSubtitleLabel.text = "improve".localizedString() } else { appActiveSubtitleLabel.font = UIFont.preferredFont(for: .callout, weight: .regular) appActiveSubtitleLabel.textColor = appActiveSubtitleLabelInitialColor appActiveSubtitleLabel.text = "home_header_active_no_action_required".localizedString() } } // MARK: policy update message func shouldShowPolicyUpdateMessage() -> Bool { // this is the min version that the disclamer should be diplayed on. let minVersionShowPolicyUpdate = 89 let latestVersionShown = UserDefaults.standard.integer(forKey: "latestPolicyUpdateVersionShown") guard let currentVersion = (Bundle.main.version as NSString?)?.integerValue else { return false } if currentVersion >= minVersionShowPolicyUpdate && latestVersionShown < minVersionShowPolicyUpdate { return true } return false } func showPolicyUpdateMessage() { guard let currentVersion = (Bundle.main.version as NSString?)?.integerValue else { return } UserDefaults.standard.set(currentVersion, forKey: "latestPolicyUpdateVersionShown") let disclaimerAlert = CSGenericContentViewController(nibName: "CSGenericContentView", bundle: nil) let contentAttributedString = NSMutableAttributedString(string: "update_description".localizedString(), attributes: [ .font: UIFont.preferredFont(forTextStyle: .body) ]) disclaimerAlert.contentViewModel = CSGenericContentViewModel(viewTitle: "update_heading".localizedString(), viewContentDescription: contentAttributedString, buttonLabel: "update_modal_button".localizedString(), buttonCallback: { self.startAllSensors() disclaimerAlert.dismiss(animated: true) }) disclaimerAlert.modalPresentationStyle = UIModalPresentationStyle.overCurrentContext disclaimerAlert.modalTransitionStyle = UIModalTransitionStyle.coverVertical present(disclaimerAlert, animated: true, completion: nil) } func showTokenExpiredMessage() { toggleViews() } // MARK: Sensors func startAllSensors() { BluetraceManager.shared.turnOnAllSensors() } // MARK: API calls func getMessagesFromServer(force: Bool = false, completion: @escaping () -> Void = {}) { let onMessagesDone: (MessageResponse?, CovidSafeAPIError?) -> Void = { (messageResponse, error) in if let error = error { DLog("Get messages error: \(error.localizedDescription)") if error == .TokenExpiredError { self.showTokenExpiredMessage() } completion() return } // show update available section guard let messages = messageResponse?.messages else { self.shouldShowUpdateApp = false DispatchQueue.main.async { self.updateAppActiveSubtitle() } completion() return } self.shouldShowUpdateApp = messages.count > 0 DispatchQueue.main.async { self.updateAppActiveSubtitle() } NotificationCenter.default.post(name: .shouldUpdateAppFromMessages, object: nil) completion() } if force { MessageAPI.getMessages(completion: onMessagesDone) } else { MessageAPI.getMessagesIfNeeded(completion: onMessagesDone) } } // MARK: Reachability @objc func reachabilityChanged(note: Notification) { let reachability = note.object as! Reachability switch reachability.connection { case .wifi, .cellular: updateAppActiveSubtitle() case .unavailable, .none: updateAppActiveSubtitle() } } // MARK: IBActions @IBAction func onAppSettingsTapped(_ sender: UITapGestureRecognizer) { guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(settingsURL) } @IBAction func noInternetTapped(_ sender: Any) { performSegue(withIdentifier: "internetConnectionSegue", sender: nil) } @IBAction func updateAvailableTapped(_ sender: Any) { if let url = URL(string: "itms-apps://itunes.apple.com/app/id1509242894"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:], completionHandler: nil) } } @IBAction func onChangeLanguageTapped(_ sender: UITapGestureRecognizer) { let nav = HelpNavController() nav.pageSectionId = "other-languages" nav.modalTransitionStyle = .coverVertical nav.modalPresentationStyle = .fullScreen present(nav, animated: true, completion: nil) } @IBAction func onBluetoothPhoneSettingsTapped(_ sender: Any) { attemptTurnOnBluetooth() } @IBAction func onShareTapped(_ sender: UITapGestureRecognizer) { let shareText = TracerRemoteConfig.defaultShareText let activity = UIActivityViewController(activityItems: [shareText], applicationActivities: nil) activity.popoverPresentationController?.sourceView = shareView present(activity, animated: true, completion: nil) } @IBAction func bluetoothPairingTapped(_ sender: Any) { guard let url = URL(string: "\(URLHelper.getHelpURL())#bluetooth-pairing-request") else { return } let safariVC = SFSafariViewController(url: url) present(safariVC, animated: true, completion: nil) } @IBAction func onPositiveButtonTapped(_ sender: UITapGestureRecognizer) { guard let uploadVC = UIStoryboard(name: "UploadData", bundle: nil).instantiateInitialViewController() else { return } navigationController?.pushViewController(uploadVC, animated: true) } @IBAction func onHelpButtonTapped(_ sender: Any) { let nav = HelpNavController() nav.modalTransitionStyle = .coverVertical nav.modalPresentationStyle = .fullScreen present(nav, animated: true, completion: nil) } @IBAction func improvementAvailableTapped(_ sender: Any) { if shouldShowUpdateApp || !isInternetReachable() { navigateToSettings() } } @IBAction func registerAgainTapped(_ sender: Any) { guard let regVC = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "personalDetails") as? PersonalDetailsViewController else { return } regVC.reauthenticating = true let navigationController = UINavigationController(rootViewController: regVC) navigationController.setToolbarHidden(true, animated: false) navigationController.isNavigationBarHidden = true navigationController.modalPresentationStyle = .fullScreen navigationController.modalTransitionStyle = .coverVertical present(navigationController, animated: true, completion: nil) } @objc func appWillResignActive(_ notification: Notification) { self.lottieBluetoothView?.stop() } @objc func appWillEnterForeground(_ notification: Notification) { self.lottieBluetoothView?.play() } @objc func enableUserInteraction(_ notification: Notification) { self.view.isUserInteractionEnabled = true lottieBluetoothView?.play() } @objc func disableUserInteraction(_ notification: Notification) { self.view.isUserInteractionEnabled = false lottieBluetoothView?.stop() } @objc func enableDeferringSystemGestures(_ notification: Notification) { if #available(iOS 11.0, *) { _preferredScreenEdgesDeferringSystemGestures = .bottom setNeedsUpdateOfScreenEdgesDeferringSystemGestures() } } @objc func disableDeferringSystemGestures(_ notification: Notification) { if #available(iOS 11.0, *) { _preferredScreenEdgesDeferringSystemGestures = [] setNeedsUpdateOfScreenEdgesDeferringSystemGestures() } } @objc func refreshControlEvent() { refreshView() DispatchQueue.main.async { self.scrollView.refreshControl?.endRefreshing() } } } // MARK: Statistics delegate extension HomeViewController: StatisticsDelegate { func setStatisticsContainerHeight(height: CGFloat) { self.statisticsSectionHeight.constant = height UIView.animate(withDuration: 0.3) { self.view.layoutIfNeeded() } } } struct TracerRemoteConfig { static let defaultShareText = """ \("share_this_app_content".localizedString(comment: "Share app with friends text")) """ } // MARK: HomeDelegate protocol HomeDelegate { func showTokenExpiredMessage() func isInternetReachable() -> Bool }