import CoreLocation import Eureka import Foundation import PromiseKit import Shared final class ConnectionURLViewController: HAFormViewController, TypedRowControllerType { typealias RowValue = ConnectionURLViewController var row: RowOf! var onDismissCallback: ((UIViewController) -> Void)? let urlType: ConnectionInfo.URLType let server: Server init(server: Server, urlType: ConnectionInfo.URLType, row: RowOf) { self.server = server self.urlType = urlType self.row = row super.init() self.title = urlType.description self.isModalInPresentation = true } enum SaveError: LocalizedError { case lastURL case validation([ValidationError]) var errorDescription: String? { switch self { case .lastURL: return L10n.Settings.ConnectionSection.Errors.cannotRemoveLastUrl case let .validation(errors): return errors.map(\.msg).joined(separator: "\n") } } var isFinal: Bool { switch self { case .lastURL: return true case .validation: return true } } } private func check(url: URL?, useCloud: Bool?, validationErrors: [ValidationError]) throws { if !validationErrors.isEmpty { throw SaveError.validation(validationErrors) } if url == nil { let existingInfo = server.info.connection let other: ConnectionInfo.URLType = urlType == .internal ? .external : .internal if existingInfo.address(for: other) == nil, useCloud == false || (useCloud == nil && !existingInfo.useCloud) { throw SaveError.lastURL } } } @objc private func cancel() { onDismissCallback?(self) } @objc private func save() { let givenURL = (form.rowBy(tag: RowTag.url.rawValue) as? URLRow)?.value let useCloud = (form.rowBy(tag: RowTag.useCloud.rawValue) as? SwitchRow)?.value let localPush = (form.rowBy(tag: RowTag.localPush.rawValue) as? SwitchRow)?.value let alwaysFallbackToInternalURL = (form.rowBy(tag: RowTag.alwaysFallbackToInternalURL.rawValue) as? SwitchRow)? .value func commit() { server.update { info in info.connection.set(address: givenURL, for: urlType) if let useCloud { info.connection.useCloud = useCloud } if let localPush { info.connection.isLocalPushEnabled = localPush } if let section = form.sectionBy(tag: RowTag.ssids.rawValue) as? MultivaluedSection { info.connection.internalSSIDs = section.allRows .compactMap { $0 as? TextRow } .compactMap(\.value) .filter { !$0.isEmpty } } if let section = form.sectionBy(tag: RowTag.hardwareAddresses.rawValue) as? MultivaluedSection { info.connection.internalHardwareAddresses = section.allRows .compactMap { $0 as? TextRow } .compactMap(\.value) .map { $0.lowercased() } .filter { !$0.isEmpty } } info.connection.alwaysFallbackToInternalURL = alwaysFallbackToInternalURL ?? false } onDismissCallback?(self) } updateNavigationItems(isChecking: true) firstly { () -> Promise in try check(url: givenURL, useCloud: useCloud, validationErrors: form.validate()) if useCloud == true, let url = server.info.connection.address(for: .remoteUI) { return Current.webhooks.sendTest(server: server, baseURL: url) } if let givenURL, useCloud != true { return Current.webhooks.sendTest(server: server, baseURL: givenURL) } return .value(()) }.ensure { self.updateNavigationItems(isChecking: false) }.done { commit() }.catch { error in let alert = UIAlertController( title: L10n.Settings.ConnectionSection.ValidateError.title, message: error.localizedDescription, preferredStyle: .alert ) let canCommit: Bool if let error = error as? SaveError { canCommit = !error.isFinal } else { canCommit = true } if canCommit { alert.addAction(UIAlertAction( title: L10n.Settings.ConnectionSection.ValidateError.useAnyway, style: .default, handler: { _ in commit() } )) } alert.addAction(UIAlertAction( title: L10n.Settings.ConnectionSection.ValidateError.editUrl, style: .cancel, handler: nil )) self.present(alert, animated: true, completion: nil) } } fileprivate enum RowTag: String { case url case internalURLWarning case ssids case hardwareAddresses case useCloud case localPush case alwaysFallbackToInternalURL } private func updateNavigationItems(isChecking: Bool) { navigationItem.leftBarButtonItems = [ UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel)), ] if isChecking { let activityIndicator: UIActivityIndicatorView activityIndicator = .init(style: .medium) activityIndicator.startAnimating() navigationItem.rightBarButtonItems = [ UIBarButtonItem(customView: activityIndicator), ] } else { navigationItem.rightBarButtonItems = [ UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(save)), ] } } override func viewDidLoad() { super.viewDidLoad() updateNavigationItems(isChecking: false) if urlType.isAffectedByCloud, server.info.connection.canUseCloud { form +++ SwitchRow { $0.title = L10n.Settings.ConnectionSection.HomeAssistantCloud.title $0.tag = RowTag.useCloud.rawValue $0.value = server.info.connection.useCloud } } form +++ Section() <<< URLRow(RowTag.url.rawValue) { $0.value = server.info.connection.address(for: urlType) $0.hidden = .function([RowTag.useCloud.rawValue], { form in if let row = form.rowBy(tag: RowTag.useCloud.rawValue) as? SwitchRow { // if cloud's around, hide when it's turned on return row.value == true } else { // never hide if cloud isn't around return false } }) $0.placeholder = { () -> String? in switch urlType { case .internal: return L10n.Settings.ConnectionSection.InternalBaseUrl.placeholder case .external: return L10n.Settings.ConnectionSection.ExternalBaseUrl.placeholder case .remoteUI, .none: return nil } }() } <<< InfoLabelRow { $0.title = L10n.Settings.ConnectionSection.cloudOverridesExternal $0.hidden = .function([RowTag.useCloud.rawValue], { form in if let row = form.rowBy(tag: RowTag.useCloud.rawValue) as? SwitchRow { // this is effectively the visual replacement for the external url, so show when cloud is on return row.value == false } else { // always hide if we're not offering the cloud option return true } }) } <<< InfoLabelRow { $0.tag = RowTag.internalURLWarning.rawValue if server.info.connection.internalSSIDs?.isEmpty ?? true, server.info.connection.internalHardwareAddresses?.isEmpty ?? true, !server.info.connection.alwaysFallbackToInternalURL, !ConnectionInfo.shouldFallbackToInternalURL { #if targetEnvironment(macCatalyst) $0.title = "‼️" + L10n.Settings.ConnectionSection.InternalBaseUrl.SsidBssidRequired.title #else $0.title = "‼️" + L10n.Settings.ConnectionSection.InternalBaseUrl.SsidRequired.title #endif } else { $0.title = L10n.Settings.ConnectionSection.InternalBaseUrl.SsidRequired.title } } if urlType.isAffectedBySSID { form +++ locationPermissionSection() form +++ MultivaluedSection( tag: .ssids, header: L10n.Settings.ConnectionSection.InternalUrlSsids.header, footer: L10n.Settings.ConnectionSection.InternalUrlSsids.footer, addNewLabel: L10n.Settings.ConnectionSection.InternalUrlSsids.addNewSsid, placeholder: L10n.Settings.ConnectionSection.InternalUrlSsids.placeholder, currentValue: Current.connectivity.currentWiFiSSID, existingValues: server.info.connection.internalSSIDs ?? [], valueRules: RuleSet() ) } if urlType.isAffectedByHardwareAddress { var rules = RuleSet() rules.add(rule: RuleRegExp( regExpr: "^[a-zA-Z0-9]{2}\\:[a-zA-Z0-9]{2}\\:[a-zA-Z0-9]{2}\\:[a-zA-Z0-9]{2}\\:[a-zA-Z0-9]{2}\\:[a-zA-Z0-9]{2}$", allowsEmpty: true, msg: L10n.Settings.ConnectionSection.InternalUrlHardwareAddresses.invalid, id: nil )) form +++ MultivaluedSection( tag: .hardwareAddresses, header: L10n.Settings.ConnectionSection.InternalUrlHardwareAddresses.header, footer: L10n.Settings.ConnectionSection.InternalUrlHardwareAddresses.footer, addNewLabel: L10n.Settings.ConnectionSection.InternalUrlHardwareAddresses.addNewSsid, placeholder: "aa:bb:cc:dd:ee:ff", currentValue: Current.connectivity.currentNetworkHardwareAddress, existingValues: server.info.connection.internalHardwareAddresses ?? [], valueRules: rules ) } if urlType.hasLocalPush { form +++ Section( footer: L10n.Settings.ConnectionSection.localPushDescription ) <<< SwitchRow(RowTag.localPush.rawValue) { $0.title = L10n.SettingsDetails.Notifications.LocalPush.title $0.value = server.info.connection.isLocalPushEnabled } <<< LearnMoreButtonRow { $0.onCellSelection { cell, _ in openURLInBrowser( URL(string: "https://companion.home-assistant.io/app/ios/local-push")!, cell.formViewController() ) } } } if !ConnectionInfo.shouldFallbackToInternalURL { form +++ Section(footer: L10n.Settings.ConnectionSection.AlwaysFallbackInternal.footer) <<< SwitchRow(RowTag.alwaysFallbackToInternalURL.rawValue) { $0.title = L10n.Settings.ConnectionSection.AlwaysFallbackInternal.title $0.value = server.info.connection.alwaysFallbackToInternalURL $0.cellUpdate { cell, _ in cell.switchControl.onTintColor = .red } $0.onChange { [weak self] row in if row.value ?? false { let alert = UIAlertController( title: L10n.Settings.ConnectionSection.AlwaysFallbackInternal.Confirmation.title, message: L10n.Settings.ConnectionSection.AlwaysFallbackInternal.Confirmation.message, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: L10n.cancelLabel, style: .cancel, handler: { _ in self?.server.info.connection.alwaysFallbackToInternalURL = false row.value = false row.cellUpdate { _, row in row.value = false } row.reload() })) alert.addAction(UIAlertAction( title: L10n.Settings.ConnectionSection.AlwaysFallbackInternal.Confirmation .confirmButton, style: .destructive )) self?.present(alert, animated: true) } } } } } private func locationPermissionSection() -> Section { class PermissionWatchingDelegate: NSObject, CLLocationManagerDelegate { let section: Section init(section: Section) { self.section = section } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { section.evaluateHidden() } func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { section.evaluateHidden() } } let section = Section() var locationManager: CLLocationManager? = CLLocationManager() var permissionDelegate: PermissionWatchingDelegate? = PermissionWatchingDelegate(section: section) locationManager?.delegate = permissionDelegate section.hidden = .function([], { _ in if let locationManager { return locationManager.authorizationStatus == .authorizedAlways && locationManager.accuracyAuthorization == .fullAccuracy } else { return locationManager?.authorizationStatus == .authorizedAlways } }) section.evaluateHidden() after(life: self).done { // we're keeping these lifetimes around longer so they update locationManager = nil permissionDelegate = nil } section <<< InfoLabelRow { $0.title = L10n.Settings.ConnectionSection.ssidPermissionAndAccuracyMessage $0.displayType = .important $0.cellUpdate { cell, _ in cell.accessibilityTraits.insert(.button) cell.selectionStyle = .default } $0.onCellSelection { _, _ in if locationManager?.authorizationStatus == .notDetermined { locationManager?.requestAlwaysAuthorization() } else { UIApplication.shared.openSettings(destination: .location) } } } return section } } private extension MultivaluedSection { convenience init( tag: ConnectionURLViewController.RowTag, header: String, footer: String, addNewLabel: String, placeholder: String, currentValue: @escaping () -> String?, existingValues: [String], valueRules: RuleSet ) { self.init( multivaluedOptions: [.Insert, .Delete], header: header, footer: footer ) { section in section.tag = tag.rawValue section.addButtonProvider = { _ in ButtonRow { $0.title = addNewLabel }.cellUpdate { cell, _ in cell.textLabel?.textAlignment = .natural cell.selectionStyle = .default } } func row(for value: String?) -> TextRow { TextRow { $0.placeholder = placeholder $0.value = value $0.add(ruleSet: valueRules) } } section.multivaluedRowToInsertAt = { _ in let current = currentValue() if section.allRows.contains(where: { ($0 as? TextRow)?.value == current }) { return row(for: nil) } else { return row(for: current) } } section.append(contentsOf: existingValues.map { row(for: $0) }) } } }