From c69b28bb80c5ef8fde79c67d61757167d7333c7d Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Thu, 3 Oct 2024 14:35:31 -0700 Subject: [PATCH] Additional Tracker Improvements. (#835) * Additional Tracker Improvements. Removes AlamoFire Adds Tracker to Notifications Fixes switching to/from demo mode Remves unused HTTPClient functions in favor of upcoming Swift/OpenAPI work Signed-off-by: Dan Cunningham * Fixes background http calls Signed-off-by: Dan Cunningham --------- Signed-off-by: Dan Cunningham --- NotificationService/NotificationService.swift | 92 +++++--- .../Sources/OpenHABCore/Util/HTTPClient.swift | 148 ++++-------- .../OpenHABCore/Util/NetworkTracker.swift | 97 ++++---- .../OpenHABCore/Util/OpenHABItemCache.swift | 78 +++---- openHAB/AppDelegate.swift | 17 +- openHAB/OpenHABRootViewController.swift | 216 ++++++++++++------ openHAB/OpenHABSitemapViewController.swift | 27 +-- 7 files changed, 358 insertions(+), 317 deletions(-) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index b63d6a0a..d46b1620 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import Foundation import OpenHABCore import os.log @@ -18,6 +19,7 @@ import UserNotifications class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? + var cancellables = Set() override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler @@ -142,9 +144,23 @@ class NotificationService: UNNotificationServiceExtension { self.attachFile(localURL: localURL, mimeType: response?.mimeType, completion: completion) } if url.starts(with: "/") { - client.downloadFile(baseURLs: [Preferences.localUrl, Preferences.remoteUrl], path: url, completionHandler: downloadCompletionHandler) - } else { - client.downloadFile(url: url, completionHandler: downloadCompletionHandler) + let connection1 = ConnectionConfiguration( + url: Preferences.localUrl, + priority: 0 + ) + let connection2 = ConnectionConfiguration( + url: Preferences.remoteUrl, + priority: 1 + ) + NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + NetworkTracker.shared.waitForActiveConnection { activeConnection in + if let openHABUrl = activeConnection?.configuration.url, let uurl = URL(string: openHABUrl) { + client.downloadFile(url: uurl.appendingPathComponent(url), completionHandler: downloadCompletionHandler) + } + } + .store(in: &cancellables) + } else if let uurl = URL(string: url) { + client.downloadFile(url: uurl, completionHandler: downloadCompletionHandler) } } @@ -158,39 +174,53 @@ class NotificationService: UNNotificationServiceExtension { let itemName = String(itemURI.absoluteString.dropFirst(scheme.count + 1)) let client = HTTPClient(username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds) - client.getItem(baseURLs: [Preferences.localUrl, Preferences.remoteUrl], itemName: itemName) { item, error in - guard let item else { - os_log("Could not find item %{PUBLIC}@", log: .default, type: .info, itemName) - completion(nil) - return - } - if let state = item.state { - // Extract MIME type and base64 string - let pattern = /^data:(.*?);base64,(.*)$/ - if let firstMatch = state.firstMatch(of: pattern) { - let mimeType = String(firstMatch.1) - let base64String = String(firstMatch.2) - if let imageData = Data(base64Encoded: base64String) { - // Create a temporary file URL - let tempDirectory = FileManager.default.temporaryDirectory - let tempFileURL = tempDirectory.appendingPathComponent(UUID().uuidString) - do { - try imageData.write(to: tempFileURL) - os_log("Image saved to temporary file: %{PUBLIC}@", log: .default, type: .info, tempFileURL.absoluteString) - self.attachFile(localURL: tempFileURL, mimeType: mimeType, completion: completion) - return - } catch { - os_log("Failed to write image data to file: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + let connection1 = ConnectionConfiguration( + url: Preferences.localUrl, + priority: 0 + ) + let connection2 = ConnectionConfiguration( + url: Preferences.remoteUrl, + priority: 1 + ) + NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + NetworkTracker.shared.waitForActiveConnection { activeConnection in + if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { + client.getItem(baseURL: url, itemName: itemName) { item, error in + guard let item else { + os_log("Could not find item %{PUBLIC}@", log: .default, type: .info, itemName) + completion(nil) + return + } + if let state = item.state { + // Extract MIME type and base64 string + let pattern = /^data:(.*?);base64,(.*)$/ + if let firstMatch = state.firstMatch(of: pattern) { + let mimeType = String(firstMatch.1) + let base64String = String(firstMatch.2) + if let imageData = Data(base64Encoded: base64String) { + // Create a temporary file URL + let tempDirectory = FileManager.default.temporaryDirectory + let tempFileURL = tempDirectory.appendingPathComponent(UUID().uuidString) + do { + try imageData.write(to: tempFileURL) + os_log("Image saved to temporary file: %{PUBLIC}@", log: .default, type: .info, tempFileURL.absoluteString) + self.attachFile(localURL: tempFileURL, mimeType: mimeType, completion: completion) + return + } catch { + os_log("Failed to write image data to file: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + } + } else { + os_log("Failed to decode base64 string to Data", log: .default, type: .error) + } + } else { + os_log("Failed to parse data: %{PUBLIC}@", log: .default, type: .error, error?.localizedDescription ?? "") } - } else { - os_log("Failed to decode base64 string to Data", log: .default, type: .error) } - } else { - os_log("Failed to parse data: %{PUBLIC}@", log: .default, type: .error, error?.localizedDescription ?? "") + completion(nil) } } - completion(nil) } + .store(in: &cancellables) } func attachFile(localURL: URL, mimeType: String?, completion: @escaping (UNNotificationAttachment?) -> Void) { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index 1f4eb1af..00efb28b 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -34,97 +34,86 @@ public class HTTPClient: NSObject { } /** - Sends a GET request to multiple base URLs for a specified path and returns the response data via a completion handler. - - This function attempts to send a GET request to the provided base URLs in the given order. If a request fails (due to network issues or HTTP error codes between 400 and 599), it will automatically attempt the next URL in the list until all URLs are exhausted or a successful response is received. + Sends a GET request to a specified base URL for a specified path and returns the response data via a completion handler. - Parameters: - - baseURLs: An array of base URL strings to attempt the request from. The function will try each URL in the order provided. - - path: An optional path component to append to each base URL. + - baseURL: The base URL to attempt the request from. + - path: An optional path component to append to the base URL. - completion: A closure to be executed once the request is complete. The closure takes three parameters: - data: The data returned by the server. This will be `nil` if the request fails. - response: The URL response object providing response metadata, such as HTTP headers and status code. - error: An error object that indicates why the request failed, or `nil` if the request was successful. */ - public func doGet(baseURLs: [String], path: String?, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { - doRequest(baseURLs: baseURLs, path: path, method: "GET") { result, response, error in + public func doGet(baseURL: URL, path: String?, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { + doRequest(baseURL: baseURL, path: path, method: "GET") { result, response, error in let data = result as? Data completion(data, response, error) } } /** - Sends a POST request to multiple base URLs for a specified path and returns the response data via a completion handler. - - This function attempts to send a POST request to the provided base URLs in the given order. If a request fails (due to network issues or HTTP error codes between 400 and 599), it will automatically attempt the next URL in the list until all URLs are exhausted or a successful response is received. + Sends a POST request to a specified base URL for a specified path and returns the response data via a completion handler. - Parameters: - - baseURLs: An array of base URL strings to attempt the request from. The function will try each URL in the order provided. - - path: An optional path component to append to each base URL. + - baseURL: The base URL to attempt the request from. + - path: An optional path component to append to the base URL. - body: The string to include as the HTTP body of the request. - completion: A closure to be executed once the request is complete. The closure takes three parameters: - data: The data returned by the server. This will be `nil` if the request fails. - response: The URL response object providing response metadata, such as HTTP headers and status code. - error: An error object that indicates why the request failed, or `nil` if the request was successful. */ - public func doPost(baseURLs: [String], path: String?, body: String, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { - doRequest(baseURLs: baseURLs, path: path, method: "POST", body: body) { result, response, error in + public func doPost(baseURL: URL, path: String?, body: String, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { + doRequest(baseURL: baseURL, path: path, method: "POST", body: body) { result, response, error in let data = result as? Data completion(data, response, error) } } /** - Sends a PUT request to multiple base URLs for a specified path and returns the response data via a completion handler. - - This function attempts to send a PUT request to the provided base URLs in the given order. If a request fails (due to network issues or HTTP error codes between 400 and 599), it will automatically attempt the next URL in the list until all URLs are exhausted or a successful response is received. + Sends a PUT request to a specified base URL for a specified path and returns the response data via a completion handler. - Parameters: - - baseURLs: An array of base URL strings to attempt the request from. The function will try each URL in the order provided. - - path: An optional path component to append to each base URL. + - baseURL: The base URL to attempt the request from. + - path: An optional path component to append to the base URL. - body: The string to include as the HTTP body of the request. - completion: A closure to be executed once the request is complete. The closure takes three parameters: - data: The data returned by the server. This will be `nil` if the request fails. - response: The URL response object providing response metadata, such as HTTP headers and status code. - error: An error object that indicates why the request failed, or `nil` if the request was successful. */ - public func doPut(baseURLs: [String], path: String?, body: String, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { - doRequest(baseURLs: baseURLs, path: path, method: "PUT", body: body) { result, response, error in + public func doPut(baseURL: URL, path: String?, body: String, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { + doRequest(baseURL: baseURL, path: path, method: "PUT", body: body) { result, response, error in let data = result as? Data completion(data, response, error) } } /** - Fetches a list of OpenHAB items from multiple base URLs and returns the items via a completion handler. - - This function attempts to send a GET request to the provided base URLs in the given order to fetch a list of OpenHAB items. If a request fails (due to network issues or HTTP error codes between 400 and 599), it will automatically attempt the next URL in the list until all URLs are exhausted or a successful response is received. + Fetches a specific OpenHAB item from a specified base URL and returns the item via a completion handler. - Parameters: - - baseURLs: An array of base URL strings to attempt the request from. The function will try each URL in the order provided. + - baseURL: The base URL to attempt the request from. + - itemName: The name of the OpenHAB item to fetch. - completion: A closure to be executed once the request is complete. The closure takes two parameters: - - items: An array of `OpenHABItem` objects returned by the server. This will be `nil` if the request fails. + - item: An `OpenHABItem` object returned by the server. This will be `nil` if the request fails. - error: An error object that indicates why the request failed, or `nil` if the request was successful. */ - public func getItems(baseURLs: [String], completion: @escaping ([OpenHABItem]?, Error?) -> Void) { - doGet(baseURLs: baseURLs, path: "/rest/items") { data, _, error in + public func getItem(baseURL: URL, itemName: String, completion: @escaping (OpenHABItem?, Error?) -> Void) { + os_log("getItem from URL %{public}@ and item %{public}@", log: .networking, type: .info, baseURL.absoluteString, itemName) + doGet(baseURL: baseURL, path: "/rest/items/\(itemName)") { data, _, error in if let error { completion(nil, error) } else { do { - var items = [OpenHABItem]() if let data { - os_log("getItemsInternal Data: %{public}@", log: .networking, type: .debug, String(data: data, encoding: .utf8) ?? "") let decoder = JSONDecoder() decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - - let codingDatas = try data.decoded(as: [OpenHABItem.CodingData].self, using: decoder) - for codingDatum in codingDatas where codingDatum.openHABItem.type != OpenHABItem.ItemType.group { - items.append(codingDatum.openHABItem) - } - os_log("Loaded items to cache: %{PUBLIC}d", log: .networking, type: .info, items.count) + let item = try data.decoded(as: OpenHABItem.CodingData.self, using: decoder) + completion(item.openHABItem, nil) + } else { + completion(nil, NSError(domain: "HTTPClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data for item"])) } - completion(items, nil) } catch { os_log("getItemsInternal ERROR: %{PUBLIC}@", log: .networking, type: .info, String(describing: error)) completion(nil, error) @@ -133,21 +122,9 @@ public class HTTPClient: NSObject { } } - /** - Fetches a specific OpenHAB item from multiple base URLs and returns the item via a completion handler. - - This function attempts to send a GET request to the provided base URLs in the given order to fetch a specific OpenHAB item. If a request fails (due to network issues or HTTP error codes between 400 and 599), it will automatically attempt the next URL in the list until all URLs are exhausted or a successful response is received. - - - Parameters: - - baseURLs: An array of base URL strings to attempt the request from. The function will try each URL in the order provided. - - itemName: The name of the OpenHAB item to fetch. - - completion: A closure to be executed once the request is complete. The closure takes two parameters: - - item: An `OpenHABItem` object returned by the server. This will be `nil` if the request fails. - - error: An error object that indicates why the request failed, or `nil` if the request was successful. - */ - public func getItem(baseURLs: [String], itemName: String, completion: @escaping (OpenHABItem?, Error?) -> Void) { - os_log("getItem from URsL %{public}@ and item %{public}@", log: .networking, type: .info, baseURLs, itemName) - doGet(baseURLs: baseURLs, path: "/rest/items/\(itemName)") { data, _, error in + public func getServerProperties(baseURL: URL, completion: @escaping (OpenHABServerProperties?, Error?) -> Void) { + os_log("getServerProperties from URL %{public}@", log: .networking, type: .info, baseURL.absoluteString) + doGet(baseURL: baseURL, path: "/rest/") { data, _, error in if let error { completion(nil, error) } else { @@ -155,13 +132,13 @@ public class HTTPClient: NSObject { if let data { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - let item = try data.decoded(as: OpenHABItem.CodingData.self, using: decoder) - completion(item.openHABItem, nil) + let properties = try data.decoded(as: OpenHABServerProperties.self, using: decoder) + completion(properties, nil) } else { - completion(nil, NSError(domain: "HTTPClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data for item"])) + completion(nil, NSError(domain: "HTTPClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data for properties"])) } } catch { - os_log("getItemsInternal ERROR: %{PUBLIC}@", log: .networking, type: .info, String(describing: error)) + os_log("getServerProperties ERROR: %{PUBLIC}@", log: .networking, type: .info, String(describing: error)) completion(nil, error) } } @@ -169,39 +146,18 @@ public class HTTPClient: NSObject { } /** - Initiates a download request to multiple base URLs for a specified path and returns the file URL via a completion handler. - - This function attempts to download a file from the provided base URLs in the given order. If a download fails (due to network issues or HTTP error codes between 400 and 599), it will automatically attempt the next URL in the list until all URLs are exhausted or a successful download occurs. - - - Parameters: - - baseURLs: An array of base URL strings to attempt the download from. The function will try each URL in the order provided. - - path: The path component to append to each base URL. - - completionHandler: A closure to be executed once the download is complete. The closure takes three parameters: - - fileURL: The local URL where the downloaded file is stored. This will be `nil` if the download fails. - - response: The URL response object providing response metadata, such as HTTP headers and status code. - - error: An error object that indicates why the request failed, or `nil` if the request was successful. - */ - public func downloadFile(baseURLs: [String], path: String, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) { - doRequest(baseURLs: baseURLs, path: path, method: "GET", download: true) { result, response, error in - let fileURL = result as? URL - completionHandler(fileURL, response, error) - } - } - - /** - Initiates a download request to a specified URL and returns the file URL via a completion handler. - - This function sends a GET request to the provided URL to download a file. If the request fails (due to network issues or HTTP error codes between 400 and 599), it will automatically attempt the request again until a successful download occurs. + Initiates a download request to a specified base URL for a specified path and returns the file URL via a completion handler. - Parameters: - - url: The URL string to download the file from. + - baseURL: The base URL to attempt the download from. + - path: The optional path component to append to the base URL. - completionHandler: A closure to be executed once the download is complete. The closure takes three parameters: - fileURL: The local URL where the downloaded file is stored. This will be `nil` if the download fails. - response: The URL response object providing response metadata, such as HTTP headers and status code. - error: An error object that indicates why the request failed, or `nil` if the request was successful. */ - public func downloadFile(url: String, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) { - doRequest(baseURLs: [url], path: nil, method: "GET", download: true) { result, response, error in + public func downloadFile(url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) { + doRequest(baseURL: url, path: nil, method: "GET", download: true) { result, response, error in let fileURL = result as? URL completionHandler(fileURL, response, error) } @@ -215,27 +171,13 @@ public class HTTPClient: NSObject { return "Basic \(authData.base64EncodedString())" } - private func doRequest(baseURLs: [String], path: String?, method: String, body: String? = nil, download: Bool = false, completion: @escaping (Any?, URLResponse?, Error?) -> Void) { - var urls: [URL] = [] - for urlString in baseURLs { - if var urlComponent = URLComponents(string: urlString) { - if let path { - urlComponent.path = path - } - if let url = urlComponent.url { - urls.append(url) - } - } + private func doRequest(baseURL: URL, path: String?, method: String, body: String? = nil, download: Bool = false, completion: @escaping (Any?, URLResponse?, Error?) -> Void) { + var url = baseURL + if let path { + url.appendPathComponent(path) } func sendRequest() { - guard !urls.isEmpty else { - os_log("All URLs processed and failed.", log: .networking, type: .error) - completion(nil, nil, NSError(domain: "HTTPClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "All URLs processed and failed."])) - return - } - - let url = urls.removeFirst() var request = URLRequest(url: url) request.httpMethod = method if let body { @@ -245,13 +187,11 @@ public class HTTPClient: NSObject { performRequest(request: request, download: download) { result, response, error in if let error { os_log("Error with URL %{public}@ : %{public}@", log: .networking, type: .error, url.absoluteString, error.localizedDescription) - // Try the next URL - sendRequest() + completion(nil, response, error) } else if let response = response as? HTTPURLResponse { if (400 ... 599).contains(response.statusCode) { os_log("HTTP error from URL %{public}@ : %{public}d", log: .networking, type: .error, url.absoluteString, response.statusCode) - // Try the next URL - sendRequest() + completion(nil, response, NSError(domain: "HTTPClient", code: response.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP error \(response.statusCode)"])) } else { os_log("Response from URL %{public}@ : %{public}d", log: .networking, type: .info, url.absoluteString, response.statusCode) completion(result, response, nil) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift index 8dae2e0f..21b7a5ab 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -9,7 +9,7 @@ // // SPDX-License-Identifier: EPL-2.0 -import Alamofire +import Combine import Foundation import Network import os.log @@ -44,8 +44,9 @@ public final class NetworkTracker: ObservableObject { private let monitor: NWPathMonitor private let monitorQueue = DispatchQueue.global(qos: .background) + private var priorityWorkItem: DispatchWorkItem? private var connectionConfigurations: [ConnectionConfiguration] = [] - + private var httpClient: HTTPClient? private var retryTimer: DispatchSourceTimer? private let timerQueue = DispatchQueue(label: "com.openhab.networktracker.timerQueue") @@ -67,11 +68,24 @@ public final class NetworkTracker: ObservableObject { monitor.start(queue: monitorQueue) } - public func startTracking(connectionConfigurations: [ConnectionConfiguration]) { + public func startTracking(connectionConfigurations: [ConnectionConfiguration], username: String, password: String, alwaysSendBasicAuth: Bool) { self.connectionConfigurations = connectionConfigurations + httpClient = HTTPClient(username: username, password: password, alwaysSendBasicAuth: alwaysSendBasicAuth) attemptConnection() } + public func waitForActiveConnection( + perform action: @escaping (ConnectionInfo?) -> Void + ) -> AnyCancellable { + $activeConnection + .filter { $0 != nil } // Only proceed if activeConnection is not nil + .first() // Automatically cancels after the first non-nil value + .receive(on: DispatchQueue.main) + .sink { activeConnection in + action(activeConnection) + } + } + // This gets called periodically when we have an active connection to make sure it's still the best choice private func checkActiveConnection() { guard let activeConnection else { @@ -81,13 +95,14 @@ public final class NetworkTracker: ObservableObject { } // Check if the active connection is reachable - NetworkConnection.tracker(openHABRootUrl: activeConnection.configuration.url) { [weak self] response in - switch response.result { - case .success: - os_log("Network status: Active connection is reachable: %{PUBLIC}@", log: OSLog.default, type: .info, activeConnection.configuration.url) - case .failure: - os_log("Network status: Active connection is not reachable: %{PUBLIC}@", log: OSLog.default, type: .error, activeConnection.configuration.url) - self?.attemptConnection() // If not reachable, run the connection logic + if let url = URL(string: activeConnection.configuration.url) { + httpClient?.getServerProperties(baseURL: url) { [weak self] _, error in + if let error { + os_log("Network status: Active connection is not reachable: %{PUBLIC}@ %{PUBLIC}@", log: OSLog.default, type: .error, activeConnection.configuration.url, error.localizedDescription) + self?.attemptConnection() // If not reachable, run the connection logic + } else { + os_log("Network status: Active connection is reachable: %{PUBLIC}@", log: OSLog.default, type: .info, activeConnection.configuration.url) + } } } } @@ -98,6 +113,7 @@ public final class NetworkTracker: ObservableObject { setActiveConnection(nil) return } + priorityWorkItem?.cancel() os_log("Network status: Checking available connections....", log: OSLog.default, type: .info) let dispatchGroup = DispatchGroup() var highestPriorityConnection: ConnectionInfo? @@ -105,7 +121,6 @@ public final class NetworkTracker: ObservableObject { var checkOutstanding = false // Track if there are any checks still in progress let priorityWaitTime: TimeInterval = 2.0 - var priorityWorkItem: DispatchWorkItem? // Set up the work item to handle the 2-second timeout priorityWorkItem = DispatchWorkItem { [weak self] in @@ -125,35 +140,35 @@ public final class NetworkTracker: ObservableObject { for configuration in connectionConfigurations { dispatchGroup.enter() checkOutstanding = true // Signal that checks are outstanding - NetworkConnection.tracker(openHABRootUrl: configuration.url) { [weak self] response in - guard let self else { return } - defer { - dispatchGroup.leave() // When each check completes, this signals the group that it's done - } - - switch response.result { - case let .success(data): - let version = getServerInfoFromData(data: data) - if version > 0 { - let connectionInfo = ConnectionInfo(configuration: configuration, version: version) - if configuration.priority == 0, highestPriorityConnection == nil { - // Found a high-priority (0) connection - highestPriorityConnection = connectionInfo - priorityWorkItem?.cancel() // Stop the 2-second wait if highest priority succeeds - setActiveConnection(connectionInfo) - } else if highestPriorityConnection == nil { - // Check if this connection has a higher priority than the current firstAvailableConnection + if let url = URL(string: configuration.url) { + httpClient?.getServerProperties(baseURL: url) { [weak self] props, error in + guard let self else { return } + defer { + dispatchGroup.leave() // When each check completes, this signals the group that it's done + } + if let error { + os_log("Network status: Failed to connect to %{PUBLIC}@ : %{PUBLIC}@", log: OSLog.default, type: .error, configuration.url, error.localizedDescription) + } else { + let version = Int(props?.version ?? "0") + if let version, version > 1 { let connectionInfo = ConnectionInfo(configuration: configuration, version: version) - if firstAvailableConnection == nil || configuration.priority < firstAvailableConnection!.configuration.priority { - os_log("Network status: Found a higher priority available connection: %{PUBLIC}@", log: OSLog.default, type: .info, configuration.url) - firstAvailableConnection = connectionInfo + if configuration.priority == 0, highestPriorityConnection == nil { + // Found a high-priority (0) connection + highestPriorityConnection = connectionInfo + priorityWorkItem?.cancel() // Stop the 2-second wait if highest priority succeeds + setActiveConnection(connectionInfo) + } else if highestPriorityConnection == nil { + // Check if this connection has a higher priority than the current firstAvailableConnection + let connectionInfo = ConnectionInfo(configuration: configuration, version: version) + if firstAvailableConnection == nil || configuration.priority < firstAvailableConnection!.configuration.priority { + os_log("Network status: Found a higher priority available connection: %{PUBLIC}@", log: OSLog.default, type: .info, configuration.url) + firstAvailableConnection = connectionInfo + } } + } else { + os_log("Network status: Invalid server version from %{PUBLIC}@", log: OSLog.default, type: .error, configuration.url) } - } else { - os_log("Network status: Invalid server version from %{PUBLIC}@", log: OSLog.default, type: .error, configuration.url) } - case let .failure(error): - os_log("Network status: Failed to connect to %{PUBLIC}@ : %{PUBLIC}@", log: OSLog.default, type: .error, configuration.url, error.localizedDescription) } } } @@ -224,14 +239,4 @@ public final class NetworkTracker: ObservableObject { status = newStatus } } - - private func getServerInfoFromData(data: Data) -> Int { - do { - let serverProperties = try data.decoded(as: OpenHABServerProperties.self) - // OH versions 2.0 through 2.4 return "1" as their version, so set the floor to 2 so we do not think this is an OH 1.x server - return max(2, Int(serverProperties.version) ?? 2) - } catch { - return -1 - } - } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift index e89cea12..2b2afdd5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OpenHABItemCache.swift @@ -32,7 +32,7 @@ public class OpenHABItemCache { url: Preferences.remoteUrl, priority: 1 ) - NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) + NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: Preferences.username, password: Preferences.password, alwaysSendBasicAuth: Preferences.alwaysSendCreds) } public func getItemNames(searchTerm: String?, types: [OpenHABItem.ItemType]?, completion: @escaping ([NSString]) -> Void) { @@ -75,62 +75,54 @@ public class OpenHABItemCache { @available(iOS 12.0, *) public func reload(searchTerm: String?, types: [OpenHABItem.ItemType]?, completion: @escaping ([NSString]) -> Void) { - NetworkTracker.shared.$activeConnection - .filter { $0 != nil } // Only proceed if activeServer is not nil - .first() // Automatically cancels after the first non-nil value - .receive(on: DispatchQueue.main) - .sink { activeConnection in - if let urlString = activeConnection?.configuration.url, let url = Endpoint.items(openHABRootUrl: urlString).url { - os_log("OpenHABItemCache Loading items from %{PUBLIC}@", log: .default, type: .info, urlString) - self.lastLoad = Date().timeIntervalSince1970 - NetworkConnection.load(from: url, timeout: self.timeout) { response in - switch response.result { - case let .success(data): - do { - try self.decodeItemsData(data) - let ret = self.items?.filter { (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && (types == nil || ($0.type != nil && types!.contains($0.type!))) }.sorted(by: \.name).map { NSString(string: $0.name) } ?? [] - completion(ret) - } catch { - print(error) - os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) - } - case let .failure(error): + NetworkTracker.shared.waitForActiveConnection { activeConnection in + if let urlString = activeConnection?.configuration.url, let url = Endpoint.items(openHABRootUrl: urlString).url { + os_log("OpenHABItemCache Loading items from %{PUBLIC}@", log: .default, type: .info, urlString) + self.lastLoad = Date().timeIntervalSince1970 + NetworkConnection.load(from: url, timeout: self.timeout) { response in + switch response.result { + case let .success(data): + do { + try self.decodeItemsData(data) + let ret = self.items?.filter { (searchTerm == nil || $0.name.contains(searchTerm.orEmpty)) && (types == nil || ($0.type != nil && types!.contains($0.type!))) }.sorted(by: \.name).map { NSString(string: $0.name) } ?? [] + completion(ret) + } catch { + print(error) os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) } + case let .failure(error): + os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) } } } - .store(in: &cancellables) + } + .store(in: &cancellables) } @available(iOS 12.0, *) public func reload(name: String, completion: @escaping (OpenHABItem?) -> Void) { - NetworkTracker.shared.$activeConnection - .filter { $0 != nil } // Only proceed if activeServer is not nil - .first() // Automatically cancels after the first non-nil value - .receive(on: DispatchQueue.main) - .sink { activeConnection in - if let urlString = activeConnection?.configuration.url, let url = Endpoint.items(openHABRootUrl: urlString).url { - os_log("OpenHABItemCache Loading items from %{PUBLIC}@", log: .default, type: .info, urlString) - self.lastLoad = Date().timeIntervalSince1970 - NetworkConnection.load(from: url, timeout: self.timeout) { response in - switch response.result { - case let .success(data): - do { - try self.decodeItemsData(data) - let item = self.getItem(name) - completion(item) - } catch { - os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) - } - case let .failure(error): - print(error) + NetworkTracker.shared.waitForActiveConnection { activeConnection in + if let urlString = activeConnection?.configuration.url, let url = Endpoint.items(openHABRootUrl: urlString).url { + os_log("OpenHABItemCache Loading items from %{PUBLIC}@", log: .default, type: .info, urlString) + self.lastLoad = Date().timeIntervalSince1970 + NetworkConnection.load(from: url, timeout: self.timeout) { response in + switch response.result { + case let .success(data): + do { + try self.decodeItemsData(data) + let item = self.getItem(name) + completion(item) + } catch { os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) } + case let .failure(error): + print(error) + os_log("OpenHABItemCache %{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) } } } - .store(in: &cancellables) + } + .store(in: &cancellables) } private func decodeItemsData(_ data: Data) throws { diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 9a040789..d04cf980 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -199,10 +199,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD if actionIdentifier != UNNotificationDefaultActionIdentifier { userInfo["actionIdentifier"] = actionIdentifier } - notifyNotificationListeners(userInfo) + notifyNotificationListeners(userInfo, withCompletionHandler: completionHandler) appData.lastNotificationInfo = userInfo + } else { + completionHandler() } - completionHandler() } private func displayNotification(userInfo: [AnyHashable: Any]) { @@ -248,8 +249,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } } - private func notifyNotificationListeners(_ userInfo: [AnyHashable: Any]) { - NotificationCenter.default.post(name: .apnsReceived, object: nil, userInfo: userInfo) + private func notifyNotificationListeners(_ userInfo: [AnyHashable: Any], withCompletionHandler completionHandler: (() -> Void)? = nil) { + if let navigationController = window?.rootViewController as? UINavigationController { + if let rootViewController = navigationController.viewControllers.first as? OpenHABRootViewController { + rootViewController.handleNotification(userInfo, completionHandler: completionHandler) + } + } } func applicationWillResignActive(_ application: UIApplication) { @@ -286,7 +291,3 @@ extension AppDelegate: MessagingDelegate { NotificationCenter.default.post(name: NSNotification.Name("apsRegistered"), object: self, userInfo: dataDict) } } - -extension Notification.Name { - static let apnsReceived = Notification.Name("apnsReceived") -} diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 99d27cfd..4d0b2b91 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -100,8 +100,6 @@ class OpenHABRootViewController: UIViewController { isDemoMode = Preferences.demomode switchToSavedView() setupTracker() - // ready for push notifications - NotificationCenter.default.addObserver(self, selector: #selector(handleApnsMessage(notification:)), name: .apnsReceived, object: nil) // check if we were launched with a notification if let userInfo = appData?.lastNotificationInfo { handleNotification(userInfo) @@ -120,32 +118,45 @@ class OpenHABRootViewController: UIViewController { } fileprivate func setupTracker() { - Publishers.CombineLatest3( + let serverInfo = Publishers.CombineLatest4( Preferences.$localUrl, Preferences.$remoteUrl, - Preferences.$demomode + Preferences.$username, + Preferences.$password ) - .sink { (localUrl, remoteUrl, demomode) in - if demomode { - NetworkTracker.shared.startTracking(connectionConfigurations: [ - ConnectionConfiguration( - url: "https://demo.openhab.org", + .eraseToAnyPublisher() + + let misc = Publishers.CombineLatest( + Preferences.$demomode, + Preferences.$alwaysSendCreds + ) + .eraseToAnyPublisher() + + Publishers.CombineLatest(serverInfo, misc) + .debounce(for: .milliseconds(500), scheduler: RunLoop.main) // ensures if multiple values are saved, we get called once + .sink { (serverInfoTuple, miscTuple) in + let (localUrl, remoteUrl, username, password) = serverInfoTuple + let (demomode, alwaysSendCreds) = miscTuple + if demomode { + NetworkTracker.shared.startTracking(connectionConfigurations: [ + ConnectionConfiguration( + url: "https://demo.openhab.org", + priority: 0 + ) + ], username: "", password: "", alwaysSendBasicAuth: false) + } else { + let connection1 = ConnectionConfiguration( + url: localUrl, priority: 0 ) - ]) - } else { - let connection1 = ConnectionConfiguration( - url: localUrl, - priority: 0 - ) - let connection2 = ConnectionConfiguration( - url: remoteUrl, - priority: 1 - ) - NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2]) + let connection2 = ConnectionConfiguration( + url: remoteUrl, + priority: 1 + ) + NetworkTracker.shared.startTracking(connectionConfigurations: [connection1, connection2], username: username, password: password, alwaysSendBasicAuth: alwaysSendCreds) + } } - } - .store(in: &cancellables) + .store(in: &cancellables) NetworkTracker.shared.$activeConnection .receive(on: DispatchQueue.main) @@ -276,33 +287,38 @@ class OpenHABRootViewController: UIViewController { } } - @objc func handleApnsMessage(notification: Notification) { - // actionIdentifier is the result of a action button being pressed - if let userInfo = notification.userInfo { - handleNotification(userInfo) - } - } - - private func handleNotification(_ userInfo: [AnyHashable: Any]) { + func handleNotification(_ userInfo: [AnyHashable: Any], completionHandler: (() -> Void)? = nil) { // actionIdentifier is the result of a action button being pressed // if not actionIdentifier, then the notification was clicked, so use "on-click" if there if let action = userInfo["actionIdentifier"] as? String ?? userInfo["on-click"] as? String { let cmd = action.split(separator: ":").dropFirst().joined(separator: ":") if action.hasPrefix("ui") { - uiCommandAction(cmd) + uiCommandAction(cmd, completionHandler: completionHandler) } else if action.hasPrefix("command") { - sendCommandAction(cmd) + sendCommandAction(cmd, completionHandler: completionHandler) } else if action.hasPrefix("http") { - httpCommandAction(action) + httpCommandAction(action, completionHandler: completionHandler) } else if action.hasPrefix("app") { - appCommandAction(action) + appCommandAction(action, completionHandler: completionHandler) } else if action.hasPrefix("rule") { - ruleCommandAction(action) + ruleCommandAction(action, completionHandler: completionHandler) + } else { + if let completionHandler { + DispatchQueue.main.async { + completionHandler() + } + } + } + } else { + if let completionHandler { + DispatchQueue.main.async { + completionHandler() + } } } } - private func uiCommandAction(_ command: String) { + private func uiCommandAction(_ command: String, completionHandler: (() -> Void)? = nil) { os_log("navigateCommandAction: %{PUBLIC}@", log: .notifications, type: .info, command) let regexPattern = /^(\/basicui\/app\\?.*|\/.*|.*)$/ if let firstMatch = command.firstMatch(of: regexPattern) { @@ -335,43 +351,87 @@ class OpenHABRootViewController: UIViewController { } else { os_log("Invalid regex: %{PUBLIC}@", log: .notifications, type: .error, command) } + if let completionHandler { + DispatchQueue.main.async { + completionHandler() + } + } } - private func sendCommandAction(_ action: String) { + private func sendCommandAction(_ action: String, completionHandler: (() -> Void)? = nil) { let components = action.split(separator: ":") if components.count == 2 { let itemName = String(components[0]) let itemCommand = String(components[1]) - // This will only fire onece since we do not retain the return cancelable - _ = NetworkTracker.shared.$activeConnection - .receive(on: DispatchQueue.main) - .sink { activeConnection in - if let openHABUrl = activeConnection?.configuration.url { - os_log("Sending comand", log: .default, type: .error) - let client = HTTPClient(username: Preferences.username, password: Preferences.password) - client.doPost(baseURLs: [openHABUrl], path: "/rest/items/\(itemName)", body: itemCommand) { data, _, error in - if let error { - os_log("Could not send data %{public}@", log: .default, type: .error, error.localizedDescription) - } else { - os_log("Request succeeded", log: .default, type: .info) - if let data { - os_log("Data: %{public}@", log: .default, type: .debug, String(data: data, encoding: .utf8) ?? "") - } + NetworkTracker.shared.waitForActiveConnection { activeConnection in + if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { + os_log("Sending comand", log: .default, type: .error) + let client = HTTPClient(username: Preferences.username, password: Preferences.password) + client.doPost(baseURL: url, path: "/rest/items/\(itemName)", body: itemCommand) { data, _, error in + if let error { + os_log("Could not send data %{public}@", log: .default, type: .error, error.localizedDescription) + self.displayErrorNotification("request to \(openHABUrl) \(error.localizedDescription)") + } else { + os_log("Request succeeded", log: .default, type: .info) + if let data { + os_log("Data: %{public}@", log: .default, type: .debug, String(data: data, encoding: .utf8) ?? "") + } + } + if let completionHandler { + DispatchQueue.main.async { + completionHandler() } } } + } else { + self.displayErrorNotification("Could not find server") + if let completionHandler { + DispatchQueue.main.async { + completionHandler() + } + } + } + } + .store(in: &cancellables) + } else { + if let completionHandler { + DispatchQueue.main.async { + completionHandler() } + } } } - private func httpCommandAction(_ command: String) { + private func displayErrorNotification(_ message: String, completionHandler: (() -> Void)? = nil) { + let content = UNMutableNotificationContent() + content.title = "Could not send command" + content.body = message + content.sound = UNNotificationSound.default + + // Create the request + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + + // Schedule the request with the notification center + UNUserNotificationCenter.current().add(request) { error in + if let error { + print("Error scheduling notification: \(error.localizedDescription)") + } + } + } + + private func httpCommandAction(_ command: String, completionHandler: (() -> Void)? = nil) { if let url = URL(string: command) { let vc = SFSafariViewController(url: url) present(vc, animated: true) } + if let completionHandler { + DispatchQueue.main.async { + completionHandler() + } + } } - private func appCommandAction(_ command: String) { + private func appCommandAction(_ command: String, completionHandler: (() -> Void)? = nil) { let content = command.dropFirst(4) // Remove "app:" let pairs = content.split(separator: ",") for pair in pairs { @@ -385,9 +445,14 @@ class OpenHABRootViewController: UIViewController { } } } + if let completionHandler { + DispatchQueue.main.async { + completionHandler() + } + } } - private func ruleCommandAction(_ command: String) { + private func ruleCommandAction(_ command: String, completionHandler: (() -> Void)? = nil) { let components = command.split(separator: ":", maxSplits: 2) guard components.count == 3, @@ -418,25 +483,36 @@ class OpenHABRootViewController: UIViewController { // nothing } - // This will only fire onece since we do not retain the return cancelable - _ = NetworkTracker.shared.$activeConnection - .receive(on: DispatchQueue.main) - .sink { activeConnection in - if let openHABUrl = activeConnection?.configuration.url { - os_log("Sending comand", log: .default, type: .error) - let client = HTTPClient(username: Preferences.username, password: Preferences.password) - client.doPost(baseURLs: [openHABUrl], path: "/rest/rules/rules/\(uuid)/runnow", body: jsonString) { data, _, error in - if let error { - os_log("Could not send data %{public}@", log: .default, type: .error, error.localizedDescription) - } else { - os_log("Request succeeded", log: .default, type: .info) - if let data { - os_log("Data: %{public}@", log: .default, type: .debug, String(data: data, encoding: .utf8) ?? "") - } + NetworkTracker.shared.waitForActiveConnection { activeConnection in + if let openHABUrl = activeConnection?.configuration.url, let url = URL(string: openHABUrl) { + os_log("Sending comand", log: .default, type: .error) + let client = HTTPClient(username: Preferences.username, password: Preferences.password) + client.doPost(baseURL: url, path: "/rest/rules/rules/\(uuid)/runnow", body: jsonString) { data, _, error in + if let error { + os_log("Could not send data %{public}@", log: .default, type: .error, error.localizedDescription) + self.displayErrorNotification("request to \(openHABUrl) \(error.localizedDescription)") + } else { + os_log("Request succeeded", log: .default, type: .info) + if let data { + os_log("Data: %{public}@", log: .default, type: .debug, String(data: data, encoding: .utf8) ?? "") + } + } + if let completionHandler { + DispatchQueue.main.async { + completionHandler() } } } + } else { + self.displayErrorNotification("Could not find active server") + if let completionHandler { + DispatchQueue.main.async { + completionHandler() + } + } } + } + .store(in: &cancellables) } func showSideMenu() { diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 7c34e48e..70e30d8a 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -478,23 +478,20 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel func pushSitemap(name: String, path: String?) { // this will be called imediately after connecting for the initial state, otherwise it will wait for the state to change // since we do not reference the sink cancelable, this will only fire once - _ = NetworkTracker.shared.$activeConnection - .filter { $0 != nil } // Only proceed if activeServer is not nil - .first() // Automatically cancels after the first non-nil value - .receive(on: DispatchQueue.main) - .sink { [weak self] activeConnection in - if let openHABUrl = activeConnection?.configuration.url, let self { - os_log("pushSitemap: pushing page", log: .default, type: .error) - let newViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! - if let path { - newViewController.pageUrl = "\(openHABUrl)/rest/sitemaps/\(name)/\(path)" - } else { - newViewController.pageUrl = "\(openHABUrl)/rest/sitemaps/\(name)" - } - newViewController.openHABRootUrl = openHABUrl - navigationController?.pushViewController(newViewController, animated: true) + NetworkTracker.shared.waitForActiveConnection { activeConnection in + if let openHABUrl = activeConnection?.configuration.url { + os_log("pushSitemap: pushing page", log: .default, type: .error) + let newViewController = (self.storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! + if let path { + newViewController.pageUrl = "\(openHABUrl)/rest/sitemaps/\(name)/\(path)" + } else { + newViewController.pageUrl = "\(openHABUrl)/rest/sitemaps/\(name)" } + newViewController.openHABRootUrl = openHABUrl + self.navigationController?.pushViewController(newViewController, animated: true) } + } + .store(in: &trackerCancellables) } // load app settings