diff --git a/.gitignore b/.gitignore index e716fd90b..b449fc8f4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ openHAB.ipa build/ BuildTools/.build OpenHABCore/Package.resolved +OpenHABCore/swift-openapi-generator/ diff --git a/BuildTools/.swiftlint.yml b/BuildTools/.swiftlint.yml index c6622e780..fef541dbe 100644 --- a/BuildTools/.swiftlint.yml +++ b/BuildTools/.swiftlint.yml @@ -29,6 +29,8 @@ excluded: - ../fastlane - ../OpenHABCore/.build - .build + - ../OpenHABCore/Sources/OpenHABCore/GeneratedSources/* + - ../OpenHABCore/swift-openapi-generator nesting: type_level: 2 diff --git a/Gemfile.lock b/Gemfile.lock index a7dd3d1f9..8982ec75c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,20 +10,20 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.952.0) - aws-sdk-core (3.201.1) + aws-partitions (1.974.0) + aws-sdk-core (3.205.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.8) + aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.88.0) - aws-sdk-core (~> 3, >= 3.201.0) + aws-sdk-kms (1.91.0) + aws-sdk-core (~> 3, >= 3.205.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.156.0) - aws-sdk-core (~> 3, >= 3.201.0) + aws-sdk-s3 (1.162.0) + aws-sdk-core (~> 3, >= 3.205.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.8.0) + aws-sigv4 (1.9.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) @@ -38,7 +38,7 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.110.0) + excon (0.111.0) faraday (1.10.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -60,7 +60,7 @@ GEM faraday-httpclient (1.0.1) faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (1.0.1) + faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) @@ -68,7 +68,7 @@ GEM faraday_middleware (1.2.0) faraday (~> 1.0) fastimage (2.3.1) - fastlane (2.221.1) + fastlane (2.222.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -110,7 +110,7 @@ GEM xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-plugin-changelog (0.16.0) - fastlane-plugin-versioning (0.5.2) + fastlane-plugin-versioning (0.6.0) gh_inspector (1.1.3) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) @@ -128,7 +128,7 @@ GEM google-apis-core (>= 0.11.0, < 2.a) google-apis-storage_v1 (0.31.0) google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.7.0) + google-cloud-core (1.7.1) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) @@ -149,14 +149,14 @@ GEM os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.6) + http-cookie (1.0.7) domain_name (~> 0.5) httpclient (2.8.3) jmespath (1.6.2) json (2.7.2) jwt (2.8.2) base64 - mini_magick (4.13.1) + mini_magick (4.13.2) mini_mime (1.1.5) multi_json (1.15.0) multipart-post (2.4.1) @@ -166,15 +166,14 @@ GEM optparse (0.5.0) os (1.1.4) plist (3.7.1) - public_suffix (6.0.0) + public_suffix (6.0.1) rake (13.2.1) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.2.9) - strscan + rexml (3.3.7) rouge (2.0.7) ruby2_keywords (0.0.5) rubyzip (2.3.2) @@ -187,7 +186,6 @@ GEM simctl (1.6.10) CFPropertyList naturally - strscan (3.1.0) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) @@ -197,15 +195,15 @@ GEM tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unicode-display_width (2.5.0) + unicode-display_width (2.6.0) word_wrap (1.0.0) - xcodeproj (1.24.0) + xcodeproj (1.25.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.3.0) - rexml (~> 3.2.4) + rexml (>= 3.3.2, < 4.0) xcpretty (0.3.0) rouge (~> 2.0.7) xcpretty-travis-formatter (1.0.1) @@ -220,4 +218,4 @@ DEPENDENCIES fastlane-plugin-versioning BUNDLED WITH - 2.5.7 + 2.5.11 diff --git a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift index f759251bb..8c921add5 100644 --- a/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift +++ b/OpenHABCore/Sources/OpenHABCore/Model/OpenHABItem.swift @@ -142,7 +142,7 @@ public extension OpenHABItem { public extension OpenHABItem.CodingData { var openHABItem: OpenHABItem { let mappedMembers = members?.map(\.openHABItem) ?? [] - + // swiftlint:disable:next line_length return OpenHABItem(name: name, type: type, state: state, link: link, label: label, groupType: groupType, stateDescription: stateDescription?.openHABStateDescription, commandDescription: commandDescription?.openHABCommandDescription, members: mappedMembers, category: category, options: options) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift index d0a8449cf..28577079a 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Endpoint.swift @@ -17,14 +17,34 @@ public enum ChartStyle { case light } -public enum IconType: Int { +public enum IconType: Int, CaseIterable, Identifiable, CustomStringConvertible { case png case svg + + public var id: Self { self } + + public var description: String { + switch self { + case .png: + "PNG" + case .svg: + "SVG" + } + } } -public enum SortSitemapsOrder: Int { +public enum SortSitemapsOrder: Int, CaseIterable, CustomStringConvertible { case label case name + + public var description: String { + switch self { + case .label: + "Label" + case .name: + "Name" + } + } } public struct Endpoint { diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift index e7a1cb0c4..1f4eb1af8 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -27,7 +27,7 @@ public class HTTPClient: NSObject { super.init() let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForRequest = 10 config.timeoutIntervalForResource = 60 session = URLSession(configuration: config, delegate: self, delegateQueue: nil) diff --git a/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift new file mode 100644 index 000000000..f66b62955 --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/NetworkTracker.swift @@ -0,0 +1,226 @@ +// Copyright (c) 2010-2024 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Alamofire +import Foundation +import Network +import os.log + +// TODO: these strings should reference Localizable keys +public enum NetworkStatus: String { + case notConnected = "Not Connected" + case connecting = "Connecting" + case connected = "Connected" + case connectionFailed = "Connection Failed" +} + +// Anticipating supporting more robust configuration options where we allow multiple url/user/pass options for users +public struct ConnectionObject: Equatable { + public let url: String + public let priority: Int // Lower is higher priority, 0 is primary + + public init(url: String, priority: Int = 10) { + self.url = url + self.priority = priority + } + + public static func == (lhs: ConnectionObject, rhs: ConnectionObject) -> Bool { + lhs.url == rhs.url && lhs.priority == rhs.priority + } +} + +public final class NetworkTracker: ObservableObject { + public static let shared = NetworkTracker() + + @Published public private(set) var activeServer: ConnectionObject? + @Published public private(set) var status: NetworkStatus = .notConnected + + private var monitor: NWPathMonitor + private var monitorQueue = DispatchQueue.global(qos: .background) + private var connectionObjects: [ConnectionObject] = [] + + private var retryTimer: DispatchSourceTimer? + + private init() { + monitor = NWPathMonitor() + monitor.pathUpdateHandler = { [weak self] path in + if path.status == .satisfied { + os_log("Network status: Connected", log: OSLog.default, type: .info) + self?.checkActiveServer() + } else { + os_log("Network status: Disconnected", log: OSLog.default, type: .info) + self?.updateStatus(.notConnected) + self?.startRetryTimer(10) // try every 10 seconds connect + } + } + monitor.start(queue: monitorQueue) + } + + public func startTracking(connectionObjects: [ConnectionObject]) { + self.connectionObjects = connectionObjects + attemptConnection() + } + + private func checkActiveServer() { + guard let activeServer, activeServer.priority == 0 else { + // No primary active server, proceed with the normal connection attempt + attemptConnection() + return + } + // Check if the last active server is reachable + NetworkConnection.tracker(openHABRootUrl: activeServer.url) { [weak self] response in + switch response.result { + case .success: + os_log("Network status: Active server is reachable: %{PUBLIC}@", log: OSLog.default, type: .info, activeServer.url) + self?.updateStatus(.connected) // If reachable, we're done + self?.cancelRetryTimer() + case .failure: + os_log("Network status: Active server is not reachable: %{PUBLIC}@", log: OSLog.default, type: .error, activeServer.url) + self?.attemptConnection() // If not reachable, run the connection logic + } + } + } + + private func attemptConnection() { + guard !connectionObjects.isEmpty else { + os_log("Network status: No connection objects available.", log: OSLog.default, type: .error) + updateStatus(.notConnected) + return + } + os_log("Network status: checking available servers....", log: OSLog.default, type: .error) + let dispatchGroup = DispatchGroup() + var highestPriorityConnection: ConnectionObject? + var firstAvailableConnection: ConnectionObject? + 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 + guard let self else { return } + // After 2 seconds, if no high-priority connection was found, check for first available connection + if let firstAvailableConnection, highestPriorityConnection == nil { + setActiveServer(firstAvailableConnection) + } else if highestPriorityConnection == nil, checkOutstanding { + os_log("Network status: No server responded in 2 seconds, waiting for checks to finish.", log: OSLog.default, type: .info) + } else { + os_log("Network status: No server responded in 2 seconds and no checks are outstanding.", log: OSLog.default, type: .error) + updateStatus(.connectionFailed) + } + } + + // Begin checking each connection object in parallel + for connection in connectionObjects { + dispatchGroup.enter() + checkOutstanding = true // Signal that checks are outstanding + + NetworkConnection.tracker(openHABRootUrl: connection.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 { + if connection.priority == 0, highestPriorityConnection == nil { + // Found a high-priority (0) connection + highestPriorityConnection = connection + priorityWorkItem?.cancel() // Stop the 2-second wait if highest priority succeeds + setActiveServer(connection) + } else if highestPriorityConnection == nil { + // Check if this connection has a higher priority than the current firstAvailableConnection + if firstAvailableConnection == nil || connection.priority < firstAvailableConnection!.priority { + os_log("Network status: Found a higher priority available connection: %{PUBLIC}@", log: OSLog.default, type: .info, connection.url) + firstAvailableConnection = connection + } + } + } else { + os_log("Network status: Invalid server version from %{PUBLIC}@", log: OSLog.default, type: .error, connection.url) + } + case let .failure(error): + os_log("Network status: Failed to connect to %{PUBLIC}@ : %{PUBLIC}@", log: OSLog.default, type: .error, connection.url, error.localizedDescription) + } + } + } + + // Start a timer that waits for 2 seconds + DispatchQueue.global().asyncAfter(deadline: .now() + priorityWaitTime, execute: priorityWorkItem!) + + // When all checks complete, finalize logic based on connection status + dispatchGroup.notify(queue: .main) { [weak self] in + guard let self else { return } + + // All checks are finished here, so no outstanding checks + checkOutstanding = false + + // If a high-priority connection was already established, we are done + if let highestPriorityConnection { + os_log("Network status: High-priority connection established with %{PUBLIC}@", log: OSLog.default, type: .info, highestPriorityConnection.url) + return + } + + // If we have an available connection and no high-priority connection, set the first available + if let firstAvailableConnection { + setActiveServer(firstAvailableConnection) + os_log("Network status: First available connection established with %{PUBLIC}@", log: OSLog.default, type: .info, firstAvailableConnection.url) + } else { + os_log("Network status: No server responded, connection failed.", log: OSLog.default, type: .error) + updateStatus(.connectionFailed) + } + } + } + + // Start the retry timer to attempt connection every N seconds + private func startRetryTimer(_ retryInterval: TimeInterval) { + cancelRetryTimer() + retryTimer = DispatchSource.makeTimerSource(queue: DispatchQueue.global()) + retryTimer?.schedule(deadline: .now() + retryInterval, repeating: retryInterval) + retryTimer?.setEventHandler { [weak self] in + os_log("Network status: Retry timer firing", log: OSLog.default, type: .info) + self?.attemptConnection() + } + retryTimer?.resume() + } + + // Cancel the retry timer + private func cancelRetryTimer() { + retryTimer?.cancel() + retryTimer = nil + } + + private func setActiveServer(_ server: ConnectionObject) { + os_log("Network status: setActiveServer: %{PUBLIC}@", log: OSLog.default, type: .info, server.url) + if activeServer != server { + activeServer = server + } + updateStatus(.connected) + startRetryTimer(60) // check every 60 seconds to see if a better server is available. + } + + private func updateStatus(_ newStatus: NetworkStatus) { + if status != newStatus { + 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/Preferences.swift b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift index d030bbb3e..b1842913f 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/Preferences.swift @@ -9,64 +9,86 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import os.log import UIKit -// Convenient access to UserDefaults - -// Much shorter as Property Wrappers are available with Swift 5.1 -// Inspired by https://www.avanderlee.com/swift/property-wrappers/ @propertyWrapper public struct UserDefault { - let key: String - let defaultValue: T + private let key: String + private let defaultValue: T + private let subject: CurrentValueSubject public var wrappedValue: T { get { - Preferences.sharedDefaults.object(forKey: key) as? T ?? defaultValue + let value = Preferences.sharedDefaults.object(forKey: key) as? T ?? defaultValue + return value } set { Preferences.sharedDefaults.set(newValue, forKey: key) + let subject = subject + DispatchQueue.main.async { + subject.send(newValue) + } } } - init(_ key: String, defaultValue: T) { + public init(_ key: String, defaultValue: T) { self.key = key self.defaultValue = defaultValue + let currentValue = Preferences.sharedDefaults.object(forKey: key) as? T ?? defaultValue + subject = CurrentValueSubject(currentValue) } -} -// It would be nice to write something like @UserDefault @TrimmedURL ("localUrl", defaultValue: "test") static var localUrl: String -// As long as multiple property wrappers are not supported we need to add a little repetitive boiler plate code + public var projectedValue: AnyPublisher { + subject.eraseToAnyPublisher() + } +} @propertyWrapper public struct UserDefaultURL { - let key: String - let defaultValue: String + private let key: String + private let defaultValue: String + private let subject: CurrentValueSubject public var wrappedValue: String { get { - guard let localUrl = Preferences.sharedDefaults.string(forKey: key) else { return defaultValue } - let trimmedUri = uriWithoutTrailingSlashes(localUrl).trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - if !trimmedUri.isValidURL { return defaultValue } - return trimmedUri + let storedValue = Preferences.sharedDefaults.string(forKey: key) ?? defaultValue + let trimmedUri = uriWithoutTrailingSlashes(storedValue).trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedUri.isValidURL ? trimmedUri : defaultValue } set { Preferences.sharedDefaults.set(newValue, forKey: key) + let subject = subject + let defaultValue = defaultValue + // Trim and validate the new URL + let trimmedUri = uriWithoutTrailingSlashes(newValue).trimmingCharacters(in: .whitespacesAndNewlines) + DispatchQueue.main.async { + if trimmedUri.isValidURL { + subject.send(trimmedUri) + } else { + subject.send(defaultValue) + } + } } } - init(_ key: String, defaultValue: String) { + public init(_ key: String, defaultValue: String) { self.key = key self.defaultValue = defaultValue + let currentValue = Preferences.sharedDefaults.string(forKey: key) ?? defaultValue + subject = CurrentValueSubject(currentValue) } - func uriWithoutTrailingSlashes(_ hostUri: String) -> String { - if !hostUri.hasSuffix("/") { - return hostUri - } + public var projectedValue: AnyPublisher { + subject.eraseToAnyPublisher() + } - return String(hostUri[.. String { + if hostUri.hasSuffix("/") { + return String(hostUri[.. + allowLocationSimulation = "YES"> String { + NetworkConnection.shared.clientCertificateManager.getIdentityName(index: index) + } +} diff --git a/openHAB/ColorPickerView.swift b/openHAB/ColorPickerView.swift new file mode 100644 index 000000000..7519187e8 --- /dev/null +++ b/openHAB/ColorPickerView.swift @@ -0,0 +1,77 @@ +// Copyright (c) 2010-2024 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Combine +import OpenHABCore +import os +import SwiftUI + +struct ColorPickerView: View { + @State private var selectedColor: Color = .white + @State private var hue: Double = 0.0 + @State private var saturation: Double = 0.0 + @State private var brightness: Double = 0.0 + + @ObservedObject var throttler = Throttler(maxInterval: 0.3) + + var widget: OpenHABWidget? // OpenHAB widget for sending commands + + private let logger = Logger(subsystem: "org.openhab.app", category: "ColorPickerView") + + var body: some View { + VStack { + // SwiftUI Color Picker + ColorPicker("Pick a Color", selection: $selectedColor) + .onChange(of: selectedColor) { newColor in + throttler.throttle { + updateHSB(from: newColor) + sendColorUpdate() + } + } + .padding() + + // Displaying HSB values + Text("Hue: \(hue, specifier: "%.2f")") + Text("Saturation: \(saturation, specifier: "%.2f")") + Text("Brightness: \(brightness, specifier: "%.2f")") + } + .onAppear { + // Set initial color from widget if available + if let initialColor = widget?.item?.stateAsUIColor() { + selectedColor = Color(initialColor) + } + } + .background(Color(UIColor.systemBackground)) + } + + // Update hue, saturation, brightness from Color + func updateHSB(from color: Color) { + let uiColor = UIColor(color) + // swiftlint:disable:next large_tuple + var (hue, saturation, brightness, alpha): (CGFloat, CGFloat, CGFloat, CGFloat) = (0.0, 0.0, 0.0, 0.0) + uiColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + self.hue = Double(hue * 360) // Convert to degrees + self.saturation = Double(saturation * 100) + self.brightness = Double(brightness * 100) + } + + // Send the color update to the widget + func sendColorUpdate() { + let command = "\(hue),\(saturation),\(brightness)" + logger.debug("Sending command: \(command)") + widget?.sendCommand(command) + } +} + +#Preview { + ColorPickerView() +} diff --git a/openHAB/DrawerUITableViewCell.swift b/openHAB/DrawerUITableViewCell.swift deleted file mode 100644 index 0efe0d91b..000000000 --- a/openHAB/DrawerUITableViewCell.swift +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2010-2024 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import os.log -import UIKit - -class DrawerUITableViewCell: UITableViewCell { - @IBOutlet private(set) var customTextLabel: UILabel! - @IBOutlet private(set) var customImageView: UIImageView! - - required init?(coder: NSCoder) { - os_log("DrawerUITableViewCell initWithCoder", log: .viewCycle, type: .info) - super.init(coder: coder) - - separatorInset = .zero - } - - // This is to fix possible different sizes of user icons - we fix size and position of UITableViewCell icons - override func layoutSubviews() { - super.layoutSubviews() - imageView?.frame = CGRect(x: 14, y: 6, width: 30, height: 30) - } -} diff --git a/openHAB/DrawerView.swift b/openHAB/DrawerView.swift new file mode 100644 index 000000000..291428a1d --- /dev/null +++ b/openHAB/DrawerView.swift @@ -0,0 +1,235 @@ +// Copyright (c) 2010-2024 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Kingfisher +import OpenHABCore +import os.log +import SafariServices +import SFSafeSymbols +import SwiftUI + +func deriveSitemaps(_ response: Data?) -> [OpenHABSitemap] { + var sitemaps = [OpenHABSitemap]() + + if let response { + do { + os_log("Response will be decoded by JSON", log: .remoteAccess, type: .info) + let sitemapsCodingData = try response.decoded(as: [OpenHABSitemap.CodingData].self) + for sitemapCodingDatum in sitemapsCodingData { + os_log("Sitemap %{PUBLIC}@", log: .remoteAccess, type: .info, sitemapCodingDatum.label) + sitemaps.append(sitemapCodingDatum.openHABSitemap) + } + } catch { + os_log("Should not throw %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) + } + } + + return sitemaps +} + +struct UiTile: Decodable { + var name: String + var url: String + var imageUrl: String +} + +struct ImageView: View { + let url: String + + // App wide data access + var appData: OpenHABDataObject? { + AppDelegate.appDelegate.appData + } + + var body: some View { + if !url.isEmpty { + switch url { + case _ where url.hasPrefix("data:image"): + let provider = Base64ImageDataProvider(base64String: url.deletingPrefix("data:image/png;base64,"), cacheKey: UUID().uuidString) + return KFImage(source: .provider(provider)).resizable() + case _ where url.hasPrefix("http"): + return KFImage(URL(string: url)).resizable() + default: + let builtURL = Endpoint.resource(openHABRootUrl: appData?.openHABRootUrl ?? "", path: url.prepare()).url + return KFImage(builtURL).resizable() + } + } else { + // This will always fallback to placeholder + return KFImage(URL(string: "bundle://openHABIcon")).placeholder { Image("openHABIcon").resizable() } + } + } +} + +struct DrawerView: View { + @State private var sitemaps: [OpenHABSitemap] = [] + @State private var uiTiles: [OpenHABUiTile] = [] + @State private var selectedSection: Int? + + var openHABUsername = "" + var openHABPassword = "" + + var onDismiss: (TargetController) -> Void + @Environment(\.dismiss) private var dismiss + + // App wide data access + var appData: OpenHABDataObject? { + AppDelegate.appDelegate.appData + } + + @ScaledMetric var openHABIconwidth = 20.0 + @ScaledMetric var tilesIconwidth = 20.0 + @ScaledMetric var sitemapIconwidth = 20.0 + + var body: some View { + List { + Section(header: Text("Main")) { + HStack { + Image("openHABIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: openHABIconwidth) + Text("Home") + } + .onTapGesture { + dismiss() + onDismiss(.webview) + } + } + + Section(header: Text("Tiles")) { + ForEach(uiTiles, id: \.url) { tile in + HStack { + ImageView(url: tile.imageUrl) + .aspectRatio(contentMode: .fit) + .frame(width: tilesIconwidth) + Text(tile.name) + } + .onTapGesture { + dismiss() + onDismiss(.tile(tile.url)) + } + } + } + + Section(header: Text("Sitemaps")) { + ForEach(sitemaps, id: \.name) { sitemap in + HStack { + let url = Endpoint.iconForDrawer(rootUrl: appData?.openHABRootUrl ?? "", icon: sitemap.icon).url + KFImage(url).placeholder { Image("openHABIcon").resizable() } + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: sitemapIconwidth) + Text(sitemap.label) + } + .onTapGesture { + dismiss() + onDismiss(.sitemap(sitemap.name)) + } + } + } + + Section(header: Text("System")) { + HStack { + Image(systemSymbol: .gear) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: openHABIconwidth) + Text(LocalizedStringKey("settings")) + } + .onTapGesture { + dismiss() + onDismiss(.settings) + } + + // check if we are using my.openHAB, add notifications menu item then + // Actually this should better test whether the host of the remoteUrl is on openhab.org + if Preferences.remoteUrl.contains("openhab.org"), !Preferences.demomode { + HStack { + Image(systemSymbol: .bell) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: openHABIconwidth) + Text(LocalizedStringKey("notifications")) + } + .onTapGesture { + dismiss() + onDismiss(.notifications) + } + } + } + } + .listStyle(.inset) + .onAppear(perform: loadData) + } + + private func loadData() { + // TODO: Replace network calls with appropriate @EnvironmentObject or other state management + loadSitemaps() + loadUiTiles() + } + + private func loadSitemaps() { + // Perform network call to load sitemaps and decode + // Update the sitemaps state + + NetworkConnection.sitemaps(openHABRootUrl: appData?.openHABRootUrl ?? "") { response in + switch response.result { + case let .success(data): + os_log("Sitemap response", log: .viewCycle, type: .info) + + sitemaps = deriveSitemaps(data) + + if sitemaps.last?.name == "_default", sitemaps.count > 1 { + sitemaps = Array(sitemaps.dropLast()) + } + + // Sort the sitemaps according to Settings selection. + switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label { + case .label: sitemaps.sort { $0.label < $1.label } + case .name: sitemaps.sort { $0.name < $1.name } + } + case let .failure(error): + os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + } + } + } + + private func loadUiTiles() { + // Perform network call to load UI Tiles and decode + // Update the uiTiles state + NetworkConnection.uiTiles(openHABRootUrl: appData?.openHABRootUrl ?? "") { response in + switch response.result { + case .success: + os_log("ui tiles response", log: .viewCycle, type: .info) + guard let responseData = response.data else { + os_log("Error: did not receive data", log: OSLog.remoteAccess, type: .info) + return + } + do { + uiTiles = try JSONDecoder().decode([OpenHABUiTile].self, from: responseData) + } catch { + os_log("Error: did not receive data %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, error.localizedDescription) + } + case let .failure(error): + os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + } + } + } + + mutating func loadSettings() { + openHABUsername = Preferences.username + openHABPassword = Preferences.password + } +} + +#Preview { + DrawerView { _ in } +} diff --git a/openHAB/Main.storyboard b/openHAB/Main.storyboard index e071c7cd7..74fd991da 100644 --- a/openHAB/Main.storyboard +++ b/openHAB/Main.storyboard @@ -615,814 +615,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1455,46 +647,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1513,73 +670,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1598,67 +688,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1683,12 +712,6 @@ - - - - - - diff --git a/openHAB/MulticastDelegate.swift b/openHAB/MulticastDelegate.swift deleted file mode 100644 index d53ac2c1e..000000000 --- a/openHAB/MulticastDelegate.swift +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2010-2024 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation - -/** - See https://www.vadimbulavin.com/multicast-delegate/ - */ -class MulticastDelegate { - private let delegates: NSHashTable = NSHashTable.weakObjects() - - func add(_ delegate: T) { - delegates.add(delegate as AnyObject) - } - - func remove(_ delegateToRemove: T) { - for delegate in delegates.allObjects.reversed() where delegate === delegateToRemove as AnyObject { - delegates.remove(delegate) - } - } - - func invoke(_ invocation: (T) -> Void) { - for delegate in delegates.allObjects.reversed() { - invocation(delegate as! T) - } - } -} diff --git a/openHAB/NotificationsView.swift b/openHAB/NotificationsView.swift new file mode 100644 index 000000000..d3eaf56c3 --- /dev/null +++ b/openHAB/NotificationsView.swift @@ -0,0 +1,114 @@ +// Copyright (c) 2010-2024 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Kingfisher +import OpenHABCore +import os.log +import SwiftUI + +struct NotificationRow: View { + var notification: OpenHABNotification + + // App wide data access + var appData: OpenHABDataObject? { + AppDelegate.appDelegate.appData + } + + var body: some View { + HStack { + KFImage(iconUrl) + .placeholder { + Image("openHABIcon").resizable() + } + .resizable() + .frame(width: 40, height: 40) + .cornerRadius(8) + VStack(alignment: .leading) { + Text(notification.message ?? "") + .font(.body) + if let timeStamp = notification.created { + Text(dateString(from: timeStamp)) + .font(.caption) + .foregroundColor(.gray) + } + } + } + + .padding(.vertical, 8) + } + + private var iconUrl: URL? { + if let appData { + return Endpoint.icon( + rootUrl: appData.openHABRootUrl, + version: appData.openHABVersion, + icon: notification.icon, + state: "", + iconType: .png, + iconColor: "" + ).url + } + return nil + } + + private func dateString(from date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + formatter.timeZone = TimeZone.current + return formatter.string(from: date) + } +} + +struct NotificationsView: View { + @State var notifications: [OpenHABNotification] = [] + + var body: some View { + List(notifications, id: \.id) { notification in + NotificationRow(notification: notification) + } + .refreshable { + loadNotifications() + } + .navigationTitle("Notifications") + .onAppear { + loadNotifications() + } + } + + private func loadNotifications() { + NetworkConnection.notification(urlString: Preferences.remoteUrl) { response in + DispatchQueue.main.async { + switch response.result { + case let .success(data): + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) + let codingDatas = try data.decoded(as: [OpenHABNotification.CodingData].self, using: decoder) + notifications = codingDatas.map(\.openHABNotification) + } catch { + os_log("%{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) + } + case let .failure(error): + os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) + } + } + } + } +} + +#Preview { + NotificationsView(notifications: [OpenHABNotification(message: "message1", created: Date.now, id: UUID().uuidString), OpenHABNotification(message: "message2", created: Date.now, id: UUID().uuidString)]) +} + +#Preview { + NotificationRow(notification: OpenHABNotification(message: "message3", created: Date.now)) +} diff --git a/openHAB/OpenHABClientCertificatesViewController.swift b/openHAB/OpenHABClientCertificatesViewController.swift deleted file mode 100644 index da74346fb..000000000 --- a/openHAB/OpenHABClientCertificatesViewController.swift +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2010-2024 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import UIKit - -class OpenHABClientCertificatesViewController: UITableViewController { - static let tableViewCellIdentifier = "ClientCertificatesCell" - - var clientCertificates: [SecIdentity] = [] - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - override func viewDidLoad() { - super.viewDidLoad() - navigationItem.title = NSLocalizedString("client_certificates", comment: "") - os_log("OpenHABClientCertificatesViewController viewDidLoad", log: .default, type: .info) - - tableView.tableFooterView = UIView() - tableView.allowsMultipleSelectionDuringEditing = false - } - - override func viewWillAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - tableView.reloadData() - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - // Return the number of rows in the section. - NetworkConnection.shared.clientCertificateManager.clientIdentities.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: UITableViewCell = tableView.dequeueReusableCell(withIdentifier: OpenHABClientCertificatesViewController.tableViewCellIdentifier, for: indexPath) - cell.textLabel?.text = NetworkConnection.shared.clientCertificateManager.getIdentityName(index: indexPath.row) - return cell - } - - override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - if editingStyle == UITableViewCell.EditingStyle.delete { - let status = NetworkConnection.shared.clientCertificateManager.deleteFromKeychain(index: indexPath.row) - if status == noErr { - tableView.deleteRows(at: [indexPath], with: .fade) - } - } - } -} diff --git a/openHAB/OpenHABDrawerItem.swift b/openHAB/OpenHABDrawerItem.swift deleted file mode 100644 index ce8924bf5..000000000 --- a/openHAB/OpenHABDrawerItem.swift +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2010-2024 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Foundation - -enum OpenHABDrawerItem { - case settings - case notifications - - var localizedString: String { - switch self { - case .settings: - NSLocalizedString("settings", comment: "") - case .notifications: - NSLocalizedString("notifications", comment: "") - } - } - - static func openHABDrawerItem(localizedString: String) -> OpenHABDrawerItem { - switch localizedString { - case OpenHABDrawerItem.settings.localizedString: - OpenHABDrawerItem.settings - case OpenHABDrawerItem.notifications.localizedString: - OpenHABDrawerItem.notifications - default: - OpenHABDrawerItem.settings - } - } -} diff --git a/openHAB/OpenHABDrawerTableViewController.swift b/openHAB/OpenHABDrawerTableViewController.swift deleted file mode 100644 index d2f12070a..000000000 --- a/openHAB/OpenHABDrawerTableViewController.swift +++ /dev/null @@ -1,335 +0,0 @@ -// Copyright (c) 2010-2024 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import SafariServices -import SFSafeSymbols -import UIKit - -func deriveSitemaps(_ response: Data?) -> [OpenHABSitemap] { - var sitemaps = [OpenHABSitemap]() - - if let response { - do { - os_log("Response will be decoded by JSON", log: .remoteAccess, type: .info) - let sitemapsCodingData = try response.decoded(as: [OpenHABSitemap.CodingData].self) - for sitemapCodingDatum in sitemapsCodingData { - os_log("Sitemap %{PUBLIC}@", log: .remoteAccess, type: .info, sitemapCodingDatum.label) - sitemaps.append(sitemapCodingDatum.openHABSitemap) - } - } catch { - os_log("Should not throw %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) - } - } - - return sitemaps -} - -struct UiTile: Decodable { - var name: String - var url: String - var imageUrl: String -} - -class OpenHABDrawerTableViewController: UITableViewController { - static let tableViewCellIdentifier = "DrawerCell" - - var sitemaps: [OpenHABSitemap] = [] - var uiTiles: [OpenHABUiTile] = [] - var openHABUsername = "" - var openHABPassword = "" - var drawerItems: [OpenHABDrawerItem] = [] - weak var delegate: ModalHandler? - - // App wide data access - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } - - init() { - super.init(nibName: nil, bundle: nil) - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - override func viewDidLoad() { - super.viewDidLoad() - tableView.tableFooterView = UIView() - drawerItems = [] - sitemaps = [] - loadSettings() - setStandardDrawerItems() - os_log("OpenHABDrawerTableViewController did load", log: .viewCycle, type: .info) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - os_log("OpenHABDrawerTableViewController viewWillAppear", log: .viewCycle, type: .info) - - NetworkConnection.sitemaps(openHABRootUrl: appData?.openHABRootUrl ?? "") { response in - switch response.result { - case let .success(data): - os_log("Sitemap response", log: .viewCycle, type: .info) - - self.sitemaps = deriveSitemaps(data) - - if self.sitemaps.last?.name == "_default", self.sitemaps.count > 1 { - self.sitemaps = Array(self.sitemaps.dropLast()) - } - - // Sort the sitemaps according to Settings selection. - switch SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label { - case .label: self.sitemaps.sort { $0.label < $1.label } - case .name: self.sitemaps.sort { $0.name < $1.name } - } - - self.drawerItems.removeAll() - self.setStandardDrawerItems() - self.tableView.reloadData() - case let .failure(error): - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - self.drawerItems.removeAll() - self.setStandardDrawerItems() - self.tableView.reloadData() - } - } - - NetworkConnection.uiTiles(openHABRootUrl: appData?.openHABRootUrl ?? "") { response in - switch response.result { - case .success: - UIApplication.shared.isNetworkActivityIndicatorVisible = false - os_log("ui tiles response", log: .viewCycle, type: .info) - guard let responseData = response.data else { - os_log("Error: did not receive data", log: OSLog.remoteAccess, type: .info) - return - } - do { - self.uiTiles = try JSONDecoder().decode([OpenHABUiTile].self, from: responseData) - self.tableView.reloadData() - } catch { - os_log("Error: did not receive data %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, error.localizedDescription) - } - case let .failure(error): - UIApplication.shared.isNetworkActivityIndicatorVisible = false - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - } - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - tableView.reloadData() - os_log("RightDrawerViewController viewDidAppear", log: .viewCycle, type: .info) - os_log("Sitemap count: %d", log: .viewCycle, type: .info, Int(sitemaps.count)) - os_log("Menu items count: %d", log: .viewCycle, type: .info, Int(drawerItems.count)) - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) // Call the super class implementation. - os_log("RightDrawerViewController viewDidDisappear", log: .viewCycle, type: .info) - } - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - 4 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch section { - case 0: - 1 - case 1: - uiTiles.count - case 2: - sitemaps.count - case 3: - drawerItems.count - default: - 0 - } - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - switch section { - case 0: - "Main" - case 1: - "Tiles" - case 2: - "Sitemaps" - case 3: - "System" - default: - "Unknown" - } - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = (tableView.dequeueReusableCell(withIdentifier: OpenHABDrawerTableViewController.tableViewCellIdentifier) as? DrawerUITableViewCell)! - cell.customImageView.subviews.forEach { $0.removeFromSuperview() } - cell.accessoryView = nil - switch indexPath.section { - case 0: - cell.customTextLabel?.text = "Home" - cell.customImageView.image = UIImage(named: "openHABIcon") - if let currentView = appData?.currentView { - // if we already are on the webview, pressing this again will force a refresh - if currentView == .webview { - cell.accessoryView = UIImageView(image: UIImage(named: "arrow.triangle.2.circlepath")) - } - } - case 1: - let imageView = UIImageView(frame: cell.customImageView.bounds) - let tile = uiTiles[indexPath.row] - cell.customTextLabel?.text = tile.name - let passedURL = tile.imageUrl - // Dependent on $OPENHAB_CONF/services/runtime.cfg - // Can either be an absolute URL, a path (sometimes malformed) or the content to be displayed (for imageURL) - if !passedURL.isEmpty { - switch passedURL { - case _ where passedURL.hasPrefix("data:image"): - if let imageData = Data(base64Encoded: passedURL.deletingPrefix("data:image/png;base64,"), options: .ignoreUnknownCharacters) { - imageView.image = UIImage(data: imageData) - } // data;image/png;base64, - case _ where passedURL.hasPrefix("http"): - os_log("Loading %{PUBLIC}@", log: .default, type: .info, String(describing: passedURL)) - imageView.kf.setImage(with: URL(string: passedURL), placeholder: UIImage(named: "openHABIcon")) - default: - if let builtURL = Endpoint.resource(openHABRootUrl: appData?.openHABRootUrl ?? "", path: passedURL.prepare()).url { - os_log("Loading %{PUBLIC}@", log: .default, type: .info, String(describing: builtURL)) - imageView.kf.setImage(with: builtURL, placeholder: UIImage(named: "openHABIcon")) - } - } - } else { - imageView.image = UIImage(named: "openHABIcon") - } - cell.customImageView.image = imageView.image - case 2: - if !sitemaps.isEmpty { - let siteMapIndex = indexPath.row - let imageView = UIImageView(frame: cell.customImageView.bounds) - - cell.customTextLabel?.text = sitemaps[siteMapIndex].label - if !sitemaps[siteMapIndex].icon.isEmpty { - if let iconURL = Endpoint.iconForDrawer(rootUrl: appData?.openHABRootUrl ?? "", icon: sitemaps[siteMapIndex].icon).url { - imageView.kf.setImage(with: iconURL, placeholder: UIImage(named: "openHABIcon")) - } - } else { - imageView.image = UIImage(named: "openHABIcon") - } - cell.customImageView.image = imageView.image - } - case 3: - // Then menu items - let drawerItem = drawerItems[indexPath.row] - cell.customTextLabel?.text = drawerItem.localizedString - switch drawerItem { - case .notifications: - cell.customImageView.image = UIImage(systemSymbol: .bell) - case .settings: - cell.customImageView.image = UIImage(systemSymbol: .gear) - } - default: - break - } - cell.separatorInset = UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 0) - - cell.preservesSuperviewLayoutMargins = false - cell.layoutMargins = .zero - - return cell - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - 44 - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - // open a alert with an OK and cancel button - os_log("Clicked on drawer section %d row %d", log: .viewCycle, type: .info, indexPath.section, indexPath.row) - - tableView.deselectRow(at: indexPath, animated: false) - // First sitemaps - switch indexPath.section { - case 0: - dismiss(animated: true) { - self.delegate?.modalDismissed(to: .webview) - } - case 1: - let passedURL = uiTiles[indexPath.row].url - // Dependent on $OPENHAB_CONF/services/runtime.cfg - // Can either be an absolute URL, a path (sometimes malformed) - if !passedURL.isEmpty { - switch passedURL { - case _ where passedURL.hasPrefix("http"): - openURL(url: URL(string: passedURL)) - default: - let builtURL = Endpoint.resource(openHABRootUrl: appData?.openHABRootUrl ?? "", path: passedURL.prepare()) - openURL(url: builtURL.url) - } - } - case 2: - if !sitemaps.isEmpty { - let sitemap = sitemaps[indexPath.row] - Preferences.defaultSitemap = sitemap.name - appData?.sitemapViewController?.pageUrl = "" - dismiss(animated: true) { - os_log("self delegate %d", log: .viewCycle, type: .info, self.delegate != nil) - self.delegate?.modalDismissed(to: .sitemap) - } - } - case 3: - // Then menu items - let drawerItem = drawerItems[indexPath.row] - - switch drawerItem { - case .settings: - dismiss(animated: true) { - self.delegate?.modalDismissed(to: .settings) - } - case .notifications: - dismiss(animated: true) { - self.delegate?.modalDismissed(to: .notifications) - } - } - default: - break - } - } - - private func setStandardDrawerItems() { - // check if we are using my.openHAB, add notifications menu item then - // Actually this should better test whether the host of the remoteUrl is on openhab.org - if Preferences.remoteUrl.contains("openhab.org"), !Preferences.demomode { - drawerItems.append(.notifications) - } - // Settings always go last - drawerItems.append(.settings) - } - - func loadSettings() { - openHABUsername = Preferences.username - openHABPassword = Preferences.password - } - - private func openURL(url: URL?) { - if let url { - let config = SFSafariViewController.Configuration() - config.entersReaderIfAvailable = true - let vc = SFSafariViewController(url: url, configuration: config) - present(vc, animated: true) - } - } -} diff --git a/openHAB/OpenHABLegalViewController.swift b/openHAB/OpenHABLegalViewController.swift deleted file mode 100644 index 4c63aec40..000000000 --- a/openHAB/OpenHABLegalViewController.swift +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2010-2024 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import UIKit - -class OpenHABLegalViewController: UIViewController { - @IBOutlet private var legalTextView: UITextView! - - override func viewDidLoad() { - super.viewDidLoad() - let legalPath = Bundle.main.url(forResource: "legal", withExtension: "rtf") - var legalAttributedString: NSAttributedString? - if let legalPath { - legalAttributedString = try? NSAttributedString( - url: legalPath, - options: [.characterEncoding: String.Encoding.utf8.rawValue], - documentAttributes: nil - ) - } - if let legalAttributedString { - legalTextView.attributedText = legalAttributedString - } - - legalTextView.backgroundColor = .ohSystemBackground - legalTextView.textColor = .ohLabel - } -} diff --git a/openHAB/OpenHABNotification.swift b/openHAB/OpenHABNotification.swift index 4970cc65b..cab69b023 100644 --- a/openHAB/OpenHABNotification.swift +++ b/openHAB/OpenHABNotification.swift @@ -12,19 +12,22 @@ import Foundation class OpenHABNotification: NSObject { - var message = "" + var message: String? var created: Date? - var icon = "" - var severity = "" - - init(message: String, created: Date?) { + var icon: String? + var severity: String? + var id = "" + init(message: String? = nil, created: Date? = nil, icon: String? = nil, severity: String? = nil, id: String = "") { self.message = message self.created = created + self.icon = icon + self.severity = severity + self.id = id } - init(dictionary: [String: Any]) { + convenience init(dictionary: [String: Any]) { let propertyNames: Set = ["message", "icon", "severity"] - super.init() + self.init() let keyArray = dictionary.keys for key in keyArray { if key as String == "created" { @@ -47,7 +50,7 @@ class OpenHABNotification: NSObject { extension OpenHABNotification { public struct CodingData: Decodable { let id: String - let message: String + let message: String? let v: Int let created: Date? @@ -63,6 +66,6 @@ extension OpenHABNotification { // Convenience method to convert a decoded value into a proper OpenHABNotification instance extension OpenHABNotification.CodingData { var openHABNotification: OpenHABNotification { - OpenHABNotification(message: message, created: created) + OpenHABNotification(message: message, created: created, id: id) } } diff --git a/openHAB/OpenHABNotificationsViewController.swift b/openHAB/OpenHABNotificationsViewController.swift deleted file mode 100644 index c78ecdca7..000000000 --- a/openHAB/OpenHABNotificationsViewController.swift +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) 2010-2024 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import SideMenu -import UIKit - -class OpenHABNotificationsViewController: UITableViewController { - static let tableViewCellIdentifier = "NotificationCell" - - var notifications: NSMutableArray = [] - var openHABRootUrl = "" - var openHABUsername = "" - var openHABPassword = "" - - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } - - override func viewDidLoad() { - super.viewDidLoad() - notifications = [] - tableView.tableFooterView = UIView() - refreshControl = UIRefreshControl() - refreshControl?.addTarget(self, action: #selector(OpenHABNotificationsViewController.handleRefresh(_:)), for: .valueChanged) - if let refreshControl { - tableView.refreshControl = refreshControl - } - - navigationItem.largeTitleDisplayMode = .never - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - loadSettings() - loadNotifications() - } - - func loadNotifications() { - NetworkConnection.notification(urlString: Preferences.remoteUrl) { response in - switch response.result { - case let .success(data): - do { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) - let codingDatas = try data.decoded(as: [OpenHABNotification.CodingData].self, using: decoder) - self.notifications = [] - for codingDatum in codingDatas { - self.notifications.add(codingDatum.openHABNotification) - } - } catch { - os_log("%{PUBLIC}@ ", log: .default, type: .error, error.localizedDescription) - } - - self.refreshControl?.endRefreshing() - self.tableView.reloadData() - - case let .failure(error): - os_log("%{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - self.refreshControl?.endRefreshing() - } - } - } - - @objc - func handleRefresh(_ refreshControl: UIRefreshControl?) { - os_log("Refresh pulled", log: .default, type: .info) - loadNotifications() - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - notifications.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: OpenHABNotificationsViewController.tableViewCellIdentifier) as? NotificationTableViewCell - guard let notification = notifications[indexPath.row] as? OpenHABNotification else { return UITableViewCell() } - - cell?.customTextLabel?.text = notification.message - - if let timeStamp = notification.created { - let dateFormatter = DateFormatter() - dateFormatter.dateStyle = .medium - dateFormatter.timeStyle = .medium - dateFormatter.timeZone = TimeZone.current - cell?.customDetailTextLabel?.text = dateFormatter.string(from: timeStamp) - } - - if let iconUrl = Endpoint.icon( - rootUrl: appData!.openHABRootUrl, - version: appData!.openHABVersion, - icon: notification.icon, - state: "", - iconType: .png, - iconColor: "" - ).url { - cell?.imageView?.kf.setImage( - with: iconUrl, - placeholder: UIImage(named: "openHABIcon") - ) - } - - if cell?.responds(to: #selector(setter: NotificationTableViewCell.preservesSuperviewLayoutMargins)) ?? false { - cell?.preservesSuperviewLayoutMargins = false - } - // Explictly set your cell's layout margins - if cell?.responds(to: #selector(setter: NotificationTableViewCell.layoutMargins)) ?? false { - cell?.layoutMargins = .zero - } - cell?.separatorInset = UIEdgeInsets(top: 0, left: 60, bottom: 0, right: 0) - return cell! - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - // open a alert with an OK and cancel button - tableView.deselectRow(at: indexPath, animated: false) - } - - func loadSettings() { - appData?.openHABUsername = Preferences.username - appData?.openHABPassword = Preferences.password - } -} - -extension UIBarButtonItem { - static func menuButton(_ target: Any?, action: Selector, imageName: String) -> UIBarButtonItem { - let button = UIButton(type: .system) - button.setImage(UIImage(named: imageName), for: .normal) - button.addTarget(target, action: action, for: .touchUpInside) - - let menuBarItem = UIBarButtonItem(customView: button) - menuBarItem.customView?.translatesAutoresizingMaskIntoConstraints = false - menuBarItem.customView?.heightAnchor.constraint(equalToConstant: 24).isActive = true - menuBarItem.customView?.widthAnchor.constraint(equalToConstant: 24).isActive = true - - return menuBarItem - } -} diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index d41e5a408..8412fe232 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -9,19 +9,23 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import FirebaseCrashlytics import Foundation import OpenHABCore import os.log import SafariServices import SideMenu +import SwiftUI import UIKit enum TargetController { - case sitemap + case webview case settings + case sitemap(String) case notifications - case webview + case browser(String) + case tile(String) } protocol ModalHandler: AnyObject { @@ -35,6 +39,7 @@ struct CommandItem: CommItem { class OpenHABRootViewController: UIViewController { var currentView: OpenHABViewController! var isDemoMode = false + var cancellables = Set() private lazy var webViewController: OpenHABWebViewController = { let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main) @@ -94,7 +99,7 @@ class OpenHABRootViewController: UIViewController { // save this so we know if its changed later 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 @@ -114,6 +119,34 @@ class OpenHABRootViewController: UIViewController { } } + fileprivate func setupTracker() { + Publishers.CombineLatest( + Preferences.$localUrl, + Preferences.$remoteUrl + ) + .sink { (localUrl, remoteUrl) in + let connection1 = ConnectionObject( + url: localUrl, + priority: 0 + ) + let connection2 = ConnectionObject( + url: remoteUrl, + priority: 1 + ) + NetworkTracker.shared.startTracking(connectionObjects: [connection1, connection2]) + } + .store(in: &cancellables) + + NetworkTracker.shared.$activeServer + .receive(on: DispatchQueue.main) + .sink { [weak self] activeServer in + if let activeServer { + self?.appData?.openHABRootUrl = activeServer.url + } + } + .store(in: &cancellables) + } + fileprivate func setupSideMenu() { let hamburgerButtonItem: UIBarButtonItem let imageConfig = UIImage.SymbolConfiguration(textStyle: .largeTitle) @@ -127,13 +160,6 @@ class OpenHABRootViewController: UIViewController { // Define the menus - SideMenuManager.default.rightMenuNavigationController = storyboard!.instantiateViewController(withIdentifier: "RightMenuNavigationController") as? SideMenuNavigationController - - // Enable gestures. The left and/or right menus must be set up above for these to work. - // Note that these continue to work on the Navigation Controller independent of the View Controller it displays! - SideMenuManager.default.addPanGestureToPresent(toView: navigationController!.navigationBar) - SideMenuManager.default.addScreenEdgePanGesturesToPresent(toView: navigationController!.view, forMenu: .right) - let presentationStyle: SideMenuPresentationStyle = .viewSlideOutMenuIn presentationStyle.presentingEndAlpha = 1 presentationStyle.onTopShadowOpacity = 0.5 @@ -142,9 +168,74 @@ class OpenHABRootViewController: UIViewController { settings.statusBarEndAlpha = 0 SideMenuManager.default.rightMenuNavigationController?.settings = settings - if let menu = SideMenuManager.default.rightMenuNavigationController { - let drawer = menu.viewControllers.first as? OpenHABDrawerTableViewController - drawer?.delegate = self + + let drawerView = DrawerView { mode in + self.handleDismiss(mode: mode) + } + let hostingController = UIHostingController(rootView: drawerView) + let menu = SideMenuNavigationController(rootViewController: hostingController) + + SideMenuManager.default.rightMenuNavigationController = menu + + // Enable gestures. The left and/or right menus must be set up above for these to work. + // Note that these continue to work on the Navigation Controller independent of the View Controller it displays! + SideMenuManager.default.addPanGestureToPresent(toView: navigationController!.navigationBar) + SideMenuManager.default.addScreenEdgePanGesturesToPresent(toView: navigationController!.view, forMenu: .right) + } + + private func openTileURL(_ urlString: String) { + // Use SFSafariViewController in SwiftUI with UIViewControllerRepresentable + // Dependent on $OPENHAB_CONF/services/runtime.cfg + // Can either be an absolute URL, a path (sometimes malformed) + if !urlString.isEmpty { + let url: URL? = if urlString.hasPrefix("http") { + URL(string: urlString) + } else { + Endpoint.resource(openHABRootUrl: appData?.openHABRootUrl ?? "", path: urlString.prepare()).url + } + openURL(url: url) + } + } + + private func openURL(url: URL?) { + if let url { + let config = SFSafariViewController.Configuration() + config.entersReaderIfAvailable = true + let vc = SFSafariViewController(url: url, configuration: config) + present(vc, animated: true) + } + } + + private func handleDismiss(mode: TargetController) { + switch mode { + case .webview: + // Handle webview navigation or state update + print("Dismissed to WebView") + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) + switchView(target: .webview) + case .settings: + print("Dismissed to Settings") + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { + self.modalDismissed(to: .settings) + } + case let .sitemap(sitemap): + Preferences.defaultSitemap = sitemap + appData?.sitemapViewController?.pageUrl = "" + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { + self.modalDismissed(to: .sitemap(sitemap)) + } + case .notifications: + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { + self.modalDismissed(to: .notifications) + } + case let .browser(urlString): + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { + self.modalDismissed(to: .browser(urlString)) + } + case let .tile(urlString): + SideMenuManager.default.rightMenuNavigationController?.dismiss(animated: true) { + self.modalDismissed(to: .tile(urlString)) + } } } @@ -206,21 +297,30 @@ class OpenHABRootViewController: UIViewController { if let firstMatch = command.firstMatch(of: regexPattern) { let path = String(firstMatch.1) os_log("navigateCommandAction path: %{PUBLIC}@", log: .notifications, type: .info, path) - if currentView != webViewController { - switchView(target: .webview) - } if path.starts(with: "/basicui/app?") { - // TODO: this is a sitemap, we should use the native renderer - // temp hack right now to just use a webview - webViewController.loadWebView(force: true, path: path) - } else if path.starts(with: "/") { - // have the webview load this path itself - webViewController.loadWebView(force: true, path: path) + if currentView != sitemapViewController { + switchView(target: .sitemap("")) + } + if let urlComponents = URLComponents(string: path) { + let queryItems = urlComponents.queryItems + let sitemap = queryItems?.first(where: { $0.name == "sitemap" })?.value + let subview = queryItems?.first(where: { $0.name == "w" })?.value + if let sitemap { + sitemapViewController.pushSitemap(name: sitemap, path: subview) + } + } } else { - // have the mainUI handle the navigation - webViewController.navigateCommand(path) + if currentView != webViewController { + switchView(target: .webview) + } + if path.starts(with: "/") { + // have the webview load this path itself + webViewController.loadWebView(force: true, path: path) + } else { + // have the mainUI handle the navigation + webViewController.navigateCommand(path) + } } - } else { os_log("Invalid regex: %{PUBLIC}@", log: .notifications, type: .error, command) } @@ -231,17 +331,25 @@ class OpenHABRootViewController: UIViewController { if components.count == 2 { let itemName = String(components[0]) let itemCommand = String(components[1]) - let client = HTTPClient(username: Preferences.username, password: Preferences.username) - client.doPost(baseURLs: [Preferences.localUrl, Preferences.remoteUrl], 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) ?? "") + // This will only fire onece since we do not retain the return cancelable + _ = NetworkTracker.shared.$activeServer + .receive(on: DispatchQueue.main) + .sink { activeServer in + if let openHABUrl = activeServer?.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) ?? "") + } + } + } } } - } } } @@ -298,17 +406,26 @@ class OpenHABRootViewController: UIViewController { } catch { // nothing } - let client = HTTPClient(username: Preferences.username, password: Preferences.username) - client.doPost(baseURLs: [Preferences.localUrl, Preferences.remoteUrl], 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) ?? "") + + // This will only fire onece since we do not retain the return cancelable + _ = NetworkTracker.shared.$activeServer + .receive(on: DispatchQueue.main) + .sink { activeServer in + if let openHABUrl = activeServer?.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) ?? "") + } + } + } } } - } } func showSideMenu() { @@ -316,11 +433,9 @@ class OpenHABRootViewController: UIViewController { if let menu = SideMenuManager.default.rightMenuNavigationController { // don't try and push an already visible menu less you crash the app dismiss(animated: false) { - var topMostViewController: UIViewController? = if #available(iOS 13, *) { + var topMostViewController: UIViewController? = UIApplication.shared.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }.last { $0.isKeyWindow }?.rootViewController - } else { - UIApplication.shared.keyWindow?.rootViewController - } + while let presentedViewController = topMostViewController?.presentedViewController { topMostViewController = presentedViewController } @@ -344,7 +459,12 @@ class OpenHABRootViewController: UIViewController { } private func switchView(target: TargetController) { - let targetView = target == .sitemap ? sitemapViewController : webViewController + let targetView = + if case .sitemap = target { + sitemapViewController + } else { + webViewController + } if currentView != targetView { if currentView != nil { @@ -367,10 +487,10 @@ class OpenHABRootViewController: UIViewController { private func switchToSavedView() { if Preferences.demomode { - switchView(target: .sitemap) + switchView(target: .sitemap("")) } else { os_log("OpenHABRootViewController switchToSavedView %@", log: .viewCycle, type: .info, Preferences.defaultView == "sitemap" ? "sitemap" : "web") - switchView(target: Preferences.defaultView == "sitemap" ? .sitemap : .webview) + switchView(target: Preferences.defaultView == "sitemap" ? .sitemap("") : .webview) } } } @@ -391,19 +511,17 @@ extension OpenHABRootViewController: ModalHandler { case .sitemap: switchView(target: to) case .settings: - if let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABSettingsViewController") as? OpenHABSettingsViewController { - navigationController?.pushViewController(newViewController, animated: true) - } + let hostingController = UIHostingController(rootView: SettingsView()) + navigationController?.pushViewController(hostingController, animated: true) case .notifications: - if navigationController?.visibleViewController is OpenHABNotificationsViewController { - os_log("Notifications are already open", log: .notifications, type: .info) - } else { - if let newViewController = storyboard?.instantiateViewController(withIdentifier: "OpenHABNotificationsViewController") as? OpenHABNotificationsViewController { - navigationController?.pushViewController(newViewController, animated: true) - } - } + let hostingController = UIHostingController(rootView: NotificationsView()) + navigationController?.pushViewController(hostingController, animated: true) case .webview: switchView(target: to) + case .browser: + break + case let .tile(urlString): + openTileURL(urlString) } } } diff --git a/openHAB/OpenHABSelectionTableViewController.swift b/openHAB/OpenHABSelectionTableViewController.swift deleted file mode 100644 index a03f5bd95..000000000 --- a/openHAB/OpenHABSelectionTableViewController.swift +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) 2010-2024 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import OpenHABCore -import os.log -import UIKit - -// swiftlint:disable:next type_name -public protocol OpenHABSelectionTableViewControllerDelegate: NSObjectProtocol { - func didSelectWidgetMapping(_ selectedMapping: Int) -} - -class OpenHABSelectionTableViewController: UITableViewController { - static let tableViewCellIdentifier = "SelectionCell" - - var mappings: [AnyHashable] = [] - weak var delegate: OpenHABSelectionTableViewControllerDelegate? - var selectionItem: OpenHABItem? - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - override func viewDidLoad() { - super.viewDidLoad() - - os_log("I have %d mappings", log: .viewCycle, type: .info, mappings.count) - - // Uncomment the following line to preserve selection between presentations. - // self.clearsSelectionOnViewWillAppear = NO; - - // Uncomment the following line to display an Edit button in the navigation bar for this view controller. - // self.navigationItem.rightBarButtonItem = self.editButtonItem; - } - - // MARK: - Table view data source - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - mappings.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: UITableViewCell = tableView.dequeueReusableCell(withIdentifier: OpenHABSelectionTableViewController.tableViewCellIdentifier, for: indexPath) - if let mapping = mappings[indexPath.row] as? OpenHABWidgetMapping { - cell.textLabel?.text = mapping.label - if selectionItem?.state == mapping.command { - os_log("This item is selected", log: .viewCycle, type: .info) - cell.accessoryType = .checkmark - } else { - cell.accessoryType = .none - } - } - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - os_log("Selected mapping %d", log: .viewCycle, type: .info, indexPath.row) - - delegate?.didSelectWidgetMapping(indexPath.row) - navigationController?.popViewController(animated: true) - } -} diff --git a/openHAB/OpenHABSettingsViewController.swift b/openHAB/OpenHABSettingsViewController.swift deleted file mode 100644 index a75683041..000000000 --- a/openHAB/OpenHABSettingsViewController.swift +++ /dev/null @@ -1,333 +0,0 @@ -// Copyright (c) 2010-2024 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import FirebaseCrashlytics -import Kingfisher -import OpenHABCore -import os.log -import SafariServices -import UIKit -import WebKit - -class OpenHABSettingsViewController: UITableViewController, UITextFieldDelegate { - var settingsLocalUrl = "" - var settingsRemoteUrl = "" - var settingsUsername = "" - var settingsPassword = "" - var settingsAlwaysSendCreds = false - var settingsIgnoreSSL = false - var settingsDemomode = false - var settingsIdleOff = false - var settingsIconType: IconType = .png - var settingsRealTimeSliders = false - var settingsSendCrashReports = false - var settingsSortSitemapsBy: SortSitemapsOrder = .label - var settingsDefaultMainUIPath = "" - var settingsAlwaysAllowWebRTC = false - - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } - - @IBOutlet private var settingsTableView: UITableView! - @IBOutlet private var demomodeSwitch: UISwitch! - @IBOutlet private var passwordTextField: UITextField! - @IBOutlet private var usernameTextField: UITextField! - @IBOutlet private var remoteUrlTextField: UITextField! - @IBOutlet private var localUrlTextField: UITextField! - @IBOutlet private var idleOffSwitch: UISwitch! - @IBOutlet private var ignoreSSLSwitch: UISwitch! - @IBOutlet private var iconSegmentedControl: UISegmentedControl! - @IBOutlet private var alwaysSendCredsSwitch: UISwitch! - @IBOutlet private var realTimeSlidersSwitch: UISwitch! - @IBOutlet private var sendCrashReportsSwitch: UISwitch! - @IBOutlet private var sendCrashReportsDummy: UIButton! - @IBOutlet private var sortSitemapsBy: UISegmentedControl! - @IBOutlet private var useCurrentMainUIPathButton: UIButton! - @IBOutlet private var defaultMainUIPathTextField: UITextField! - @IBOutlet private var appVersionLabel: UILabel! - @IBOutlet private var alwaysAllowWebRTCSwitch: UISwitch! - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - override func viewDidLoad() { - super.viewDidLoad() - os_log("OpenHABSettingsViewController viewDidLoad", log: .viewCycle, type: .info) - navigationItem.hidesBackButton = true - let leftBarButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(OpenHABSettingsViewController.cancelButtonPressed(_:))) - let rightBarButton = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(OpenHABSettingsViewController.saveButtonPressed(_:))) - navigationItem.leftBarButtonItem = leftBarButton - navigationItem.rightBarButtonItem = rightBarButton - loadSettings() - updateSettingsUi() - localUrlTextField?.delegate = self - remoteUrlTextField?.delegate = self - usernameTextField?.delegate = self - passwordTextField?.delegate = self - defaultMainUIPathTextField.delegate = self - demomodeSwitch?.addTarget(self, action: #selector(OpenHABSettingsViewController.demomodeSwitchChange(_:)), for: .valueChanged) - sendCrashReportsDummy.addTarget(self, action: #selector(crashReportingDummyPressed(_:)), for: .touchUpInside) - useCurrentMainUIPathButton?.addTarget(self, action: #selector(currentMainUIPathButtonPressed(_:)), for: .touchUpInside) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewDidAppear(animated) - if let indexPathForSelectedRow = tableView.indexPathForSelectedRow { - settingsTableView.deselectRow(at: indexPathForSelectedRow, animated: true) - } - } - - // This is to automatically hide keyboard on done/enter pressing - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - return false - } - - @objc - private func cancelButtonPressed(_ sender: Any?) { - navigationController?.popViewController(animated: true) - os_log("Cancel button pressed", log: .viewCycle, type: .info) - } - - @objc - private func saveButtonPressed(_ sender: Any?) { - // TODO: Make a check if any of the preferences has changed - os_log("Save button pressed", log: .viewCycle, type: .info) - - updateSettings() - saveSettings() - appData?.sitemapViewController?.pageUrl = "" - NotificationCenter.default.post(name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) - navigationController?.popToRootViewController(animated: true) - } - - @objc - private func demomodeSwitchChange(_ sender: Any?) { - if demomodeSwitch!.isOn { - os_log("Demo is ON", log: .viewCycle, type: .info) - disableConnectionSettings() - } else { - os_log("Demo is OFF", log: .viewCycle, type: .info) - enableConnectionSettings() - } - } - - @objc - private func privacyButtonPressed(_ sender: Any?) { - let webViewController = SFSafariViewController(url: URL.privacyPolicy) - webViewController.configuration.barCollapsingEnabled = true - - present(webViewController, animated: true) - } - - @objc - private func crashReportingDummyPressed(_ sender: Any?) { - if sendCrashReportsSwitch.isOn { - sendCrashReportsSwitch.setOn(!sendCrashReportsSwitch.isOn, animated: true) - Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(false) - } else { - let alertController = UIAlertController(title: NSLocalizedString("crash_reporting", comment: ""), message: NSLocalizedString("crash_reporting_info", comment: ""), preferredStyle: .actionSheet) - alertController.addAction( - UIAlertAction(title: NSLocalizedString("activate", comment: ""), style: .default) { [weak self] _ in - self?.sendCrashReportsSwitch.setOn(true, animated: true) - Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) - } - ) - alertController.addAction( - UIAlertAction(title: NSLocalizedString("privacy_policy", comment: ""), style: .default) { [weak self] _ in - self?.privacyButtonPressed(nil) - } - ) - alertController.addAction( - UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .default) - ) - - if let popOver = alertController.popoverPresentationController { - popOver.sourceView = sendCrashReportsSwitch - popOver.sourceRect = sendCrashReportsSwitch.bounds - } - present(alertController, animated: true) - } - } - - @objc - private func currentMainUIPathButtonPressed(_ sender: Any?) { - promptForDefaultWebView() - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - // hide connection options when in demo mode - if section == 0, demomodeSwitch!.isOn { - return 1 - } - return super.tableView(tableView, numberOfRowsInSection: section) - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - settingsTableView.deselectRow(at: indexPath, animated: true) - os_log("Row selected %d %d", log: .notifications, type: .info, indexPath.section, indexPath.row) - switch tableView.cellForRow(at: indexPath)?.tag { - case 888: - privacyButtonPressed(nil) - case 998: - let websiteDataTypes = NSSet(array: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache]) - let date = Date(timeIntervalSince1970: 0) - WKWebsiteDataStore.default().removeData(ofTypes: websiteDataTypes as! Set, modifiedSince: date, completionHandler: {}) - alertCacheCleared() - case 999: - os_log("Clearing image cache", log: .viewCycle, type: .info) - KingfisherManager.shared.cache.clearMemoryCache() - KingfisherManager.shared.cache.clearDiskCache() - KingfisherManager.shared.cache.cleanExpiredDiskCache() - alertCacheCleared() - default: break - } - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - switch section { - case 0: - NSLocalizedString("openhab_connection", comment: "") - case 1: - NSLocalizedString("application_settings", comment: "") - case 2: - NSLocalizedString("mainui_settings", comment: "") - case 3: - NSLocalizedString("sitemap_settings", comment: "") - case 4: - NSLocalizedString("about_settings", comment: "") - default: - "" - } - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - UITableView.automaticDimension - } - - func enableConnectionSettings() { - settingsTableView.reloadData() - } - - func disableConnectionSettings() { - settingsTableView.reloadData() - } - - func updateSettingsUi() { - localUrlTextField?.text = settingsLocalUrl - remoteUrlTextField?.text = settingsRemoteUrl - usernameTextField?.text = settingsUsername - passwordTextField?.text = settingsPassword - alwaysSendCredsSwitch?.isOn = settingsAlwaysSendCreds - ignoreSSLSwitch?.isOn = settingsIgnoreSSL - demomodeSwitch?.isOn = settingsDemomode - idleOffSwitch?.isOn = settingsIdleOff - realTimeSlidersSwitch?.isOn = settingsRealTimeSliders - sendCrashReportsSwitch?.isOn = settingsSendCrashReports - iconSegmentedControl?.selectedSegmentIndex = settingsIconType.rawValue - sortSitemapsBy?.selectedSegmentIndex = settingsSortSitemapsBy.rawValue - defaultMainUIPathTextField?.text = settingsDefaultMainUIPath - alwaysAllowWebRTCSwitch?.isOn = settingsAlwaysAllowWebRTC - if settingsDemomode == true { - disableConnectionSettings() - } else { - enableConnectionSettings() - } - - let appBuildString = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String - let appVersionString = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - appVersionLabel?.text = "\(appVersionString ?? "") (\(appBuildString ?? ""))" - } - - func loadSettings() { - settingsLocalUrl = Preferences.localUrl - settingsRemoteUrl = Preferences.remoteUrl - settingsUsername = Preferences.username - settingsPassword = Preferences.password - settingsAlwaysSendCreds = Preferences.alwaysSendCreds - settingsIgnoreSSL = Preferences.ignoreSSL - settingsDemomode = Preferences.demomode - settingsIdleOff = Preferences.idleOff - settingsRealTimeSliders = Preferences.realTimeSliders - settingsSendCrashReports = Preferences.sendCrashReports - settingsIconType = IconType(rawValue: Preferences.iconType) ?? .png - settingsSortSitemapsBy = SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label - settingsDefaultMainUIPath = Preferences.defaultMainUIPath - settingsAlwaysAllowWebRTC = Preferences.alwaysAllowWebRTC - } - - func updateSettings() { - settingsLocalUrl = localUrlTextField?.text ?? "" - settingsRemoteUrl = remoteUrlTextField?.text ?? "" - settingsUsername = usernameTextField?.text ?? "" - settingsPassword = passwordTextField?.text ?? "" - settingsAlwaysSendCreds = alwaysSendCredsSwitch?.isOn ?? false - settingsIgnoreSSL = ignoreSSLSwitch?.isOn ?? false - NetworkConnection.shared.serverCertificateManager.ignoreSSL = settingsIgnoreSSL - settingsDemomode = demomodeSwitch?.isOn ?? false - settingsIdleOff = idleOffSwitch?.isOn ?? false - settingsRealTimeSliders = realTimeSlidersSwitch?.isOn ?? false - settingsSendCrashReports = sendCrashReportsSwitch?.isOn ?? false - settingsIconType = IconType(rawValue: iconSegmentedControl.selectedSegmentIndex) ?? .png - settingsSortSitemapsBy = SortSitemapsOrder(rawValue: sortSitemapsBy.selectedSegmentIndex) ?? .label - settingsDefaultMainUIPath = defaultMainUIPathTextField?.text ?? "" - settingsAlwaysAllowWebRTC = alwaysAllowWebRTCSwitch?.isOn ?? false - } - - func saveSettings() { - Preferences.localUrl = settingsLocalUrl - Preferences.remoteUrl = settingsRemoteUrl - Preferences.username = settingsUsername - Preferences.password = settingsPassword - Preferences.alwaysSendCreds = settingsAlwaysSendCreds - Preferences.ignoreSSL = settingsIgnoreSSL - Preferences.demomode = settingsDemomode - Preferences.idleOff = settingsIdleOff - Preferences.realTimeSliders = settingsRealTimeSliders - Preferences.iconType = settingsIconType.rawValue - Preferences.sendCrashReports = settingsSendCrashReports - Preferences.sortSitemapsby = settingsSortSitemapsBy.rawValue - Preferences.defaultMainUIPath = settingsDefaultMainUIPath - Preferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC - WatchMessageService.singleton.syncPreferencesToWatch() - } - - func promptForDefaultWebView() { - DispatchQueue.main.async { - let alertController = UIAlertController(title: NSLocalizedString("uselastpath_settings", comment: ""), message: self.appData?.currentWebViewPath ?? "/", preferredStyle: .actionSheet) - // popover cords needed for iPad - if let ppc = alertController.popoverPresentationController { - ppc.sourceView = self.useCurrentMainUIPathButton as UIView - ppc.sourceRect = (self.useCurrentMainUIPathButton as UIView).bounds - } - let cancel = UIAlertAction(title: NSLocalizedString("cancel", comment: ""), style: .cancel) { (_: UIAlertAction) in - } - let currentPath = UIAlertAction(title: NSLocalizedString("ok", comment: ""), style: .default) { (_: UIAlertAction) in - if let path = self.appData?.currentWebViewPath { - self.defaultMainUIPathTextField?.text = path - self.settingsDefaultMainUIPath = path - } - } - alertController.addAction(currentPath) - alertController.addAction(cancel) - self.present(alertController, animated: true, completion: nil) - } - } - - func alertCacheCleared() { - let alertController = UIAlertController(title: NSLocalizedString("cache_cleared", comment: ""), message: "", preferredStyle: .alert) - let confirmed = UIAlertAction(title: NSLocalizedString("ok", comment: ""), style: .default) - alertController.addAction(confirmed) - present(alertController, animated: true, completion: nil) - } -} diff --git a/openHAB/OpenHABSitemapViewController.swift b/openHAB/OpenHABSitemapViewController.swift index 37926003c..47d26f23a 100644 --- a/openHAB/OpenHABSitemapViewController.swift +++ b/openHAB/OpenHABSitemapViewController.swift @@ -12,6 +12,7 @@ import Alamofire import AVFoundation import AVKit +import Combine import Kingfisher import OpenHABCore import os.log @@ -46,9 +47,12 @@ struct OpenHABImageProcessor: ImageProcessor { let svgkSourceNSData = SVGKSourceNSData.source(from: data, urlForRelativeLinks: nil) let parseResults = SVGKParser.parseSource(usingDefaultSVGKParser: svgkSourceNSData) if parseResults?.parsedDocument != nil, let image = SVGKImage(parsedSVG: parseResults, from: svgkSourceNSData), image.hasSize() { + if image.size.width > 1000 || image.size.height > 1000 { + return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange) + } return image.uiImage } else { - return UIImage(named: "error.png") + return UIImage(systemSymbol: .exclamationmarkTriangle).withTintColor(.orange) } default: return Kingfisher.DefaultImageProcessor().process(item: item, options: KingfisherParsedOptionsInfo(KingfisherManager.shared.defaultOptions)) @@ -79,7 +83,6 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel private var filteredPage: OpenHABSitemapPage? private var serverProperties: OpenHABServerProperties? private let search = UISearchController(searchResultsController: nil) - private var webViewController: OpenHABWebViewController? private var isUserInteracting = false private var isWaitingToReload = false @@ -164,6 +167,24 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel if idleOff { UIApplication.shared.isIdleTimerDisabled = true } + + NetworkTracker.shared.$status + .receive(on: DispatchQueue.main) + .sink { status in + os_log("OpenHABViewController tracker status %{PUBLIC}@", log: .viewCycle, type: .info, status.rawValue) + switch status { + case .connecting: + self.showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) + case .connectionFailed: + os_log("Tracking error", log: .viewCycle, type: .info) + self.showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: NSLocalizedString("network_not_available", comment: ""), theme: .error) + case _: + break + } + } + .store(in: &trackerCancellables) + + var activeServerWatcher = NetworkTracker.shared.$activeServer.eraseToAnyPublisher() // if pageUrl == "" it means we are the first opened OpenHABSitemapViewController if pageUrl == "" { // Set self as root view controller @@ -173,9 +194,9 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel widgetTableView.reloadData() } os_log("OpenHABSitemapViewController pageUrl is empty, this is first launch", log: .viewCycle, type: .info) - OpenHABTracker.shared.multicastDelegate.add(self) - OpenHABTracker.shared.restart() } else { + // we only want to our watcher to notify us about changes, and not the inital value + activeServerWatcher = activeServerWatcher.dropFirst().eraseToAnyPublisher() if !pageNetworkStatusChanged() { os_log("OpenHABSitemapViewController pageUrl = %{PUBLIC}@", log: .notifications, type: .info, pageUrl) loadPage(false) @@ -184,6 +205,18 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel restart() } } + // listen for network changes, if stateWatcher.dropFirst() was NOT called, then this will exectue imediately with current values and then again if the network changes, otherwise it will be called on changes only. + activeServerWatcher + .receive(on: DispatchQueue.main) + .sink { activeServer in + if let activeServer { + os_log("OpenHABSitemapViewController tracker URL %{PUBLIC}@", log: .viewCycle, type: .info, activeServer.url) + self.openHABRootUrl = activeServer.url + self.selectSitemap() + } + } + .store(in: &trackerCancellables) + ImageDownloader.default.authenticationChallengeResponder = self } @@ -193,7 +226,9 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel currentPageOperation?.cancel() currentPageOperation = nil } - OpenHABTracker.shared.multicastDelegate.remove(self) + + trackerCancellables.removeAll() + super.viewWillDisappear(animated) if #unavailable(iOS 13.0) { @@ -341,7 +376,7 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel return sitemapPageCodingData.openHABSitemapPage }() } catch { - //Printing the error is the only way to actually get the real issue, localizedDescription is pretty useless here + // Printing the error is the only way to actually get the real issue, localizedDescription is pretty useless here print(error) os_log("Should not throw %{PUBLIC}@", log: OSLog.remoteAccess, type: .error, error.localizedDescription) DispatchQueue.main.async { @@ -438,6 +473,27 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } } + // This is mainly used for navigting to a specific sitemap and path from notifications + 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.$activeServer + .receive(on: DispatchQueue.main) + .sink { [weak self] activeServer in + if let openHABUrl = activeServer?.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) + } + } + } + // load app settings func loadSettings() { openHABUsername = Preferences.username @@ -521,37 +577,6 @@ class OpenHABSitemapViewController: OpenHABViewController, GenericUITableViewCel } } -// MARK: - OpenHABTrackerDelegate - -extension OpenHABSitemapViewController: OpenHABTrackerDelegate { - func openHABTracked(_ openHABUrl: URL?, version: Int) { - os_log("OpenHABSitemapViewController openHAB URL = %{PUBLIC}@", log: .remoteAccess, type: .error, "\(openHABUrl!)") - openHABRootUrl = openHABUrl?.absoluteString ?? "" - selectSitemap() - } - - func openHABTrackingProgress(_ message: String?) { - os_log("OpenHABSitemapViewController %{PUBLIC}@", log: .viewCycle, type: .info, message ?? "") - showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: message ?? "", theme: .info) - } - - func openHABTrackingError(_ error: Error) { - os_log("Tracking error: %{PUBLIC}@", log: .viewCycle, type: .info, error.localizedDescription) - showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: error.localizedDescription, theme: .error) - } -} - -// MARK: - OpenHABSelectionTableViewControllerDelegate - -extension OpenHABSitemapViewController: OpenHABSelectionTableViewControllerDelegate { - // send command on selected selection widget mapping - func didSelectWidgetMapping(_ selectedMappingIndex: Int) { - let selectedWidget: OpenHABWidget? = relevantPage?.widgets[selectedWidgetRow] - let selectedMapping: OpenHABWidgetMapping? = selectedWidget?.mappingsOrItemOptions[selectedMappingIndex] - sendCommand(selectedWidget?.item, commandToSend: selectedMapping?.command) - } -} - // MARK: - UISearchResultsUpdating extension OpenHABSitemapViewController: UISearchResultsUpdating { @@ -694,10 +719,9 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour os_log("Job failed: %{PUBLIC}@", log: .viewCycle, type: .info, error.localizedDescription) } } - cell.imageView?.kf.setImage( with: KF.ImageResource(downloadURL: urlc, cacheKey: urlc.path + (urlc.query ?? "")), - placeholder: UIImage(named: "blankicon.png"), + placeholder: nil, options: [.processor(OpenHABImageProcessor())], completionHandler: reportOnResults ) @@ -745,6 +769,7 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour if let link = widget?.linkedPage?.link { os_log("Selected %{PUBLIC}@", log: .viewCycle, type: .info, link) } + selectedWidgetRow = indexPath.row let newViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABPageViewController") as? OpenHABSitemapViewController)! newViewController.title = widget?.linkedPage?.title.components(separatedBy: "[")[0] @@ -753,15 +778,23 @@ extension OpenHABSitemapViewController: UITableViewDelegate, UITableViewDataSour navigationController?.pushViewController(newViewController, animated: true) } else if widget?.type == .selection { os_log("Selected selection widget", log: .viewCycle, type: .info) - selectedWidgetRow = indexPath.row - let selectionViewController = (storyboard?.instantiateViewController(withIdentifier: "OpenHABSelectionTableViewController") as? OpenHABSelectionTableViewController)! let selectedWidget: OpenHABWidget? = relevantWidget(indexPath: indexPath) - selectionViewController.title = selectedWidget?.labelText - selectionViewController.mappings = selectedWidget?.mappingsOrItemOptions ?? [] - selectionViewController.delegate = self - selectionViewController.selectionItem = selectedWidget?.item - navigationController?.pushViewController(selectionViewController, animated: true) + let hostingController = UIHostingController(rootView: SelectionView( + mappings: selectedWidget?.mappingsOrItemOptions ?? [], + selectionItem: + Binding( + get: { selectedWidget?.item }, + set: { selectedWidget?.item = $0 } + ), + onSelection: { selectedMappingIndex in + let selectedWidget: OpenHABWidget? = self.relevantPage?.widgets[self.selectedWidgetRow] + let selectedMapping: OpenHABWidgetMapping? = selectedWidget?.mappingsOrItemOptions[selectedMappingIndex] + self.sendCommand(selectedWidget?.item, commandToSend: selectedMapping?.command) + } + )) + hostingController.title = widget?.labelText + navigationController?.pushViewController(hostingController, animated: true) } if let index = widgetTableView.indexPathForSelectedRow { widgetTableView.deselectRow(at: index, animated: false) diff --git a/openHAB/OpenHABTracker.swift b/openHAB/OpenHABTracker.swift deleted file mode 100644 index 0e7b0f396..000000000 --- a/openHAB/OpenHABTracker.swift +++ /dev/null @@ -1,323 +0,0 @@ -// Copyright (c) 2010-2024 Contributors to the openHAB project -// -// See the NOTICE file(s) distributed with this work for additional -// information. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0 -// -// SPDX-License-Identifier: EPL-2.0 - -import Alamofire -import Foundation -import OpenHABCore -import os.log -import SystemConfiguration - -protocol OpenHABTrackerDelegate: AnyObject { - func openHABTracked(_ openHABUrl: URL?, version: Int) - func openHABTrackingProgress(_ message: String?) - func openHABTrackingError(_ error: Error) -} - -protocol OpenHABTrackerExtendedDelegate: OpenHABTrackerDelegate { - func openHABTrackingNetworkChange(_ networkStatus: NetworkReachabilityManager.NetworkReachabilityStatus) -} - -class OpenHABTracker: NSObject { - static var shared = OpenHABTracker() - - public var multicastDelegate = MulticastDelegate() - private var oldReachabilityStatus: NetworkReachabilityManager.NetworkReachabilityStatus? - private let reach = NetworkReachabilityManager() - private var openHABDemoMode = false - private var openHABLocalUrl = "" - private var openHABRemoteUrl = "" - private var netService: NetService? - private var restartTimer: Timer? - - var appData: OpenHABDataObject? { - AppDelegate.appDelegate.appData - } - - override private init() { - super.init() - NotificationCenter.default.addObserver(self, selector: #selector(restart), name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) - start() - } - - @objc - func restart() { - reach?.stopListening() - start() - } - - private func start() { - openHABDemoMode = Preferences.demomode - openHABLocalUrl = Preferences.localUrl - openHABRemoteUrl = Preferences.remoteUrl - - #if DEBUG - // always activate demo mode for UITest - if ProcessInfo.processInfo.environment["UITest"] != nil { - openHABDemoMode = true - } - #endif - - // Start NetworkReachabilityManager.Listener - oldReachabilityStatus = reach?.status - reach?.startListening { [weak self] status in - guard let self else { return } - let nStatus = status - // use a timer to prevent bouncing/flapping around when switching between wifi, vpn, and wwan - restartTimer?.invalidate() - restartTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in - if nStatus != self.oldReachabilityStatus { - if let oldReachabilityStatus = self.oldReachabilityStatus { - os_log("OpenHABTracker Network status changed from %{PUBLIC}@ to %{PUBLIC}@", log: OSLog.remoteAccess, type: .info, self.string(from: oldReachabilityStatus) ?? "", self.string(from: nStatus) ?? "") - } - self.oldReachabilityStatus = nStatus - if self.isNetworkConnected() { - self.restart() - } - } - } - } - - // Check if any network is available - if isNetworkConnected() { - // Check if demo mode is switched on in preferences - if openHABDemoMode { - os_log("OpenHABTracker demo mode preference is on", log: .default, type: .info) - tryDemoMode() - } else { - if isNetworkWiFi(), openHABLocalUrl.isEmpty { - startDiscovery() - } else { - os_log("OpenHABTracker network trying all", log: .default, type: .info) - tryAll() - } - } - } else { - os_log("OpenHABTracker network not available", log: .default, type: .info) - multicastDelegate.invoke { $0.openHABTrackingError(errorMessage("network_not_available")) } - } - } - - private func tryDiscoveryUrl(_ discoveryUrl: URL?) { - multicastDelegate.invoke { $0.openHABTrackingProgress(NSLocalizedString("connecting_discovered", comment: "")) } - tryUrl(discoveryUrl) - } - - private func tryDemoMode() { - multicastDelegate.invoke { $0.openHABTrackingProgress(NSLocalizedString("running_demo_mode", comment: "")) } - tryUrl(URL(staticString: "https://demo.openhab.org")) - } - - /// Attemps to connect to the URL and get the openHAB version - /// - Parameter tryUrl: Completes with the url and version of openHAB that succeeded, or an Error object if failed - private func tryUrl(_ tryUrl: URL?) { - getServerInfoForUrl(tryUrl) { url, version, error in - if let error { - self.multicastDelegate.invoke { $0.openHABTrackingError(error) } - } else { - self.appData?.openHABVersion = version - self.appData?.openHABRootUrl = url?.absoluteString ?? "" - self.multicastDelegate.invoke { $0.openHABTracked(url, version: version) } - } - } - } - - /// Attemps to connect in parallel to the remote and local URLs if configured, the first URL to succeed wins - private func tryAll() { - var urls = [String: Double]() - if !openHABLocalUrl.isEmpty { - urls[openHABLocalUrl] = 0.0 - } - if !openHABRemoteUrl.isEmpty { - urls[openHABRemoteUrl] = openHABLocalUrl.isEmpty ? 0 : 1.5 - } - if urls.isEmpty { - multicastDelegate.invoke { $0.openHABTrackingError(errorMessage("error")) } - return - } - multicastDelegate.invoke { $0.openHABTrackingProgress(NSLocalizedString("connecting", comment: "")) } - tryUrls(urls) { url, version, error in - if let error { - os_log("OpenHABTracker failed %{PUBLIC}@", log: .default, type: .info, error.localizedDescription) - self.multicastDelegate.invoke { $0.openHABTrackingError(error) } - } else { - self.appData?.openHABVersion = version - self.appData?.openHABRootUrl = url?.absoluteString ?? "" - self.multicastDelegate.invoke { $0.openHABTracked(url, version: version) } - } - } - } - - /// Tries to connect in parallel to all URL's passed in and completes when either the first requests succeedes, or all fail. - /// - Parameters: - /// - urls: Tuple of String URLS and a request Delay value - /// - completion: Completes with the url and version of openHAB that succeeded, or an Error object if all failed - private func tryUrls(_ urls: [String: Double], completion: @escaping (URL?, Int, Error?) -> Void) { - var isRequestCompletedSuccessfully = false - // request in flight - var requests = [URL: DataRequest]() - // timers that have yet to be executed - var timers = [URL: Timer]() - for (urlString, delay) in urls { - let url = URL(string: urlString)! - let restUrl = URL(string: "rest/", relativeTo: url)! - let timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in - let request = NetworkConnection.shared.manager.request(restUrl, method: .get) - .validate() - requests[url] = request - // remove us from the outstanding timer list - timers.removeValue(forKey: url) - request.responseData { response in - // remove us from the outstanding request list - requests.removeValue(forKey: url) - os_log("OpenHABTracker response for URL %{PUBLIC}@", log: .notifications, type: .error, url.absoluteString) - switch response.result { - case let .success(data): - let version = self.getServerInfoFromData(data: data) - if version > 0, !isRequestCompletedSuccessfully { - isRequestCompletedSuccessfully = true - // cancel any timers not yet fired - timers.values.forEach { $0.invalidate() } - // cancel any requests still in flight - requests.values.forEach { $0.cancel() } - completion(url, version, nil) - } - case let .failure(error): - os_log("OpenHABTracker request failure %{PUBLIC}@", log: .notifications, type: .error, error.localizedDescription) - } - // check if we are the last attempt - if !isRequestCompletedSuccessfully, requests.isEmpty, timers.isEmpty { - os_log("OpenHABTracker last response", log: .notifications, type: .error) - completion(nil, 0, self.errorMessage("network_not_available")) - } - } - request.resume() - } - timers[url] = timer - RunLoop.main.add(timer, forMode: .common) - } - } - - /// Attempts to parse the data response from a request and determine if its an openHAB server and its server version - /// - Parameter data: request data - /// - Returns: Version of openHAB or -1 if not an openHAB server - private func getServerInfoFromData(data: Data) -> Int { - do { - let serverProperties = try data.decoded(as: OpenHABServerProperties.self) - os_log("OpenHABTracker openHAB version %@", log: .remoteAccess, type: .info, serverProperties.version) - // OH versions 2.0 through 2.4 return "1" as thier version, so set the floor to 2 so we do not think this is a OH 1.x serevr - return max(2, Int(serverProperties.version) ?? 2) - } catch { - os_log("OpenHABTracker Could not decode response", log: .notifications, type: .error) - return -1 - } - } - - /// Attempts to connect to a URL and determine its server version - /// - Parameters: - /// - url: URL of the openHAB server - /// - completion: Completes with the url and version of openHAB that succeeded, or an Error object if failed - private func getServerInfoForUrl(_ url: URL?, completion: @escaping (URL?, Int, Error?) -> Void) { - let strUrl = url?.absoluteString ?? "" - os_log("OpenHABTracker getServerInfo, trying: %{PUBLIC}@", log: .default, type: .info, strUrl) - NetworkConnection.tracker(openHABRootUrl: strUrl) { response in - os_log("OpenHABTracker getServerInfo, recieved data for URL: %{PUBLIC}@", log: .default, type: .info, strUrl) - switch response.result { - case let .success(data): - let version = self.getServerInfoFromData(data: data) - if version > 0 { - completion(url, version, nil) - } else { - completion(url, 0, self.errorMessage("error")) - } - case let .failure(error): - os_log("OpenHABTracker getServerInfo ERROR for %{PUBLIC}@ : %{PUBLIC}@ %d", log: .remoteAccess, type: .error, strUrl, error.localizedDescription, response.response?.statusCode ?? 0) - completion(url, 0, error) - } - } - } - - private func startDiscovery() { - os_log("OpenHABTracking starting Bonjour discovery", log: .default, type: .info) - multicastDelegate.invoke { $0.openHABTrackingProgress(NSLocalizedString("discovering_oh", comment: "")) } - netService = NetService(domain: "local.", type: "_openhab-server-ssl._tcp.", name: "openHAB-ssl") - netService!.delegate = self - netService!.resolve(withTimeout: 5.0) - } - - func normalizeUrl(_ url: String?) -> String? { - if let url, url.hasSuffix("/") { - return String(url.dropLast()) - } - return url - } - - func isNetworkConnected() -> Bool { - reach?.isReachable ?? false - } - - func isNetworkWiFi() -> Bool { - reach?.isReachableOnEthernetOrWiFi ?? false - } - - func string(from status: NetworkReachabilityManager.NetworkReachabilityStatus) -> String? { - switch status { - case .unknown, .notReachable: - "unreachable" - case let .reachable(connectionType): - connectionType == .ethernetOrWiFi ? "WiFi" : "WWAN" - } - } - - func errorMessage(_ message: String) -> NSError { - var errorDetail: [AnyHashable: Any] = [:] - errorDetail[NSLocalizedDescriptionKey] = NSLocalizedString(message, comment: "") - return NSError(domain: "openHAB", code: 101, userInfo: errorDetail as? [String: Any]) - } -} - -extension OpenHABTracker: NetServiceDelegate, NetServiceBrowserDelegate { - // NSNetService delegate methods for publication - func netServiceDidResolveAddress(_ resolvedNetService: NetService) { - func getStringIp(fromAddressData dataIn: Data?) -> String? { - var ipString: String? - let data = dataIn! as NSData - let socketAddress: sockaddr_in = data.castToCPointer() - ipString = String(cString: inet_ntoa(socketAddress.sin_addr), encoding: .ascii) - return ipString - } - - guard let data = resolvedNetService.addresses?.first else { return } - let resolvedComponents: URLComponents = { - var components = URLComponents() - components.host = getStringIp(fromAddressData: data) - components.scheme = "https" - components.port = resolvedNetService.port - return components - }() - - let openhabUrl = "\(resolvedComponents.url!)" - os_log("OpenHABTracker discovered:%{PUBLIC}@ ", log: OSLog.remoteAccess, type: .info, openhabUrl) - tryDiscoveryUrl(resolvedComponents.url) - } - - func netService(_ netService: NetService, didNotResolve errorDict: [String: NSNumber]) { - os_log("OpenHABTracker discovery didn't resolve openHAB", log: .default, type: .info) - tryAll() - } -} - -extension NSData { - func castToCPointer() -> T { - let mem = UnsafeMutablePointer.allocate(capacity: MemoryLayout.size) - getBytes(mem, length: MemoryLayout.size) - return mem.move() - } -} diff --git a/openHAB/OpenHABViewController.swift b/openHAB/OpenHABViewController.swift index d4c1a38da..776cc4cdd 100644 --- a/openHAB/OpenHABViewController.swift +++ b/openHAB/OpenHABViewController.swift @@ -9,12 +9,15 @@ // // SPDX-License-Identifier: EPL-2.0 +import Combine import OpenHABCore import SideMenu import SwiftMessages import UIKit class OpenHABViewController: UIViewController { + var trackerCancellables = Set() + override func viewDidLoad() { super.viewDidLoad() NotificationCenter.default.addObserver(self, selector: #selector(OpenHABViewController.didEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil) diff --git a/openHAB/OpenHABWebViewController.swift b/openHAB/OpenHABWebViewController.swift index 6ac6533ad..39406d062 100644 --- a/openHAB/OpenHABWebViewController.swift +++ b/openHAB/OpenHABWebViewController.swift @@ -21,7 +21,6 @@ class OpenHABWebViewController: OpenHABViewController { private var currentTarget = "" private var openHABTrackedRootUrl = "" private var hideNavBar = false - private var tracker: OpenHABTracker? private var activityIndicator: UIActivityIndicatorView! private var observation: NSKeyValueObservation? private var sseTimer: Timer? @@ -73,7 +72,31 @@ class OpenHABWebViewController: OpenHABViewController { navigationController?.setNavigationBarHidden(hideNavBar, animated: animated) navigationController?.navigationBar.prefersLargeTitles = false parent?.navigationItem.title = "Main View" - OpenHABTracker.shared.multicastDelegate.add(self) + NetworkTracker.shared.$activeServer + .receive(on: DispatchQueue.main) + .sink { activeServer in + if let activeServer { + os_log("OpenHABWebViewController openHAB URL = %{PUBLIC}@", log: .remoteAccess, type: .info, "\(activeServer.url)") + self.openHABTrackedRootUrl = activeServer.url + self.loadWebView(force: false) + } + } + .store(in: &trackerCancellables) + + NetworkTracker.shared.$status + .receive(on: DispatchQueue.main) + .sink { status in + os_log("OpenHABWebViewController tracker status %{PUBLIC}@", log: .viewCycle, type: .info, status.rawValue) + switch status { + case .connecting: + self.showPopupMessage(seconds: 60, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .info) + case .connectionFailed: + self.pageLoadError(message: NSLocalizedString("network_not_available", comment: "")) + case _: + break + } + } + .store(in: &trackerCancellables) startTracker() } @@ -82,15 +105,13 @@ class OpenHABWebViewController: OpenHABViewController { // Show the navigation bar on other view controllers navigationController?.setNavigationBarHidden(false, animated: animated) navigationController?.navigationBar.prefersLargeTitles = true - OpenHABTracker.shared.multicastDelegate.remove(self) + trackerCancellables.removeAll() } func startTracker() { if currentTarget == "" { showActivityIndicator(show: true) } - // TODO: we should remove the need for this. - OpenHABTracker.shared.restart() } func loadWebView(force: Bool = false, path: String? = nil) { @@ -175,7 +196,6 @@ class OpenHABWebViewController: OpenHABViewController { func pageLoadError(message: String) { os_log("pageLoadError - webView.url %{PUBLIC}@ %{PUBLIC}@", log: .wkwebview, type: .info, String(describing: webView.url?.description), message) showActivityIndicator(show: false) - // webView.loadHTMLString("Page Not Found", baseURL: URL(string: "https://openHAB.org/")) showPopupMessage(seconds: 60, title: NSLocalizedString("error", comment: ""), message: message, theme: .error) currentTarget = "" } @@ -184,6 +204,7 @@ class OpenHABWebViewController: OpenHABViewController { currentTarget = "" clearExistingPage() startTracker() + loadWebView(force: true) } override func viewName() -> String { @@ -283,9 +304,6 @@ extension OpenHABWebViewController: WKScriptMessageHandler { os_log("WKScriptMessage sseConnected is false", log: OSLog.remoteAccess, type: .info) sseTimer?.invalidate() sseTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { _ in - self.sseTimer = Timer.scheduledTimer(withTimeInterval: 20.0, repeats: false) { _ in - self.reloadView() - } self.showPopupMessage(seconds: 20, title: NSLocalizedString("connecting", comment: ""), message: "", theme: .error) self.acceptsCommands = false } @@ -350,7 +368,6 @@ extension OpenHABWebViewController: WKNavigationDelegate { os_log("Challenge.protectionSpace.authtenticationMethod: %{PUBLIC}@", log: .wkwebview, type: .info, String(describing: challenge.protectionSpace.authenticationMethod)) if let url = modifyUrl(orig: URL(string: openHABTrackedRootUrl)), challenge.protectionSpace.host == url.host { - // openHABTracker takes care of triggering server trust prompts if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { guard let serverTrust = challenge.protectionSpace.serverTrust else { completionHandler(.performDefaultHandling, nil) @@ -403,23 +420,3 @@ extension OpenHABWebViewController: WKUIDelegate { return nil } } - -// MARK: - OpenHABTrackerDelegate - -extension OpenHABWebViewController: OpenHABTrackerDelegate { - func openHABTracked(_ openHABUrl: URL?, version: Int) { - os_log("OpenHABWebViewController openHAB URL = %{PUBLIC}@", log: .remoteAccess, type: .error, "\(openHABUrl!)") - openHABTrackedRootUrl = openHABUrl?.absoluteString ?? "" - loadWebView(force: false) - } - - func openHABTrackingProgress(_ message: String?) { - os_log("OpenHABViewController %{PUBLIC}@", log: .viewCycle, type: .info, message ?? "") - showPopupMessage(seconds: 1.5, title: NSLocalizedString("connecting", comment: ""), message: message ?? "", theme: .info) - } - - func openHABTrackingError(_ error: Error) { - os_log("Tracking error: %{PUBLIC}@", log: .viewCycle, type: .info, error.localizedDescription) - pageLoadError(message: error.localizedDescription) - } -} diff --git a/openHAB/RTFTextView.swift b/openHAB/RTFTextView.swift new file mode 100644 index 000000000..4dec2867d --- /dev/null +++ b/openHAB/RTFTextView.swift @@ -0,0 +1,49 @@ +// Copyright (c) 2010-2024 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import SwiftUI +import UIKit + +struct RTFTextView: UIViewRepresentable { + let rtfFileName: String + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = true + textView.backgroundColor = UIColor.clear + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + if let url = Bundle.main.url(forResource: rtfFileName, withExtension: "rtf") { + do { + let attributedString = try NSAttributedString( + url: url, + + options: [.characterEncoding: String.Encoding.utf8.rawValue], + documentAttributes: nil + ) + uiView.attributedText = attributedString + uiView.backgroundColor = .ohSystemBackground + uiView.textColor = .ohLabel + } catch { + print("Failed to load RTF file: \(error.localizedDescription)") + } + } else { + print("RTF file not found") + } + } +} + +#Preview { + RTFTextView(rtfFileName: "") +} diff --git a/openHAB/SelectionView.swift b/openHAB/SelectionView.swift new file mode 100644 index 000000000..cd8c2d388 --- /dev/null +++ b/openHAB/SelectionView.swift @@ -0,0 +1,54 @@ +// Copyright (c) 2010-2024 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import OpenHABCore +import os.log +import SwiftUI + +struct SelectionView: View { + var mappings: [OpenHABWidgetMapping] // List of mappings (instead of AnyHashable, we use a concrete type) + @Binding var selectionItem: OpenHABItem? // Binding to track the selected item state + var onSelection: (Int) -> Void // Closure to handle selection + + var body: some View { + List(0 ..< mappings.count, id: \.self) { index in + let mapping = mappings[index] + HStack { + Text(mapping.label) + Spacer() + if selectionItem?.state == mapping.command { + Image(systemSymbol: .checkmark) + .foregroundColor(.blue) + } + } + .contentShape(Rectangle()) // Ensures entire row is tappable + .onTapGesture { + os_log("Selected mapping %d", log: .viewCycle, type: .info, index) + onSelection(index) + } + } + .navigationTitle("Select Mapping") // Navigation title + } +} + +#Preview { + let selectedItem: OpenHABItem? = OpenHABItem(name: "", type: "", state: "command2", link: "", label: nil, groupType: nil, stateDescription: nil, commandDescription: nil, members: [], category: nil, options: nil) + + return SelectionView( + mappings: [ + OpenHABWidgetMapping(command: "command1", label: "Option 1"), + OpenHABWidgetMapping(command: "command2", label: "Option 2") + ], + selectionItem: .constant(selectedItem) + ) { selectedMappingIndex in + print("Selected mapping at index \(selectedMappingIndex)") + } +} diff --git a/openHAB/SettingsView.swift b/openHAB/SettingsView.swift new file mode 100644 index 000000000..8322022b6 --- /dev/null +++ b/openHAB/SettingsView.swift @@ -0,0 +1,381 @@ +// Copyright (c) 2010-2024 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import FirebaseCrashlytics +import Kingfisher +import OpenHABCore +import os +import SafariServices +import SFSafeSymbols +import SwiftUI +import WebKit + +struct SettingsView: View { + @State var settingsDemomode = false + @State var settingsLocalUrl = "" + @State var settingsRemoteUrl = "" + @State var settingsUsername = "" + @State var settingsPassword = "" + @State var settingsAlwaysSendCreds = true + @State var settingsIdleOff = true + @State var settingsIgnoreSSL = true + @State var settingsRealTimeSliders = true + @State var settingsSendCrashReports = false + @State var settingsIconType: IconType = .png + @State var settingsSortSitemapsBy: SortSitemapsOrder = .label + @State var settingsDefaultMainUIPath = "" + @State var settingsAlwaysAllowWebRTC = true + + @State private var showingCacheAlert = false + @State private var showCrashReportingAlert = false + @State private var showUselastPathAlert = false + + @State private var hasBeenLoaded = false + + @Environment(\.dismiss) private var dismiss + + var appData: OpenHABDataObject? { + AppDelegate.appDelegate.appData + } + + var appVersion: String { + let appBuildString = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + let appVersionString = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + return "\(appVersionString ?? "") (\(appBuildString ?? ""))" + } + + private let logger = Logger(subsystem: "org.openhab.app", category: "SettingsView") + + var body: some View { + Form { + Section(header: Text(LocalizedStringKey("openhab_connection"))) { + Toggle(isOn: $settingsDemomode) { + Text("Demo Mode") + } + + if !settingsDemomode { + LabeledContent { + Spacer() + TextField( + "Local URL", + text: $settingsLocalUrl + ) + .fixedSize() + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + } label: { + Text("Local URL") + if settingsLocalUrl.isEmpty { + Text("Enter URL of local server") + } + } + + LabeledContent { + Spacer() + TextField( + "Remote URL", + text: $settingsRemoteUrl + ) + .fixedSize() + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + } label: { + Text("Remote URL") + if settingsRemoteUrl.isEmpty { + Text("Enter URL of remote server") + } + } + + LabeledContent { + TextField( + "Foo", + text: $settingsUsername + ) + .fixedSize() + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + } label: { + Text("Username") + if settingsUsername.isEmpty { + Text("Enter username on server, if required") + } + } + + LabeledContent { + SecureField( + "1234", + text: $settingsPassword + ) + .fixedSize() + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.system(.caption)) + + } label: { + Text("Password") + if settingsPassword.isEmpty { + Text("Enter password on server") + } + } + + Toggle(isOn: $settingsAlwaysSendCreds) { + Text("Always send credentials") + } + } + } + + Section(header: Text(LocalizedStringKey("application_settings"))) { + Toggle(isOn: $settingsIgnoreSSL) { + Text("Ignore SSL certificates") + } + + Toggle(isOn: $settingsIdleOff) { + Text("Disable Idle Timeout") + } + + Toggle(isOn: $settingsSendCrashReports) { + Text("Crash Reporting") + } + + .onAppear { + // Setting .onAppear of view required here because onAppear of entire view is run after onChange is active + // when migrating to iOS17 this + settingsSendCrashReports = Preferences.sendCrashReports + hasBeenLoaded = true + } + .onChange(of: settingsSendCrashReports) { newValue in + logger.debug("Detected change on settingsSendCrashReports") + if newValue, hasBeenLoaded { + showCrashReportingAlert = true + } + } + .confirmationDialog( + "crash_reporting", + isPresented: $showCrashReportingAlert + ) { + Button(role: .destructive) { + settingsSendCrashReports = true + } label: { + Text(LocalizedStringKey("activate")) + } + Button(LocalizedStringKey("privacy_policy")) { + presentPrivacyPolicy() + settingsSendCrashReports = false + } + Button(role: .cancel) { + settingsSendCrashReports = false + } label: { + Text(LocalizedStringKey("cancel")) + } + } message: { + Text(LocalizedStringKey("crash_reporting_info")) + } + + NavigationLink { + ClientCertificatesView() + } label: { + Text("Client Certificates") + } + } + + Section(header: Text(LocalizedStringKey("mainui_settings"))) { + Toggle(isOn: $settingsAlwaysAllowWebRTC) { + Text("Always allow WebRTC") + } + + LabeledContent { + TextField( + "/overview/", + text: $settingsDefaultMainUIPath + ) + .fixedSize() + Button { + showUselastPathAlert = true + } label: { + Image(systemSymbol: .plusCircle) + } + .confirmationDialog( + "uselastpath_settings", + isPresented: $showUselastPathAlert + ) { + Button("Ok") { + if let path = appData?.currentWebViewPath { + settingsDefaultMainUIPath = path + } + } + Button(role: .cancel) {} label: { + Text(LocalizedStringKey("cancel")) + } + Button("cancel", role: .cancel) {} + } message: { + Text(LocalizedStringKey("uselastpath_settings")) + } + + } label: { + Text("Default Path") + } + + Button { + let websiteDataTypes = NSSet(array: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache]) + let date = Date(timeIntervalSince1970: 0) + WKWebsiteDataStore.default().removeData(ofTypes: websiteDataTypes as! Set, modifiedSince: date) {} + showingCacheAlert = true + } label: { + NavigationLink("Clear Web Cache", destination: EmptyView()) + } + .foregroundColor(Color(uiColor: .label)) + .alert("cache_cleared", isPresented: $showingCacheAlert) { + Button("OK", role: .cancel) {} + } + } + + Section(header: Text(LocalizedStringKey("sitemap_settings"))) { + Toggle(isOn: $settingsRealTimeSliders) { + Text("Real-time Sliders") + } + + Button { + clearWebsiteCache() + showingCacheAlert = true + } label: { + NavigationLink("Clear Image Cache", destination: EmptyView()) + } + .foregroundColor(Color(uiColor: .label)) + .alert("cache_cleared", isPresented: $showingCacheAlert) { + Button("OK", role: .cancel) {} + } + + Picker(selection: $settingsIconType) { + ForEach(IconType.allCases, id: \.self) { icontype in + Text("\(icontype)").tag(icontype) + } + } label: { + Text("Icon Type") + } + + Picker(selection: $settingsSortSitemapsBy) { + ForEach(SortSitemapsOrder.allCases, id: \.self) { sortsitemaporder in + Text("\(sortsitemaporder)").tag(sortsitemaporder) + } + } label: { + Text("Sort sitemaps by") + } + } + + Section(header: Text(LocalizedStringKey("about_settings"))) { + LabeledContent("App Version", value: appVersion) + + NavigationLink { + RTFTextView(rtfFileName: "legal") + .navigationTitle("Legal") + .navigationBarTitleDisplayMode(.inline) + } label: { + Text("Legal") + } + + Button { + presentPrivacyPolicy() + } label: { + Text("privacy_policy") + } + } + } + .formStyle(.grouped) + .navigationBarBackButtonHidden(true) + .navigationBarTitle("Settings") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button("Save") { + saveSettings() + appData?.sitemapViewController?.pageUrl = "" + NotificationCenter.default.post(name: NSNotification.Name("org.openhab.preferences.saved"), object: nil) + dismiss() + } + } + ToolbarItemGroup(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + .onAppear { + loadSettings() + logger.debug("Loading Settings") + } + } + + func clearWebsiteCache() { + logger.debug("Clearing image cache") + KingfisherManager.shared.cache.clearMemoryCache() + KingfisherManager.shared.cache.clearDiskCache() + KingfisherManager.shared.cache.cleanExpiredDiskCache() + } + + func presentPrivacyPolicy() { + let vc = SFSafariViewController(url: .privacyPolicy) + UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) + } + + func loadSettings() { + settingsLocalUrl = Preferences.localUrl + settingsRemoteUrl = Preferences.remoteUrl + settingsUsername = Preferences.username + settingsPassword = Preferences.password + settingsAlwaysSendCreds = Preferences.alwaysSendCreds + settingsIgnoreSSL = Preferences.ignoreSSL + settingsDemomode = Preferences.demomode + settingsIdleOff = Preferences.idleOff + settingsRealTimeSliders = Preferences.realTimeSliders + settingsSendCrashReports = Preferences.sendCrashReports + settingsIconType = IconType(rawValue: Preferences.iconType) ?? .png + settingsSortSitemapsBy = SortSitemapsOrder(rawValue: Preferences.sortSitemapsby) ?? .label + settingsDefaultMainUIPath = Preferences.defaultMainUIPath + settingsAlwaysAllowWebRTC = Preferences.alwaysAllowWebRTC + } + + func saveSettings() { + Preferences.localUrl = settingsLocalUrl + Preferences.remoteUrl = settingsRemoteUrl + Preferences.username = settingsUsername + Preferences.password = settingsPassword + Preferences.alwaysSendCreds = settingsAlwaysSendCreds + Preferences.ignoreSSL = settingsIgnoreSSL + Preferences.demomode = settingsDemomode + Preferences.idleOff = settingsIdleOff + Preferences.realTimeSliders = settingsRealTimeSliders + Preferences.iconType = settingsIconType.rawValue + Preferences.sendCrashReports = settingsSendCrashReports + Preferences.sortSitemapsby = settingsSortSitemapsBy.rawValue + Preferences.defaultMainUIPath = settingsDefaultMainUIPath + Preferences.alwaysAllowWebRTC = settingsAlwaysAllowWebRTC + WatchMessageService.singleton.syncPreferencesToWatch() + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(settingsSendCrashReports) + logger.debug("setCrashlyticsCollectionEnabled to \(settingsSendCrashReports)") + } +} + +extension UIApplication { + var firstKeyWindow: UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .first?.keyWindow + } +} + +#Preview { + SettingsView() +} diff --git a/openHAB/Throttler.swift b/openHAB/Throttler.swift index 243cb890e..f6134454f 100644 --- a/openHAB/Throttler.swift +++ b/openHAB/Throttler.swift @@ -12,7 +12,7 @@ import Foundation // Inspired by http://danielemargutti.com/2017/10/19/throttle-in-swift/ -public class Throttler { +public class Throttler: ObservableObject { private let queue: DispatchQueue = .global(qos: .background) private var job = DispatchWorkItem {} diff --git a/openHAB/blankicon.png b/openHAB/blankicon.png deleted file mode 100644 index 421c16fba..000000000 Binary files a/openHAB/blankicon.png and /dev/null differ diff --git a/openHAB/error.png b/openHAB/error.png deleted file mode 100644 index 68bbff225..000000000 Binary files a/openHAB/error.png and /dev/null differ diff --git a/openHABTestsSwift/LocalizationTests.swift b/openHABTestsSwift/LocalizationTests.swift index 16612b054..e6b46f844 100644 --- a/openHABTestsSwift/LocalizationTests.swift +++ b/openHABTestsSwift/LocalizationTests.swift @@ -42,6 +42,7 @@ class LocalizationTests: XCTestCase { continue } XCTAssertNotEqual(translation, "__MISSING__", "Missing translation for key '\(tuple.key)' in language '\(language)'.") + // swiftlint:disable:next opening_brace let regex = /%(?:\d+\$)?[+-]?(?:[lh]{0,2})(?:[qLztj])?(?:[ 0]|'.{1})?\d*(?:\\.\d?)?[@dDiuUxXoOfeEgGcCsSpaAFn]/ let numberOfMatches = translation.matches(of: regex).count XCTAssertEqual(numberOfMatches, tuple.arguments.count, "Invalid number of format specifiers for key '\(tuple.key)' in language '\(language)'.") diff --git a/openHABWatch Extension/Views/Rows/ImageRow.swift b/openHABWatch Extension/Views/Rows/ImageRow.swift index feb886a3d..c80362f46 100644 --- a/openHABWatch Extension/Views/Rows/ImageRow.swift +++ b/openHABWatch Extension/Views/Rows/ImageRow.swift @@ -26,7 +26,7 @@ struct ImageRow: View { os_log("Failure loading icon: %{PUBLIC}s", log: .notifications, type: .debug, kingfisherError.localizedDescription) } .placeholder { - Image(systemSymbol: .arrow2CirclepathCircle) + Image(systemSymbol: .arrowTriangle2Circlepath) .font(.callout) .opacity(0.3) }