From 4580e4ea66872b026050bfe218ec03050f604fcd Mon Sep 17 00:00:00 2001 From: isidoro98 Date: Wed, 7 May 2025 23:52:08 +0200 Subject: [PATCH 1/4] Create network status widget --- OsmAnd.xcodeproj/project.pbxproj | 4 + .../Contents.json | 26 +++ .../widget_network_status_day.svg | 11 ++ .../widget_network_status_night.svg | 11 ++ .../Contents.json | 26 +++ .../widget_network_status_offline_day.svg | 13 ++ .../widget_network_status_offline_night.svg | 13 ++ .../Contents.json | 26 +++ .../widget_network_status_online_day.svg | 13 ++ .../widget_network_status_online_night.svg | 13 ++ .../en.lproj/Localizable.strings | 8 + .../Widgets/Factory/MapWidgetsFactory.swift | 2 + .../Controllers/Map/Widgets/WidgetType.swift | 11 +- .../Widgets/Widgets/NetworkStatusWidget.swift | 171 ++++++++++++++++++ .../Map/Widgets/WidgetsInitializer.swift | 1 + 15 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 Resources/Images.xcassets/Icons/widgets/widget_network_status.imageset/Contents.json create mode 100644 Resources/Images.xcassets/Icons/widgets/widget_network_status.imageset/widget_network_status_day.svg create mode 100644 Resources/Images.xcassets/Icons/widgets/widget_network_status.imageset/widget_network_status_night.svg create mode 100644 Resources/Images.xcassets/Icons/widgets/widget_network_status_offline.imageset/Contents.json create mode 100644 Resources/Images.xcassets/Icons/widgets/widget_network_status_offline.imageset/widget_network_status_offline_day.svg create mode 100644 Resources/Images.xcassets/Icons/widgets/widget_network_status_offline.imageset/widget_network_status_offline_night.svg create mode 100644 Resources/Images.xcassets/Icons/widgets/widget_network_status_online.imageset/Contents.json create mode 100644 Resources/Images.xcassets/Icons/widgets/widget_network_status_online.imageset/widget_network_status_online_day.svg create mode 100644 Resources/Images.xcassets/Icons/widgets/widget_network_status_online.imageset/widget_network_status_online_night.svg create mode 100644 Sources/Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index 0b2065e999..c9652b4a16 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -928,6 +928,7 @@ 46FB65B92A8BBC2000A21850 /* CoordinatesBaseWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46FB65B62A8BBC2000A21850 /* CoordinatesBaseWidget.swift */; }; 46FB65BA2A8BBC2000A21850 /* CoordinatesMapCenterWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46FB65B72A8BBC2000A21850 /* CoordinatesMapCenterWidget.swift */; }; 46FB65BC2A8BCF2300A21850 /* WidgetUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46FB65BB2A8BCF2200A21850 /* WidgetUtils.swift */; }; + 5A711AE72DCBDB390011F5F1 /* NetworkStatusWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A711AE62DCBDB390011F5F1 /* NetworkStatusWidget.swift */; }; 780AB027242B3730001DF5EF /* ic_custom_safari@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 780AB023242B372F001DF5EF /* ic_custom_safari@2x.png */; }; 780AB029242B3730001DF5EF /* ic_custom_safari@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 780AB024242B372F001DF5EF /* ic_custom_safari@3x.png */; }; 780AB02D242B683B001DF5EF /* img_empty_state_contour_lines@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 780AB02C242B683A001DF5EF /* img_empty_state_contour_lines@3x.png */; }; @@ -4585,6 +4586,7 @@ 46FB65B62A8BBC2000A21850 /* CoordinatesBaseWidget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoordinatesBaseWidget.swift; sourceTree = ""; }; 46FB65B72A8BBC2000A21850 /* CoordinatesMapCenterWidget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoordinatesMapCenterWidget.swift; sourceTree = ""; }; 46FB65BB2A8BCF2200A21850 /* WidgetUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WidgetUtils.swift; sourceTree = ""; }; + 5A711AE62DCBDB390011F5F1 /* NetworkStatusWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkStatusWidget.swift; sourceTree = ""; }; 780AB023242B372F001DF5EF /* ic_custom_safari@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "ic_custom_safari@2x.png"; path = "Resources/Icons/ic_custom_safari@2x.png"; sourceTree = ""; }; 780AB024242B372F001DF5EF /* ic_custom_safari@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "ic_custom_safari@3x.png"; path = "Resources/Icons/ic_custom_safari@3x.png"; sourceTree = ""; }; 780AB02C242B683A001DF5EF /* img_empty_state_contour_lines@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "img_empty_state_contour_lines@3x.png"; path = "Resources/Icons/img_empty_state_contour_lines@3x.png"; sourceTree = ""; }; @@ -13987,6 +13989,7 @@ DA8DC38E2A0CCFB2006C116B /* Widgets */ = { isa = PBXGroup; children = ( + 5A711AE62DCBDB390011F5F1 /* NetworkStatusWidget.swift */, DAABE4E72A16102100569F71 /* AverageSpeedWidget.swift */, DA7B18E42A1259160029967F /* BatteryWidget.swift */, DA7B18DF2A1227A30029967F /* CurrentTimeWidget.swift */, @@ -17075,6 +17078,7 @@ DA5A83C026C563A800F274C7 /* OAActionAddMapSourceViewController.mm in Sources */, DA5A854C26C563A900F274C7 /* OAWikiArticleHelper.mm in Sources */, DA5A848026C563A900F274C7 /* OATopTextView.mm in Sources */, + 5A711AE72DCBDB390011F5F1 /* NetworkStatusWidget.swift in Sources */, DA5A839626C563A800F274C7 /* OAPluginsViewController.mm in Sources */, DA5A816226C563A700F274C7 /* OAAppearance.m in Sources */, 32C1C4ED2DD4C4F200A053D4 /* OAClickableWayHelper.mm in Sources */, diff --git a/Resources/Images.xcassets/Icons/widgets/widget_network_status.imageset/Contents.json b/Resources/Images.xcassets/Icons/widgets/widget_network_status.imageset/Contents.json new file mode 100644 index 0000000000..2211220670 --- /dev/null +++ b/Resources/Images.xcassets/Icons/widgets/widget_network_status.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "widget_network_status_day.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "widget_network_status_night.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Resources/Images.xcassets/Icons/widgets/widget_network_status.imageset/widget_network_status_day.svg b/Resources/Images.xcassets/Icons/widgets/widget_network_status.imageset/widget_network_status_day.svg new file mode 100644 index 0000000000..c9d311a71d --- /dev/null +++ b/Resources/Images.xcassets/Icons/widgets/widget_network_status.imageset/widget_network_status_day.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Resources/Images.xcassets/Icons/widgets/widget_network_status.imageset/widget_network_status_night.svg b/Resources/Images.xcassets/Icons/widgets/widget_network_status.imageset/widget_network_status_night.svg new file mode 100644 index 0000000000..c9d311a71d --- /dev/null +++ b/Resources/Images.xcassets/Icons/widgets/widget_network_status.imageset/widget_network_status_night.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Resources/Images.xcassets/Icons/widgets/widget_network_status_offline.imageset/Contents.json b/Resources/Images.xcassets/Icons/widgets/widget_network_status_offline.imageset/Contents.json new file mode 100644 index 0000000000..a4279056d6 --- /dev/null +++ b/Resources/Images.xcassets/Icons/widgets/widget_network_status_offline.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "widget_network_status_offline_day.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "widget_network_status_offline_night.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Resources/Images.xcassets/Icons/widgets/widget_network_status_offline.imageset/widget_network_status_offline_day.svg b/Resources/Images.xcassets/Icons/widgets/widget_network_status_offline.imageset/widget_network_status_offline_day.svg new file mode 100644 index 0000000000..e7650c2b29 --- /dev/null +++ b/Resources/Images.xcassets/Icons/widgets/widget_network_status_offline.imageset/widget_network_status_offline_day.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Resources/Images.xcassets/Icons/widgets/widget_network_status_offline.imageset/widget_network_status_offline_night.svg b/Resources/Images.xcassets/Icons/widgets/widget_network_status_offline.imageset/widget_network_status_offline_night.svg new file mode 100644 index 0000000000..e7650c2b29 --- /dev/null +++ b/Resources/Images.xcassets/Icons/widgets/widget_network_status_offline.imageset/widget_network_status_offline_night.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Resources/Images.xcassets/Icons/widgets/widget_network_status_online.imageset/Contents.json b/Resources/Images.xcassets/Icons/widgets/widget_network_status_online.imageset/Contents.json new file mode 100644 index 0000000000..08b738412a --- /dev/null +++ b/Resources/Images.xcassets/Icons/widgets/widget_network_status_online.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "widget_network_status_online_day.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "widget_network_status_online_night.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/Resources/Images.xcassets/Icons/widgets/widget_network_status_online.imageset/widget_network_status_online_day.svg b/Resources/Images.xcassets/Icons/widgets/widget_network_status_online.imageset/widget_network_status_online_day.svg new file mode 100644 index 0000000000..8db6876351 --- /dev/null +++ b/Resources/Images.xcassets/Icons/widgets/widget_network_status_online.imageset/widget_network_status_online_day.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Resources/Images.xcassets/Icons/widgets/widget_network_status_online.imageset/widget_network_status_online_night.svg b/Resources/Images.xcassets/Icons/widgets/widget_network_status_online.imageset/widget_network_status_online_night.svg new file mode 100644 index 0000000000..8db6876351 --- /dev/null +++ b/Resources/Images.xcassets/Icons/widgets/widget_network_status_online.imageset/widget_network_status_online_night.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index 32a9255c81..7ba512e1aa 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -310,6 +310,7 @@ "current_time_widget_desc" = "Shows the current time as taken from your device."; "battery_widget_desc" = "Shows the battery level of your device."; "battery_widget_level_privacy_ios_desc" = "Due to iOS privacy reasons, the widget can only display the battery level in 5% steps, such as 35%, 60%, or 85%"; +"network_status_widget_desc" = "Displays the current network connection quality (Excellent, Fair, Poor, or Offline) by periodically checking internet latency."; "gps_info_widget_desc" = "Shows the number of satellites currently visible and used."; "altitude_widget_desc" = "Shows the height above sea level of current geolocation."; "max_speed_widget_desc" = "Displays the speed limit for the currently driven road."; @@ -430,6 +431,7 @@ "map_widget_camera_distance" = "Camera elevation"; "map_widget_zoom_level" = "Zoom level"; "map_widget_target_distance" = "Distance from camera to target"; +"map_widget_network_status" = "Network status"; "shared_string_collapse" = "Collapse"; "shared_string_topbar" = "Top bar"; @@ -1916,6 +1918,12 @@ "mapillary_menu_title_pano" = "Display only 360° images"; "mapil_select_user" = "Select user"; +// Network status widget +"network_status_widget_offline" = "Offline"; +"network_status_widget_poor" = "Poor"; +"network_status_widget_fair" = "Fair"; +"network_status_widget_excellent" = "Excellent"; + // Coordinate widget "coordinates_widget_current_location" = "Coordinates: current location"; "coordinates_widget_map_center" = "Coordinates: map center"; diff --git a/Sources/Controllers/Map/Widgets/Factory/MapWidgetsFactory.swift b/Sources/Controllers/Map/Widgets/Factory/MapWidgetsFactory.swift index 948c39a8bc..5811d62753 100644 --- a/Sources/Controllers/Map/Widgets/Factory/MapWidgetsFactory.swift +++ b/Sources/Controllers/Map/Widgets/Factory/MapWidgetsFactory.swift @@ -88,6 +88,8 @@ final class MapWidgetsFactory: NSObject { return CurrentTimeWidget(customId: customId, appMode: appMode, widgetParams: widgetParams) case .battery: return BatteryWidget(customId: customId, appMode: appMode, widgetParams: widgetParams) + case .networkStatus: + return NetworkStatusWidget(customId: customId, appMode: appMode, widgetParams: widgetParams) case .radiusRuler: return RulerDistanceWidget(customId: customId, appMode: appMode, widgetParams: widgetParams) case .sunrise: diff --git a/Sources/Controllers/Map/Widgets/WidgetType.swift b/Sources/Controllers/Map/Widgets/WidgetType.swift index 4b0b75d278..40702653b8 100644 --- a/Sources/Controllers/Map/Widgets/WidgetType.swift +++ b/Sources/Controllers/Map/Widgets/WidgetType.swift @@ -299,7 +299,7 @@ extension WidgetType { static let glideTarget = WidgetType(ordinal: 53, id: "glide_ratio_to_target", title: localizedString("glide_ratio_to_target"), descr: localizedString("map_widget_glide_target_desc"), iconName: "widget_glide_ratio_to_target", group: .glide, defaultPanel: .rightPanel) static let glideAverage = WidgetType(ordinal: 54, id: "average_glide_ratio", title: localizedString("average_glide_ratio"), descr: localizedString("map_widget_glide_average_desc"), iconName: "widget_glide_ratio_average", group: .glide, defaultPanel: .rightPanel) - + // Vehicle Metrics static let OBDSpeed = WidgetType(ordinal: 55, id: "obd_speed", title: localizedString("obd_widget_vehicle_speed"), descr: localizedString("obd_speed_desc"), iconName: "widget_obd_speed", group: .vehicleMetrics, defaultPanel: .rightPanel) static let OBDRpm = WidgetType(ordinal: 56, id: "obd_rpm", title: localizedString("obd_widget_engine_speed"), descr: localizedString("obd_rpm_desc"), iconName: "widget_obd_engine_speed", group: .vehicleMetrics, defaultPanel: .rightPanel) @@ -314,11 +314,13 @@ extension WidgetType { static let OBDCalculatedEngineLoad = WidgetType(ordinal: 65, id: "obd_calculated_engine_load", title: localizedString("obd_calculated_engine_load"), descr: localizedString("obd_calculated_engine_load_desc"), iconName: "widget_obd_engine_calculated_load", group: .vehicleMetrics, defaultPanel: .rightPanel) static let OBDThrottlePosition = WidgetType(ordinal: 66, id: "obd_throttle_position", title: localizedString("obd_throttle_position"), descr: localizedString("obd_throttle_position_desc"), iconName: "widget_obd_throttle_position", group: .vehicleMetrics, defaultPanel: .rightPanel) static let OBDFuelConsumption = WidgetType(ordinal: 67, id: "obd_fuel_consumption", title: localizedString("obd_fuel_consumption"), descr: localizedString("obd_fuel_consumption_desc"), iconName: "widget_obd_fuel_consumption", group: .vehicleMetrics, defaultPanel: .rightPanel) - + static let tripRecordingAverageSlope = WidgetType(ordinal: 68, id: "trip_recording_average_slope", title: localizedString("average_slope"), descr: localizedString("trip_recording_average_slope_widget_description"), iconName: "widget_track_recording_average_slope_uphill", group: .tripRecording, defaultPanel: .rightPanel) static let tripRecordingMaxSpeed = WidgetType(ordinal: 69, id: "trip_recording_max_speed", title: localizedString("shared_string_max_speed"), descr: localizedString("trip_recording_max_speed_widget_description"), iconName: "widget_track_recording_max_speed", group: .tripRecording, defaultPanel: .rightPanel) static let tripRecordingMovingTime = WidgetType(ordinal: 70, id: "trip_recording_moving_time", title: localizedString("trip_recording_moving_time"), descr: localizedString("trip_recording_moving_time_widget_description"), iconName: "widget_track_recording_moving_time", group: .tripRecording, defaultPanel: .rightPanel) - + + static let networkStatus = WidgetType(ordinal: 71, id: "network_status", title: localizedString("map_widget_network_status"), descr: localizedString("network_status_widget_desc"), iconName: "widget_network_status", defaultPanel: .rightPanel) + static let values = [nextTurn, smallNextTurn, secondNextTurn, @@ -403,6 +405,7 @@ extension WidgetType { OBDFuelConsumption, tripRecordingAverageSlope, tripRecordingMaxSpeed, - tripRecordingMovingTime + tripRecordingMovingTime, + networkStatus ] } diff --git a/Sources/Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift b/Sources/Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift new file mode 100644 index 0000000000..711f2a7512 --- /dev/null +++ b/Sources/Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift @@ -0,0 +1,171 @@ +import Foundation +import Network + +@objcMembers +final class NetworkStatusWidget: OASimpleWidget { + + // MARK: - Constants + + private enum Constants { + static let requestTimeout: TimeInterval = 2 + static let resourceTimeout: TimeInterval = 3 + static let updateInterval: TimeInterval = 5 + static let latencyRetryDelayNanoseconds: UInt64 = 500_000_000 // 0.5 seconds + } + + // MARK: - Properties + + private var lastPingTimestamp: TimeInterval = 0 + private var isLatencyCheckInProgress = false + private var isMonitoring = false + private var currentPath: NWPath? + + private let monitor = NWPathMonitor() + + private lazy var session: URLSession = { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = Constants.requestTimeout + config.timeoutIntervalForResource = Constants.resourceTimeout + return URLSession(configuration: config) + }() + + // MARK: - Initializers + + init(customId: String?, appMode: OAApplicationMode, widgetParams: ([String: Any])? = nil) { + super.init(type: .networkStatus) + configurePrefs(withId: customId, appMode: appMode, widgetParams: widgetParams) + startNetworkMonitoring() + } + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + monitor.cancel() + } + + // MARK: - Public Methods + + override func updateInfo() -> Bool { + let currentTime = Date().timeIntervalSince1970 + guard currentTime - lastPingTimestamp >= Constants.updateInterval else { return false } + + lastPingTimestamp = currentTime + Task { + await checkLatency() + } + return false + } + + // MARK: - Monitoring + + private func startNetworkMonitoring() { + guard !isMonitoring else { return } + isMonitoring = true + + monitor.pathUpdateHandler = { [weak self] path in + guard let self = self else { return } + self.currentPath = path + DispatchQueue.main.async { + if path.status != .satisfied { + self.updateStatus(.offline) + } else { + Task { + await self.checkLatency() + } + } + } + } + + monitor.start(queue: DispatchQueue(label: "NetworkMonitor")) + } + + private func isNetworkAvailable() -> Bool { + return currentPath?.status == .satisfied + } + + // MARK: - Latency Check + + private func latencyCheckURL() -> URL? { + return URL(string: "https://clients3.google.com/generate_204") + } + + private func determineNetworkQuality(for latency: Int) -> NetworkQuality { + switch latency { + case ..<80: + return .excellent + case ..<250: + return .fair + default: + return .poor + } + } + + @MainActor + private func updateStatus(_ quality: NetworkQuality) { + setIcon(quality.iconName) + setText(quality.label, subtext: nil) + } + + private func checkLatency(retries: Int = 1) async { + guard !isLatencyCheckInProgress, isNetworkAvailable(), let url = latencyCheckURL() else { + await MainActor.run { updateStatus(.offline) } + return + } + + isLatencyCheckInProgress = true + defer { isLatencyCheckInProgress = false } + + let startTime = Date() + + do { + let (_, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 204 else { + throw URLError(.badServerResponse) + } + + let latency = Int(Date().timeIntervalSince(startTime) * 1000) + let quality = determineNetworkQuality(for: latency) + await MainActor.run { updateStatus(quality) } + + } catch { + if retries > 0 { + try? await Task.sleep(nanoseconds: Constants.latencyRetryDelayNanoseconds) + await checkLatency(retries: retries - 1) + } else { + await MainActor.run { updateStatus(.offline) } + } + } + } +} + +// MARK: - Network Quality Enum + +fileprivate enum NetworkQuality { + case excellent + case fair + case poor + case offline + + var label: String { + switch self { + case .excellent: return localizedString("network_status_widget_excellent") + case .fair: return localizedString("network_status_widget_fair") + case .poor: return localizedString("network_status_widget_poor") + case .offline: return localizedString("network_status_widget_offline") + } + } + + var iconName: String { + switch self { + case .offline: return "widget_network_status_offline" + default: return "widget_network_status_online" + } + } +} + diff --git a/Sources/Controllers/Map/Widgets/WidgetsInitializer.swift b/Sources/Controllers/Map/Widgets/WidgetsInitializer.swift index a0a5590af0..99689f4f1f 100644 --- a/Sources/Controllers/Map/Widgets/WidgetsInitializer.swift +++ b/Sources/Controllers/Map/Widgets/WidgetsInitializer.swift @@ -75,6 +75,7 @@ class WidgetsInitializer: NSObject, WidgetRegistrationDelegate { addWidgetInfo(.gpsInfo) addWidgetInfo(.currentTime) addWidgetInfo(.battery) + addWidgetInfo(.networkStatus) addWidgetInfo(.radiusRuler) addWidgetInfo(.timeToIntermediate) addWidgetInfo(.timeToDestination) From 1b4712fb72cfbf111b635a6f174e08c30dd6b169 Mon Sep 17 00:00:00 2001 From: isidoro98 Date: Thu, 8 May 2025 00:40:43 +0200 Subject: [PATCH 2/4] Add isUpdateNeeded --- .../Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift b/Sources/Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift index 711f2a7512..304f523cd3 100644 --- a/Sources/Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift +++ b/Sources/Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift @@ -53,7 +53,7 @@ final class NetworkStatusWidget: OASimpleWidget { override func updateInfo() -> Bool { let currentTime = Date().timeIntervalSince1970 - guard currentTime - lastPingTimestamp >= Constants.updateInterval else { return false } + guard isUpdateNeeded() || currentTime - lastPingTimestamp >= Constants.updateInterval else { return false } lastPingTimestamp = currentTime Task { From ef132191191289148f2c2a2dcc1b7386e3ba754f Mon Sep 17 00:00:00 2001 From: isidoro98 Date: Fri, 30 Jan 2026 16:24:27 +0000 Subject: [PATCH 3/4] Update network status - Include update interval setting - Include endpoint url override option - Improve network quality detection by leveraging jitter and failed requests - Update latency thresholds --- .../en.lproj/Localizable.strings | 5 +- .../Widgets/Widgets/NetworkStatusWidget.swift | 289 ++++++++++++++++-- .../WidgetConfigurationViewController.swift | 117 +++++++ 3 files changed, 380 insertions(+), 31 deletions(-) diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index 7ba512e1aa..5354a3b37c 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -310,7 +310,7 @@ "current_time_widget_desc" = "Shows the current time as taken from your device."; "battery_widget_desc" = "Shows the battery level of your device."; "battery_widget_level_privacy_ios_desc" = "Due to iOS privacy reasons, the widget can only display the battery level in 5% steps, such as 35%, 60%, or 85%"; -"network_status_widget_desc" = "Displays the current network connection quality (Excellent, Fair, Poor, or Offline) by periodically checking internet latency."; +"network_status_widget_desc" = "Displays the current network connection quality by periodically sending a burst of requests and measuring latency, jitter, and packet loss.\n\nQuality levels:\n• Excellent — latency < 100 ms\n• Fair — latency 100–299 ms\n• Poor — latency ≥ 300 ms\n• Offline — no connection\n\nHigh jitter or packet loss may further downgrade the reported quality."; "gps_info_widget_desc" = "Shows the number of satellites currently visible and used."; "altitude_widget_desc" = "Shows the height above sea level of current geolocation."; "max_speed_widget_desc" = "Displays the speed limit for the currently driven road."; @@ -1923,6 +1923,9 @@ "network_status_widget_poor" = "Poor"; "network_status_widget_fair" = "Fair"; "network_status_widget_excellent" = "Excellent"; +"network_status_update_interval_desc" = "Specify the time interval for checking network latency."; +"network_status_url" = "URL"; +"network_status_url_desc" = "URL used for latency measurement"; // Coordinate widget "coordinates_widget_current_location" = "Coordinates: current location"; diff --git a/Sources/Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift b/Sources/Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift index 304f523cd3..8bac81511c 100644 --- a/Sources/Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift +++ b/Sources/Controllers/Map/Widgets/Widgets/NetworkStatusWidget.swift @@ -5,16 +5,48 @@ import Network final class NetworkStatusWidget: OASimpleWidget { // MARK: - Constants - + private enum Constants { static let requestTimeout: TimeInterval = 2 static let resourceTimeout: TimeInterval = 3 - static let updateInterval: TimeInterval = 5 static let latencyRetryDelayNanoseconds: UInt64 = 500_000_000 // 0.5 seconds + static let latencyBurstCount = 3 + + // Latency thresholds (ms): below excellent = excellent, below fair = fair, at or above fair = poor + static let excellentLatencyThreshold = 100 + static let fairLatencyThreshold = 300 + + // Jitter (ms) above which quality is downgraded by one tier + static let jitterDowngradeThreshold = 100 + + // Packet loss ratio at or above which quality is downgraded by one tier + static let packetLossDowngradeThreshold = 0.34 } + private struct BurstResult { + let averageLatency: Int // ms + let jitter: Int // ms, max - min of samples (0 if ≤1 sample) + let packetLossRatio: Double // 0.0–1.0 + + init(samples: [Int], totalAttempts: Int) { + if samples.isEmpty { + averageLatency = 0; jitter = 0; packetLossRatio = 1.0 + } else { + averageLatency = samples.reduce(0, +) / samples.count + jitter = samples.count > 1 ? (samples.max()! - samples.min()!) : 0 + packetLossRatio = Double(totalAttempts - samples.count) / Double(totalAttempts) + } + } + } + + static let UPDATE_INTERVAL_PREF_ID = "network_status_update_interval" + static let LATENCY_CHECK_URL_PREF_ID = "network_status_latency_check_url" + + static let defaultUpdateInterval = 5 + static let defaultLatencyCheckURL = "https://clients3.google.com/generate_204" + // MARK: - Properties - + private var lastPingTimestamp: TimeInterval = 0 private var isLatencyCheckInProgress = false private var isMonitoring = false @@ -22,6 +54,12 @@ final class NetworkStatusWidget: OASimpleWidget { private let monitor = NWPathMonitor() + private var updateIntervalPref: OACommonLong + private var latencyCheckURLPref: OACommonString + private var customId: String? + + private static var availableIntervals: [Int: String] = getAvailableIntervals() + private lazy var session: URLSession = { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = Constants.requestTimeout @@ -31,13 +69,20 @@ final class NetworkStatusWidget: OASimpleWidget { // MARK: - Initializers - init(customId: String?, appMode: OAApplicationMode, widgetParams: ([String: Any])? = nil) { - super.init(type: .networkStatus) + convenience init(customId: String?, appMode: OAApplicationMode, widgetParams: ([String: Any])? = nil) { + self.init(frame: .zero) + + widgetType = .networkStatus + self.customId = customId configurePrefs(withId: customId, appMode: appMode, widgetParams: widgetParams) + updateIntervalPref = Self.registerUpdateIntervalPref(customId, appMode: appMode, widgetParams: widgetParams) + latencyCheckURLPref = Self.registerLatencyCheckURLPref(customId, appMode: appMode, widgetParams: widgetParams) startNetworkMonitoring() } override init(frame: CGRect) { + updateIntervalPref = Self.registerUpdateIntervalPref(nil) + latencyCheckURLPref = Self.registerLatencyCheckURLPref(nil) super.init(frame: frame) } @@ -53,7 +98,8 @@ final class NetworkStatusWidget: OASimpleWidget { override func updateInfo() -> Bool { let currentTime = Date().timeIntervalSince1970 - guard isUpdateNeeded() || currentTime - lastPingTimestamp >= Constants.updateInterval else { return false } + let interval = TimeInterval(updateIntervalPref.get()) + guard isUpdateNeeded() || currentTime - lastPingTimestamp >= interval else { return false } lastPingTimestamp = currentTime Task { @@ -62,6 +108,136 @@ final class NetworkStatusWidget: OASimpleWidget { return false } + // MARK: - Settings + + override func getSettingsData(_ appMode: OAApplicationMode, + widgetConfigurationParams: [String: Any]?, + isCreate: Bool) -> OATableDataModel? { + let data = OATableDataModel() + let section = data.createNewSection() + section.headerText = localizedString("shared_string_settings") + + // Interval row + let intervalRow = section.createNewRow() + intervalRow.cellType = OAValueTableViewCell.getIdentifier() + intervalRow.key = "value_pref" + intervalRow.title = localizedString("shared_string_interval") + intervalRow.setObj(updateIntervalPref, forKey: "pref") + + var currentValue = Self.defaultUpdateInterval + if let widgetConfigurationParams, + let key = widgetConfigurationParams.keys.first(where: { $0.hasPrefix(Self.UPDATE_INTERVAL_PREF_ID) }), + let value = widgetConfigurationParams[key] as? String, + let widgetValue = Int(value) { + currentValue = widgetValue + } else if !isCreate { + currentValue = updateIntervalPref.get(appMode) + } + intervalRow.setObj(Self.getIntervalTitle(currentValue), forKey: "value") + intervalRow.setObj(getPossibleValues(), forKey: "possible_values") + intervalRow.setObj(localizedString("network_status_update_interval_desc"), forKey: "footer") + + // URL row + let urlRow = section.createNewRow() + urlRow.cellType = OAInputTableViewCell.getIdentifier() + urlRow.key = "url_input" + urlRow.title = localizedString("network_status_url") + + var currentURL = Self.defaultLatencyCheckURL + if let widgetConfigurationParams, + let key = widgetConfigurationParams.keys.first(where: { $0.hasPrefix(Self.LATENCY_CHECK_URL_PREF_ID) }), + let value = widgetConfigurationParams[key] as? String, + !value.isEmpty { + currentURL = value + } else if !isCreate { + let stored = latencyCheckURLPref.get(appMode) + if !stored.isEmpty { + currentURL = stored + } + } + urlRow.setObj(currentURL, forKey: "value") + urlRow.setObj(Self.defaultLatencyCheckURL, forKey: "default_value") + urlRow.setObj(latencyCheckURLPref, forKey: "pref") + urlRow.descr = localizedString("network_status_url_desc") + + return data + } + + override func copySettings(_ appMode: OAApplicationMode, customId: String?) { + Self.registerUpdateIntervalPref(customId).set(updateIntervalPref.get(appMode), mode: appMode) + Self.registerLatencyCheckURLPref(customId).set(latencyCheckURLPref.get(appMode), mode: appMode) + } + + // MARK: - Preference Registration + + @discardableResult + static func registerUpdateIntervalPref(_ customId: String?, + appMode: OAApplicationMode? = nil, + widgetParams: ([String: Any])? = nil) -> OACommonLong { + let settings = OAAppSettings.sharedManager() + let prefId = customId == nil || customId!.isEmpty + ? Self.UPDATE_INTERVAL_PREF_ID + : Self.UPDATE_INTERVAL_PREF_ID + customId! + + let preference = settings.registerLongPreference(prefId, defValue: defaultUpdateInterval) + if let appMode, let string = widgetParams?[Self.UPDATE_INTERVAL_PREF_ID] as? String, let widgetValue = Int(string) { + preference.set(widgetValue, mode: appMode) + } + return preference + } + + @discardableResult + static func registerLatencyCheckURLPref(_ customId: String?, + appMode: OAApplicationMode? = nil, + widgetParams: ([String: Any])? = nil) -> OACommonString { + let settings = OAAppSettings.sharedManager() + let prefId = customId == nil || customId!.isEmpty + ? Self.LATENCY_CHECK_URL_PREF_ID + : Self.LATENCY_CHECK_URL_PREF_ID + customId! + + let preference = settings.registerStringPreference(prefId, defValue: defaultLatencyCheckURL) + if let appMode, let value = widgetParams?[Self.LATENCY_CHECK_URL_PREF_ID] as? String, !value.isEmpty { + preference.set(value, mode: appMode) + } + return preference + } + + // MARK: - Interval Helpers + + static func getAvailableIntervals() -> [Int: String] { + let intervals = [3, 5, 10, 15, 30, 60, 120, 180, 300] + var result = [Int: String]() + for seconds in intervals { + let timeInterval: String + let timeUnit: String + if seconds < 60 { + timeInterval = String(seconds) + timeUnit = localizedString("shared_string_sec") + } else { + timeInterval = String(seconds / 60) + timeUnit = localizedString("int_min") + } + let formatted = String(format: localizedString("ltr_or_rtl_combine_via_space"), arguments: [timeInterval, timeUnit]) + result[seconds] = formatted + } + return result + } + + static func getIntervalTitle(_ intervalValue: Int) -> String { + availableIntervals[intervalValue] ?? "-" + } + + private func getPossibleValues() -> [OATableRowData] { + var rows = [OATableRowData]() + let valuesRow = OATableRowData() + valuesRow.key = "values" + valuesRow.cellType = OASegmentSliderTableViewCell.getIdentifier() + valuesRow.title = localizedString("shared_string_interval") + valuesRow.setObj(Self.availableIntervals, forKey: "values") + rows.append(valuesRow) + return rows + } + // MARK: - Monitoring private func startNetworkMonitoring() { @@ -92,17 +268,38 @@ final class NetworkStatusWidget: OASimpleWidget { // MARK: - Latency Check private func latencyCheckURL() -> URL? { - return URL(string: "https://clients3.google.com/generate_204") + let stored = latencyCheckURLPref.get() + if !stored.isEmpty { + return URL(string: stored) + } + return URL(string: Self.defaultLatencyCheckURL) } - private func determineNetworkQuality(for latency: Int) -> NetworkQuality { - switch latency { - case ..<80: - return .excellent - case ..<250: - return .fair - default: - return .poor + private func determineNetworkQuality(from result: BurstResult) -> NetworkQuality { + // Base quality from average latency + let baseQuality: NetworkQuality + if result.averageLatency < Constants.excellentLatencyThreshold { + baseQuality = .excellent + } else if result.averageLatency < Constants.fairLatencyThreshold { + baseQuality = .fair + } else { + baseQuality = .poor + } + + // Count downgrade factors + var downgrades = 0 + if result.jitter > Constants.jitterDowngradeThreshold { downgrades += 1 } + if result.packetLossRatio >= Constants.packetLossDowngradeThreshold { downgrades += 1 } + + return applyDowngrades(to: baseQuality, count: downgrades) + } + + private func applyDowngrades(to quality: NetworkQuality, count: Int) -> NetworkQuality { + guard count > 0 else { return quality } + switch quality { + case .excellent: return count >= 2 ? .poor : .fair + case .fair: return .poor + case .poor, .offline: return quality } } @@ -112,7 +309,7 @@ final class NetworkStatusWidget: OASimpleWidget { setText(quality.label, subtext: nil) } - private func checkLatency(retries: Int = 1) async { + private func checkLatency() async { guard !isLatencyCheckInProgress, isNetworkAvailable(), let url = latencyCheckURL() else { await MainActor.run { updateStatus(.offline) } return @@ -121,29 +318,62 @@ final class NetworkStatusWidget: OASimpleWidget { isLatencyCheckInProgress = true defer { isLatencyCheckInProgress = false } - let startTime = Date() + var samples = [Int]() + for _ in 0.. 0 { - try? await Task.sleep(nanoseconds: Constants.latencyRetryDelayNanoseconds) - await checkLatency(retries: retries - 1) - } else { - await MainActor.run { updateStatus(.offline) } + private func measureSingleLatency(url: URL) async -> Int? { + let delegate = LatencyMetricsDelegate() + let startTime = Date() + do { + let (_, response) = try await session.data(for: URLRequest(url: url), delegate: delegate) + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { + return nil } + // Prefer metrics-based timing (HTTP request/response only) + if let transaction = delegate.metrics?.transactionMetrics.last, + let requestStart = transaction.requestStartDate, + let responseEnd = transaction.responseEndDate { + return Int(responseEnd.timeIntervalSince(requestStart) * 1000) + } + // Fallback to wall-clock timing + return Int(Date().timeIntervalSince(startTime) * 1000) + } catch { + return nil } } } +// MARK: - Latency Metrics Delegate + +private final class LatencyMetricsDelegate: NSObject, URLSessionTaskDelegate { + var metrics: URLSessionTaskMetrics? + + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + self.metrics = metrics + } +} + // MARK: - Network Quality Enum fileprivate enum NetworkQuality { @@ -168,4 +398,3 @@ fileprivate enum NetworkQuality { } } } - diff --git a/Sources/Controllers/Settings/WidgetSettings/WidgetConfigurationViewController.swift b/Sources/Controllers/Settings/WidgetSettings/WidgetConfigurationViewController.swift index 84d4868197..b918a13736 100644 --- a/Sources/Controllers/Settings/WidgetSettings/WidgetConfigurationViewController.swift +++ b/Sources/Controllers/Settings/WidgetSettings/WidgetConfigurationViewController.swift @@ -41,12 +41,25 @@ final class WidgetConfigurationViewController: OABaseButtonsViewController, Widg configureNavigationButtons() } + override func registerNotifications() { + super.registerNotifications() + NotificationCenter.default.addObserver(self, + selector: #selector(keyboardWillShow(_:)), + name: UIResponder.keyboardWillShowNotification, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(keyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil) + } + override func registerCells() { addCell(OASimpleTableViewCell.reuseIdentifier) addCell(OASwitchTableViewCell.reuseIdentifier) addCell(OAValueTableViewCell.reuseIdentifier) addCell(SegmentImagesWithRightLabelTableViewCell.reuseIdentifier) addCell(OAButtonTableViewCell.reuseIdentifier) + addCell(OAInputTableViewCell.reuseIdentifier) } override func generateData() { @@ -212,6 +225,31 @@ final class WidgetConfigurationViewController: OABaseButtonsViewController, Widg cell.button.setContentHuggingPriority(.required, for: .horizontal) cell.button.setContentCompressionResistancePriority(.required, for: .horizontal) outCell = cell + } else if item.cellType == OAInputTableViewCell.getIdentifier() { + let cell = tableView.dequeueReusableCell(withIdentifier: OAInputTableViewCell.reuseIdentifier, for: indexPath) as! OAInputTableViewCell + cell.selectionStyle = .none + cell.leftIconVisibility(false) + cell.descriptionVisibility(false) + cell.titleLabel.text = item.title + cell.inputFieldVisibility(true) + + let currentValue = item.obj(forKey: "value") as? String ?? "" + let defaultValue = item.obj(forKey: "default_value") as? String ?? "" + cell.inputField.text = currentValue + cell.inputField.placeholder = item.descr + cell.inputField.keyboardType = .URL + cell.inputField.autocorrectionType = .no + cell.inputField.autocapitalizationType = .none + cell.inputField.returnKeyType = .done + cell.inputField.delegate = self + cell.inputField.tag = indexPath.section << 10 | indexPath.row + + let showClear = !currentValue.isEmpty && currentValue != defaultValue + cell.clearButtonVisibility(showClear) + cell.clearButtonArea.removeTarget(nil, action: nil, for: .allEvents) + cell.clearButtonArea.tag = indexPath.section << 10 | indexPath.row + cell.clearButtonArea.addTarget(self, action: #selector(onClearURLButtonPressed(_:)), for: .touchUpInside) + outCell = cell } return outCell } @@ -464,6 +502,85 @@ final class WidgetConfigurationViewController: OABaseButtonsViewController, Widg return false } + + @objc private func onClearURLButtonPressed(_ sender: UIButton) { + let indexPath = IndexPath(row: sender.tag & 0x3FF, section: sender.tag >> 10) + let item = tableData.item(for: indexPath) + let defaultValue = item.obj(forKey: "default_value") as? String ?? "" + + if let pref = item.obj(forKey: "pref") as? OACommonString { + if createNew { + widgetConfigurationParams?[pref.key] = defaultValue + } else { + pref.set(defaultValue, mode: selectedAppMode) + } + } + + generateData() + tableView.reloadData() + } +} + +// MARK: - Keyboard Avoidance + +extension WidgetConfigurationViewController { + + @objc private func keyboardWillShow(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let keyboardFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue, + let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double, + let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { + return + } + let options = UIView.AnimationOptions(rawValue: curveValue << 16) + UIView.animate(withDuration: duration, delay: 0, options: options) { + var insets = self.tableView.contentInset + insets.bottom = keyboardFrame.height + self.tableView.contentInset = insets + self.tableView.scrollIndicatorInsets = insets + } + } + + @objc private func keyboardWillHide(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double, + let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt else { + return + } + let options = UIView.AnimationOptions(rawValue: curveValue << 16) + UIView.animate(withDuration: duration, delay: 0, options: options) { + var insets = self.tableView.contentInset + insets.bottom = 0 + self.tableView.contentInset = insets + self.tableView.scrollIndicatorInsets = insets + } + } +} + +// MARK: - UITextFieldDelegate +extension WidgetConfigurationViewController: UITextFieldDelegate { + + func textFieldDidEndEditing(_ textField: UITextField) { + let indexPath = IndexPath(row: textField.tag & 0x3FF, section: textField.tag >> 10) + let item = tableData.item(for: indexPath) + let newValue = textField.text ?? "" + + if let pref = item.obj(forKey: "pref") as? OACommonString { + if createNew { + widgetConfigurationParams?[pref.key] = newValue + } else { + pref.set(newValue, mode: selectedAppMode) + } + } + + generateData() + tableView.reloadData() + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } } // MARK: Appearance From b99983cc65269fc8cfc7e6d37fbd78848a4d84b1 Mon Sep 17 00:00:00 2001 From: isidoro98 Date: Fri, 30 Jan 2026 16:36:38 +0000 Subject: [PATCH 4/4] Update WidgetConfigurationViewController.swift --- .../WidgetSettings/WidgetConfigurationViewController.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/Controllers/Settings/WidgetSettings/WidgetConfigurationViewController.swift b/Sources/Controllers/Settings/WidgetSettings/WidgetConfigurationViewController.swift index b918a13736..3c97f56537 100644 --- a/Sources/Controllers/Settings/WidgetSettings/WidgetConfigurationViewController.swift +++ b/Sources/Controllers/Settings/WidgetSettings/WidgetConfigurationViewController.swift @@ -246,6 +246,9 @@ final class WidgetConfigurationViewController: OABaseButtonsViewController, Widg let showClear = !currentValue.isEmpty && currentValue != defaultValue cell.clearButtonVisibility(showClear) + cell.clearButton.removeTarget(nil, action: nil, for: .allEvents) + cell.clearButton.tag = indexPath.section << 10 | indexPath.row + cell.clearButton.addTarget(self, action: #selector(onClearURLButtonPressed(_:)), for: .touchUpInside) cell.clearButtonArea.removeTarget(nil, action: nil, for: .allEvents) cell.clearButtonArea.tag = indexPath.section << 10 | indexPath.row cell.clearButtonArea.addTarget(self, action: #selector(onClearURLButtonPressed(_:)), for: .touchUpInside)