mobile-ios/CovidSafe/HomeViewController.swift
2021-04-11 20:44:31 -07:00

669 lines
28 KiB
Swift

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)
}
@IBAction func privacyPolicyTapped(_ sender: Any) {
guard let url = URL(string: URLHelper.getPrivacyPolicyURL()) else {
return
}
let safariVC = SFSafariViewController(url: url)
present(safariVC, 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
}